DevelopersGuides
Bots and arbitrage
How to build takers, routers and arbitrage bots on AXIS: reading the book, sizing order lists, racing other takers, crossfills and multi-hop swaps.
How a bot trades on AXIS#
The contract never searches for liquidity. Your bot decides which order IDs to list, and the contract re-checks each one, settles what it can and skips the rest. Three entry points take order lists:
| Call | Use it for | See |
|---|---|---|
trade |
Taking one market as Fill (market order), FillOrKill or Limit (store the remainder) |
Trades, swaps and crossfills |
swap |
Routes across several markets in one call, with an exact input or an exact output and a bound on the other side | Same page |
crossfill |
Crossing an existing order against cheaper counter orders and keeping the spread | Crossfill arbitrage |
The snippets on this page share this setup:
import {AxisApiClient, AxisContractClient, OrderKind, TradeDirection} from '@axis-markets/client'
import {Keypair, Networks, contract} from '@stellar/stellar-sdk'
const AXIS_CONTRACT = 'CA6P26K4QNNIMTYP22ILTSCXQQDEKNWJIZJPC34YEZYOLBNT7YUPX7XS'
const RPC_URL = 'https://soroban-testnet.stellar.org'
const XLM = 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC'
const USDC = 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA'
const EURC = 'CCUUDM434BMZMYWYDITHFXHDMIVTGGD6T2I5UKNX5BSLXLW7HVR4MCGZ'
const SCALE = 10n ** 18n
const bot = Keypair.fromSecret(process.env.BOT_SECRET)
const {signTransaction} = contract.basicNodeSigner(bot, Networks.TESTNET)
const api = new AxisApiClient('https://demo-api.axis.markets')
const client = new AxisContractClient({
publicKey: bot.publicKey(),
signTransaction,
rpcUrl: RPC_URL,
contractId: AXIS_CONTRACT,
networkPassphrase: Networks.TESTNET
})
Like any taker, the bot needs an allowance on every token it sells: pass approve with the call or grant a standing allowance (see Settlement and allowances).
Reading the book#
The AXIS API#
GET /order?asset=A&asset=Blists the live orders of a pair, both sides, oldest first, up to 200 per page. Every order carriesbacked, the amount its maker can currently deliver.GET /quotereturns ready-made routes with the order IDs to list.GET /depthaggregates price levels.GET /backing?owner=&asset=returns a maker'sbalance,allowance,liveUntil,authorized,budget,headroom(room left under the trustline limit, present for trustline assets) and the time of the lastskippedfill in that token.- The WebSocket channels
depth,tradesandcontractpush aggregated changes. No channel streams the individual orders of all makers, so poll/orderor run your own indexer when you need order-level updates. See WebSocket API.
// every live order on the XLM/USDC pair, both sides
async function loadPair(a, b) {
const orders = []
let cursor
while (true) {
const page = await api.getOrders({asset: [a, b], cursor, limit: 200})
orders.push(...page)
if (page.length < 200)
return orders
cursor = page[page.length - 1].cursor
}
}
// a taker prefers lower maker prices on both sides, the sort keeps equal prices oldest first
const byPrice = (x, y) => Number(BigInt(x.price) - BigInt(y.price))
const orders = await loadPair(XLM, USDC)
const asks = orders.filter(order => order.selling === XLM).sort(byPrice) // price in USDC per XLM
const bids = orders.filter(order => order.selling === USDC).sort(byPrice) // price in XLM per USDC
Prices are 18-decimal integers in the orientation of the stored order: units of buying per unit of selling. Both sides are sell orders, so for a taker a lower price is better on either side.
A self-hosted indexer#
@axis-markets/indexer runs inside your own Node process: you read the book from memory, react to events (order, trade, backing, contract, ledger) as soon as your DataSource delivers them, and apply your own rules for routing, backing, cooldowns and order age. You supply the DataSource that delivers contract events and reads balances and allowances, and a durable HistoryStorage. The indexer never routes, signs or submits: that part is your bot, and the contract treats every caller the same, whichever book view it used. See Indexer.
Choosing orders#
The contract walks your list in order and stops when the taker amount is used up. List the best prices first, and leave out what the contract would skip anyway:
- Price limit. Orders priced beyond the taker's threshold are skipped silently:
floor(10^36 / price)on the maker's price for aSelltrade,priceitself for aBuy. See Matching thresholds. - Effective depth. A maker's budget per token is
min(balance, allowance), zero once the allowance expired or while the maker's trustline is unauthorized. The contract shares the budget across that maker's orders in your list order and admits a fill only when the remaining budget covers all of it, so size each fill of a partially backed order to its backing, or leave the order out. The indexer'sbackedfield splits the budget oldest first, which can differ from your list order. - Receive side. The maker must be able to receive the asset you pay with. A missing or deauthorized trustline turns the fill into a skip, but a full trustline fails the whole call with the token's error. Leave out makers whose
headroomin the asset you pay with is smaller than the payment, as the AXIS API does. - Skip cooldowns. After a
skipevent for a maker, do not list that maker in that token again until its backing has been read again. The AXIS API leaves such makers out for 10 minutes by default. Cooldowns come fromskipevents only: a failed transaction changes nothing on-chain and points at no one. - Expiry and age. Leave out orders that expire within seconds (the AXIS API drops those expiring within the next 10 seconds) and orders whose lifetime is about to run out: restoring an archived order costs you a fresh rent chunk, and an archived spare order the simulation does not reach fails the transaction (see Archival and restore).
- Your own orders. Self-trades are allowed. Filter your own orders out of the list unless you mean to trade with them.
Sizing order lists#
The binding limit is the 16,384-byte per-transaction cap on contract events: a fill costs about 792 bytes, an order skipped with a skip event 84 bytes more, and silent skips nothing. The resulting budgets:
| Call | Most fills | Room for skip events at that many fills |
|---|---|---|
trade, Fill or Fill-or-Kill |
20 | 3 |
trade, Limit that stores a remainder (new event, about 268 B) |
20 | None, about 9 at 19 fills |
swap (swap event and the forward to the trader, about 236 B each) |
20, summed across all hops | None, about 10 at 19 fills |
crossfill (its own trade event and the payouts) |
19 maker fills | About 6 |
The Fill row is measured. The other rows are derived from the measured per-fill formulas. Each fill you give up frees room for about 9 more skips.
Read-write entries are the second limit. The JS client declares 4 read-write entries per listed order plus the settlement balances, and a transaction holds 200, so keep a list under about 48 orders from distinct makers. Every declared read-write entry pays its write fee whether or not it is used, so list the orders you need plus a few spares, not the whole book. See Resource limits and costs.
Racing other takers#
Other takers see the same book. Between your simulation and the ledger that includes your transaction, they can fill, shrink or remove the orders you listed, and makers can reprice them. Your transaction stays valid anyway: you sign the call arguments and an optional approve, never the amounts paid to individual makers.
| What happened to a listed order | Result |
|---|---|
| Filled, removed or expired | Skipped silently, your trade fills less |
| Partly filled | You fill what is left |
| Repriced beyond your threshold | Skipped silently |
| Its maker lost the backing | Skipped with a skip event |
| Its maker's trustline for your asset filled up | The whole call fails with the token's error. Leave the maker out and retry. |
A Fill trade then succeeds with fewer fills, a Fill-or-Kill fails with NotFilled (709) when it can no longer fill in full, and a swap fails with NotFilled when it can no longer meet its bounds. To lose races gracefully, list a few spare orders behind the ones you need, set the limit to the worst price you accept (the quote's worstPrice for a Buy, invertPrice(worstPrice) for a Sell), and use Fill-or-Kill or swap bounds when a partial result is useless to you.
More pitfalls:
- Trustline room. Keep room under your trustline limit for the asset you buy, since the contract forwards what the makers delivered in one transfer at the end of the call. When it does not fit, a
tradefails withCannotReceive(708) and no maker is flagged. - Token errors. A payment to a maker that fails, whether you cannot pay or the maker cannot be credited, fails the whole call with the token's own error (
tokenError: truein the JS client). Check your own balance and allowance, leave out makers without room for the payment and retry. See Who is at fault. - Authorized assets. Buying an asset whose issuer requires authorization fails with
IntermediaryCannotReceive(712) until the issuer has authorized the AXIS contract. The same applies to every asset of aswappath. - Uncertain submissions. When a submission times out, look up the transaction, or the order by its ID, before retrying. The JS client draws a new nonce on every call, so a blind retry can trade a second time.
Footprint completion#
Simulation records only the ledger entries the simulated run touched, but an order that was skipped or never reached during simulation can match at execution, and the transaction then fails if that order and its maker's entries were not declared read-write. AxisContractClient declares every listed order, its maker's entries and the settlement balances by default (autoFootprint) for Stellar Asset Contract tokens, and @axis-markets/client/footprint exports the same helpers for custom pipelines that call the contract through the generated client with their own nonce. See Footprint completion.
Crossfill arbitrage#
A Limit trade only matches the orders it lists, so the book can end up crossed: an order selling XLM for USDC can be created below the price at which another order buys XLM. crossfill settles such a pair and pays the caller the difference.
Crossed books are also how large orders get executed. A trader can place a big Limit order at their own price without listing the orders it crosses, and arbitragers compete to crossfill it in chunks against all the liquidity on the other side. The owner receives exactly their price on every chunk, and each caller keeps the difference.
How it settles: the existing order T acts as the taker. The makers deliver to the contract, and T's owner, not the caller, pays them through the owner's own allowance. T's owner receives exactly ceil(paid * T.price / 10^18), its own limit, and the rest goes to the caller. The caller needs no balance in either asset, only the transaction fee and the ability to receive T's buying asset. See Crossfill.
Finding crossed orders#
With both sides sorted as above, the book is crossed when the best ask and the best bid satisfy ask.price * bid.price < 10^36. Equal means the orders touch, and there is nothing to earn.
const crossed = asks.length > 0 && bids.length > 0 &&
BigInt(asks[0].price) * BigInt(bids[0].price) < SCALE * SCALE
Pick the taker order T on one side: the surplus is paid in T's buying asset. The counter orders M are on the other side, priced at most floor(10^36 / T.price). T must be backed: a taker order with no budget, or whose owner cannot receive T's buying asset, is skipped with a skip event naming T, and the call returns (0, 0, 0) while you still pay the fee.
Computing the surplus#
The surplus of a fill is roughly what the maker delivers times 1 - M.price * T.price / 10^36. To get exact numbers, mirror the contract's rounding:
const ceilDiv = (a, b) => (a + b - 1n) / b
// estimate a crossfill: `taker` is the existing order that pays, `makers` the counter orders in list order.
// `taker.budget` is its owner's budget in the token T sells (`budget` from GET /backing)
function estimateCrossfill(taker, makers) {
const threshold = SCALE * SCALE / taker.price
let left = taker.amount < taker.budget ? taker.amount : taker.budget
let paid = 0n
let received = 0n
const orders = []
for (const maker of makers) {
if (left <= 0n)
break
if (maker.price > threshold)
continue // not crossed
// the whole order when the budget covers it, otherwise the part it affords
let bought = maker.amount
let sold = ceilDiv(maker.amount * maker.price, SCALE)
if (sold > left) {
bought = left * SCALE / maker.price
sold = ceilDiv(bought * maker.price, SCALE)
}
if (bought === 0n || bought > maker.backed)
continue // the maker cannot back this fill: skipped
if (bought < ceilDiv(sold * taker.price, SCALE))
continue // the fill would leave the caller short: skipped
left -= sold
paid += sold
received += bought
orders.push(maker.id)
}
return {orders, paid, received, surplus: received - ceilDiv(paid * taker.price, SCALE)}
}
Example: T sells 100 XLM for at least 0.20 USDC each. M sells 25 USDC at 4.8 XLM per USDC, which is a bid of about 0.2083 USDC per XLM.
const T = {amount: 1_000_000_000n, budget: 1_000_000_000n, price: 200_000_000_000_000_000n}
const M = {id: 2n, amount: 250_000_000n, backed: 250_000_000n, price: 4_800_000_000_000_000_000n}
estimateCrossfill(T, [M])
// {orders: [2n], paid: 999999999n, received: 208333333n, surplus: 8333333n}
T's owner pays 99.9999999 XLM, M delivers 20.8333333 USDC, T's owner receives 20 USDC and the caller keeps 0.8333333 USDC. T's leftover of one base unit is dust, so T is removed.
Executing#
import {Axis} from '@axis-markets/client'
// high level: pass the counter orders found with estimateCrossfill. Without `makerIds` the account quotes T's whole
// amount, which lists nothing unless the other side can absorb all of it, and still submits the call
const axis = new Axis({apiUrl: 'https://demo-api.axis.markets', rpcUrl: RPC_URL, contractId: AXIS_CONTRACT, networkPassphrase: Networks.TESTNET})
await axis.connect()
const account = axis.account(bot.publicKey(), {signTransaction})
await account.ready
const {sold, bought, surplus} = await account.crossfill(takerOrderId, makerIds)
// contract client: (amount T sold, amount the makers delivered, surplus paid to the caller)
const [paid, received, earned] = await client.crossfill(bot.publicKey(), BigInt(takerOrderId), makerIds.map(id => BigInt(id)))
The indexer records the taker order's own fill as a trade with crossfill: true and you as taker. It stays in the trade history but is left out of candles, ticker volume and your own account channel.
Checks before you call:
- You must be able to receive T's buying asset, even when the surplus would be zero (
CannotReceive, 708), and the contract must be able to hold it (IntermediaryCannotReceive, 712, for an asset whose issuer has not authorized the contract). - The payer is T's owner. A token error on a payment to a maker means the owner could not pay or the maker could not be credited, never that you are at fault: leave that maker out and retry.
- Fills that would leave you short after rounding are skipped silently, so the surplus never goes negative.
- One call fits at most 19 maker fills, and an 8-order crossfill costs about 0.014 XLM in fees (September 2026). A surplus worth less than the fee loses money.
Multi-hop opportunities with swap#
swap executes a whole route atomically: it plans every hop read-only first, then executes, and fails with NotFilled (709) unless the route meets the bounds. Use it when a route through an intermediate asset beats the direct market, or to close a cycle. A path may end in the asset it starts from, so XLM to USDC to EURC and back to XLM is one swap. Ask for more than you put in, and the call only succeeds when the cycle pays:
const input = 100_000_000n // 10 XLM
const path = []
let amount = input
for (const [selling, buying] of [[XLM, USDC], [USDC, EURC], [EURC, XLM]]) {
const quote = await api.quoteSell({sellingAsset: selling, buyingAsset: buying, amount: amount.toString(), direct: true})
if (quote.status !== 'success')
throw new Error(quote.error ?? 'No liquidity')
path.push({asset: buying, orders: quote.paths[0].path[0].orders.map(id => BigInt(id))})
amount = BigInt(quote.paths[0].bought)
}
if (path.reduce((count, step) => count + step.orders.length, 0) > 20)
throw new Error('Too many orders for one swap')
// fails in simulation with #709 NotFilled, before signing, unless at least 10.01 XLM come back
const [sold, bought] = await client.swap({
direction: TradeDirection.Sell,
trader: bot.publicKey(),
selling: XLM,
sellingAmount: input,
buyingAmount: input + 100_000n,
path
})
Notes:
/quoterefuses identical selling and buying assets, so build cycles from per-leg quotes, as above, or from your own book.- A failed simulation costs nothing, because nothing is submitted. A transaction that fails on-chain still pays its fee.
swapsettles strictly: a maker whose transfer to the contract fails reverts the whole call with the token's error. Makers that cannot back a fill are skipped in planning and execution alike, so a route may list fallback orders behind doubtful ones. The AXIS API leaves makers whose backing still awaits confirmation out of multi-hop routes.- Every asset of the path passes through the contract, so an asset whose issuer has not authorized the contract fails the call with 712, and a missing or full trustline for the final asset fails it with the token's error. See Strict settlement in swaps.
Costs per fill#
About two thirds of a multi-fill fee is the write-entry fee, 4 entries per maker: filling 1 order costs about 0.003 XLM, 8 orders about 0.014 XLM and 20 orders about 0.032 XLM at the September 2026 mainnet settings. On top come the inclusion fee bid, the transaction size and the read-write entries declared for listed orders that did not fill. See Fee estimates.
demo-dex-bot is a tool for testing and a reference bot implementation.