AXIS Docs

DevelopersGuides

Market making

How to quote on AXIS: allowances, placing a ladder, requoting in place with update, keeping orders backed, following fills and the risks to manage.

How making works on AXIS#

Your orders are stored in the AXIS contract, while your tokens stay in your own account. Each order is backed by your balance and a standing allowance on the token it sells, and a fill settles within the taker's transaction at your price. Takers, or the routers they use, decide which orders to list in their trades. The contract does not rank orders or protect a queue position. See Settlement and allowances.

Because nothing is locked, the inventory behind your orders stays liquid. The same balance can back orders on several markets, quote RFQs with immediate settlement or earn yield elsewhere, and you can redeploy it without canceling orders or withdrawing anything from the contract.

The snippets on this page share this setup. The maker key signs every call.

import {Axis, AxisContractClient, invertPrice} 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 SCALE = 10n ** 18n

const maker = Keypair.fromSecret(process.env.MAKER_SECRET)
const {signTransaction} = contract.basicNodeSigner(maker, Networks.TESTNET)

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(maker.publicKey(), {signTransaction})
await account.ready

// lower-level client, used here for explicit approvals
const client = new AxisContractClient({
    publicKey: maker.publicKey(),
    signTransaction,
    rpcUrl: RPC_URL,
    contractId: AXIS_CONTRACT,
    networkPassphrase: Networks.TESTNET
})

Allowances#

The contract spends your tokens through one allowance per token, granted to the AXIS contract. That single allowance backs every order selling the token, in every market, and your own taker trades in it. An order's effective backing is min(balance, allowance), shared by all your orders selling that token. Nothing reserves funds for a particular order.

  • Amount. At least the sum of what your open orders still sell in that token, plus room for the orders you plan to add. The contract can spend it only to settle fills of your orders at their prices and trades you sign.
  • Expiry. An allowance lives until a ledger sequence, at most about 180 days ahead, and reads as zero afterwards. Every order selling that token stops filling at that moment, and an expired allowance can only be granted again.
  • Fills consume it. A fill lowers your balance and your allowance by the same amount, so they stay aligned. Your own taker trades in the same token lower the allowance without lowering any order.

Grant a standing allowance explicitly with an update that changes no order:

const ledger = await axis.getLedger()
const liveUntil = ledger + 90 * 17_280 // about 90 days at 17,280 ledgers per day
await client.update({
    trader: maker.publicKey(),
    updates: [],
    approvals: [
        {asset: XLM, amount: 50_000_000_000n, liveUntil}, // 5,000 XLM
        {asset: USDC, amount: 10_000_000_000n, liveUntil} // 1,000 USDC
    ]
})

The amount is absolute: it replaces the current allowance. Renew well before liveUntil.

AxisAccount also manages allowances on its own. Before a call that grows your commitment in a token, it adds an approval of exactly "this call plus your open orders" for about 30 days when the allowance is short or expires within about a day (see Automatic allowance management). That can lower a larger standing allowance you granted, so renew before the last day, or pass an explicit approve to sell and buy (approve: null sends none).

Placing a ladder#

An order is created by a trade with kind Limit: whatever the trade does not fill stays on the book. Each call creates at most one order, so a ladder of N orders takes N transactions, and each new order pays about 0.042 XLM, mostly ledger rent.

Think in sell-equivalent terms. The contract stores every order as "sell amount of selling at price units of buying per unit". An ask sells the base asset. A bid sells the quote asset at the inverted price. Placing both sides with sell keeps update simple later, because amounts and prices stay in the stored orientation.

const now = Math.floor(Date.now() / 1000)

// asks: sell 100 XLM at 0.230, 0.231 and 0.232 USDC per XLM
const asks = []
for (const level of [230n, 231n, 232n]) {
    const {orderId} = await account.sell({
        selling: XLM,
        buying: USDC,
        amount: 1_000_000_000n,
        price: level * SCALE / 1000n,
        orders: [], // priced away from the best bid: nothing to cross
        expires: now + 3600
    })
    asks.push(orderId)
}

// bids: buy 100 XLM at 0.220, 0.219 and 0.218 USDC, stored as selling the USDC it costs
const bids = []
for (const level of [220n, 219n, 218n]) {
    const {orderId} = await account.sell({
        selling: USDC,
        buying: XLM,
        amount: 1_000_000_000n * level / 1000n,
        price: invertPrice(level * SCALE / 1000n), // XLM per USDC
        orders: [],
        expires: now + 3600
    })
    bids.push(orderId)
}

Notes:

  • Crossing. Without orders, sell and buy fill the orders within the limit price that a direct AXIS API quote finds before placing the remainder on the book. orders: [] crosses nothing, and the order is placed even if it crosses the book. There is no post-only flag, so check the best opposite price yourself before passing an empty list.
  • IDs and nonces. The JS client picks a fresh nonce per call and returns the new order ID. If you build calls yourself, the nonce must be unique among your live orders (OrderExists, 711). See Order IDs and nonces.
  • Expiration. expires is a UNIX timestamp in seconds. At that moment the order stops filling, with no event, and its entry stays in storage where update can revive or remove it.
  • Requirements. An order on the book needs an open market for the pair (721), a recent oracle price (722) and a value of at least the minimum order value (720). See Markets and oracle.
  • Rounding. invertPrice rounds down, a difference in the 18th decimal of the price.

Requoting with update#

update changes the amount, price and expiration of existing orders in place. The ID stays, no new entry is created and no new rent chunk is paid, and each updated order's ledger lifetime is extended, so an order you keep updating never archives.

// move the asks up by 0.001 USDC, resize the first one and extend the last one
await account.update([
    {id: asks[0], price: 231n * SCALE / 1000n, amount: 1_200_000_000n},
    {id: asks[1], price: 232n * SCALE / 1000n},
    {id: asks[2], price: 233n * SCALE / 1000n, expires: now + 7200}
])

// move a bid: amount in USDC, price in XLM per USDC
await account.update([{id: bids[0], price: invertPrice(221n * SCALE / 1000n)}])

AxisAccount.update keeps the current value of every field you leave out and adds an approval when the new amounts grow your commitment. Rules the contract enforces:

  • Batch size. One update holds about 110 orders. The JS client sends batches of 90 to leave a margin.
  • Checks. A changed order goes through the checks of a new one: backing per selling token on the sum of the orders in the call (702, 703), the minimum order value at the cached oracle price (720, or 722 while prices are stale), a market the oracle still quotes (721) and an owner who can receive the buying asset (708). Removals skip all of them.
  • Amount 0 removes the order. IDs that no longer exist are skipped, and the first entry wins when an ID repeats.
  • Absolute amounts. The amount you send replaces the current one, so a fill that lands between your signature and the update is not counted against it. To cut exposure whatever is in flight, remove the order or lower the allowance in the same call (see Absolute amounts and fills in flight).

Requoting in place costs a fraction of removing and recreating orders: refreshing a 40-order ladder every minute costs about 23 XLM per day with update against about 2,400 XLM by recreation. See Requote economics.

The AXIS API proposes orders best price first. At equal prices it follows the order in which its indexer last inserted them, and an update reinserts the order, so an updated order moves behind the others at its price, even when only the amount changed. The contract itself enforces no priority.

Keeping orders backed#

account.committed(asset) is what your open orders still sell in a token. account.getAllowance(asset) reports balance, allowance, liveUntil, available (the budget, min(balance, allowance)) and committed. Keep available at or above committed.

Each order carries backed, its share of the budget, which the indexer splits across your orders selling that token, oldest first. When the budget drops below what your orders sell:

  • The AXIS API counts and proposes only what the budget covers, spending it on your best-priced orders first, so the far end of your ladder drops out first.
  • A taker who lists an order anyway gets a skip event for it if your remaining budget does not cover the fill. The fill is never trimmed to the budget.
  • The order itself stays unchanged on the book and fills again as soon as the backing returns.

Common causes: spending the token elsewhere, an expired or consumed allowance, a deauthorized trustline, and Classic DEX offers or the XLM reserve locking part of a Classic account's balance. The balance the contract reads includes funds locked by your own Classic offers, so a fill that needs them passes the backing check and is then skipped at settlement. Quote AXIS and the Classic DEX from the same account only if you account for both.

Following fills#

The WebSocket account channel sends a snapshot of your orders and backing, then a message per change. Order changes arrive as order messages with an action of new, fill, filled, update, cancel or expire. Fills carry a fill object seen from your side: sold is what you delivered, bought what you received.

{
    "type": "order",
    "topic": "account:GACV...CLY5",
    "address": "GACV...CLY5",
    "action": "fill",
    "order": {"id": "16021478962171082823578897907180359047", "amount": "954768", "backed": "954768"},
    "fill": {"sold": "2264099", "bought": "7595760", "taker": "GBDC...KVHG", "trade": "1407940085307320303620", "ts": 1791033627}
}

The order object is shortened here. backing messages report your balance and allowance per token and the new split of backed amounts across your orders. AxisAccount turns these messages into events:

account.on('fill', ({order, sold, bought, taker}) => {
    console.log(`order ${order.id}: delivered ${sold}, received ${bought}, ${order.amount} left`)
})
account.on('filled', ({order}) => console.log(`order ${order.id} filled`))
account.on('expire', order => console.log(`order ${order.id} expired`))
account.on('backing', ({asset}) => console.log(asset, account.getAllowance(asset)))

See WebSocket API for the full message list and Axis, markets and accounts for every event.

Handling skips#

A skip event means a taker listed one of your orders and you could not settle the fill: your remaining budget did not cover it, you could not receive the taker's asset, or your transfer to the contract failed. The order is untouched and the taker's trade went on with other orders. A payment you cannot be credited with although your trustline is authorized, for example because it is full, produces no skip but fails the taker's whole transaction, and routers that check trustline room leave you out, so keep room under the limits of the assets you receive too.

The indexer records the time of your last skip per token you sell (skipped in your backing), and the AXIS API stops proposing your orders in that token for a cooldown, 10 minutes by default. Repeated skips therefore cost you flow. When one appears:

const lastSkip = new Map()
account.on('backing', ({asset, backing}) => {
    if (!backing?.skipped || backing.skipped === lastSkip.get(asset))
        return
    lastSkip.set(asset, backing.skipped)
    const {balance, allowance, liveUntil, committed} = account.getAllowance(asset)
    console.warn('skipped fill selling', asset, {balance, allowance, liveUntil, committed})
})

Then top up the balance, renew the allowance, check your trustlines, or shrink your orders to what you can back. See Failure modes.

Frozen mode#

While the safety admin has frozen the contract, trades and every update that changes an order fail with Frozen (730), while removals and approvals still work, so you can pull quotes and revoke allowances.

axis.on('frozen', async frozen => {
    if (frozen)
        await account.cancelAll() // removals work while frozen
})

When trading resumes, quotes that sat through the freeze may be stale and can fill at once. Either remove them during the freeze, or be ready to update them as soon as frozen turns false. Oracle prices may also have aged during a long freeze, in which case changes fail with 722 until a keeper runs requote. See Frozen mode.

Risks#

  • Crossed books. Someone can create an order that crosses yours, and anyone can then crossfill the two. Your order fills exactly at your price, and the whole crossed spread goes to the caller.
  • Being picked off. Takers pick which orders to list, and stale quotes are the first ones listed when the market moves. Requote quickly, and use short expires values that you extend with each update as a dead-man switch: if your bot stops, the quotes expire on their own.
  • No on-chain priority. The contract matches whatever a taker lists, in the taker's order. The AXIS API routes by price, but other routers may choose differently.
  • Unbacked orders stay live. An order you stopped backing fills again when backing returns, even much later. Remove orders you no longer want instead of letting the allowance run out.
  • Self-trades are allowed. Filter your own orders out of the orders you list as a taker.

demo-dex-bot is a tool for testing and a reference bot implementation.