AXIS Docs

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=B lists the live orders of a pair, both sides, oldest first, up to 200 per page. Every order carries backed, the amount its maker can currently deliver.
  • GET /quote returns ready-made routes with the order IDs to list. GET /depth aggregates price levels.
  • GET /backing?owner=&asset= returns a maker's balance, allowance, liveUntil, authorized, budget, headroom (room left under the trustline limit, present for trustline assets) and the time of the last skipped fill in that token.
  • The WebSocket channels depth, trades and contract push aggregated changes. No channel streams the individual orders of all makers, so poll /order or 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 a Sell trade, price itself for a Buy. 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's backed field 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 headroom in the asset you pay with is smaller than the payment, as the AXIS API does.
  • Skip cooldowns. After a skip event 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 from skip events 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 trade fails with CannotReceive (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: true in 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 a swap path.
  • 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.

An existing order T crossed by cheaper maker orders M1 and M2. The caller lists them in crossfill, the makers deliver to the contract, T's owner pays the makers and receives exactly its limit price and the surplus goes to the caller.

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:

  • /quote refuses 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.
  • swap settles 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.