AXIS Docs

DevelopersSmart contract

Settlement and allowances

The AXIS contract holds no funds between calls. Orders are backed by the owner's balance and a token allowance granted to the contract, and each fill moves the maker's asset into the contract and the taker's payment straight to the maker, with one forward to the trader at the end of the call.

Allowance model#

Every token a trader sells through AXIS needs a standing allowance with the AXIS contract as the spender. It is granted on the token contract itself with approve(from, spender, amount, live_until_ledger), the standard SEP-41 call that every Stellar Asset Contract implements:

  • The amount is absolute. approve replaces the current allowance, it does not add to it.
  • The allowance is a temporary storage entry with its own live_until_ledger, at most the network's maximum entry TTL ahead (about 180 days). After that ledger allowance() reads zero. An expired allowance cannot be restored, only granted again.
  • transfer_from spends it. Every fill paid from the allowance lowers what is left of it.

Only the AXIS contract's own transfer_from calls spend the allowance a trader grants, and only in three situations:

  • a fill of one of the trader's orders, at that order's price,
  • the trader's own trade or swap,
  • a crossfill that uses the trader's order as the taker order, which pays the trader exactly at that order's price.

update never spends an allowance.

In-call approvals#

A trader can grant the allowance inside the same transaction that needs it:

pub struct Approval {
    // token the allowance is granted on
    pub asset: Address,
    // absolute allowance amount
    pub amount: i128,
    // ledger sequence the allowance lives until
    pub live_until: u32,
}

trade and swap accept one optional Approval (normally on the selling asset), and update accepts a list. The contract calls approve(trader, AXIS, amount, live_until) on asset before it matches or checks anything that depends on the allowance. That approve is a sub-invocation the trader signs together with the root call. Because the amount is an absolute call argument, the signed authorization does not depend on ledger state. A zero amount revokes the allowance. Within one update, the last approval given for an asset wins, and approvals keep working while the contract is frozen.

The JS client sizes approvals automatically to the amount the call needs plus the trader's open orders selling that token, see Automatic allowance management.

Backing#

backing(owner, token) = max(0, min(balance(owner), allowance(owner, AXIS)))

A balance that cannot be read, such as one behind a missing trustline, counts as zero. The backing is one budget per owner and token, shared by all the owner's orders selling that token. The contract checks it when an order is created or changed:

Call What is checked Errors
trade storing a remainder Balance and allowance each cover the remainder, the owner can receive buying 702, 703, 708
update changing orders Per selling asset, balance and allowance each cover the sum of the batch's new amounts, the owner can receive each order's buying 702, 703, 708

These checks consider the order being stored, or the orders in the batch, not the owner's other open orders. Nothing enforces the backing afterwards. The owner can spend the balance, or lower or revoke the allowance, at any time, and the orders stay on the book unchanged. An order on the book is a promise to sell, not a reservation of funds.

Admission at match time#

When matching reaches a listed order, the dispatcher (src/dispatcher.rs) decides whether the maker can settle that fill. On the maker's first fill in a call it reads two things:

  1. Whether the maker can receive the asset the taker pays with, through the token's authorized (false for a missing or deauthorized trustline).
  2. The maker's backing in the asset they sell. A maker who cannot receive gets a backing of zero.

The fill is admitted when the maker can receive and the backing left covers the whole fill. An admitted fill lowers the backing left, which the maker's later orders in the same list share. A fill that is not admitted is skipped as a whole. It is never trimmed down to the backing. The order stays unchanged, and the taker's remaining amount moves on to the next listed order.

// queue a fill if the maker can settle it, returns whether the fill was queued
pub fn add_fill(&mut self, order: Order, bought: i128, sold: i128) -> bool
// settle every fill maker by maker, returns the amounts the taker actually sold and bought,
// and the highest order price among the settled fills
pub fn settle(self) -> (i128, i128, i128)

Settlement order#

After matching, settle moves the tokens. The makers' assets pass through the contract on their way to the trader:

  1. Up-front checks. If at least one fill was admitted, the trader must be able to receive the bought asset, otherwise the call fails with CannotReceive (708). The contract itself must be able to hold it, otherwise the call fails with IntermediaryCannotReceive (712), see Assets that require authorization. Both checks run before any transfer.
  2. Maker leg, per maker in address order: try_transfer_from(AXIS, maker, AXIS, total_bought) collects the maker's asset into the contract. If it fails, the maker is skipped: none of their orders change, and every order of theirs in the call gets a skip event. Admission already confirmed the backing, so the cause is a balance locked elsewhere (classic selling liabilities or the XLM reserve) or a deauthorized trustline on the asset the maker sells.
  3. Taker leg for the same maker: transfer_from(AXIS, taker, maker, total_sold), the taker pays the maker directly. A failure here fails the whole call with the token's own error, see Failure modes.
  4. Order writes for the same maker: each fill stores left = amount - bought, set to zero when it is dust, and an order at zero is deleted. Each fill emits one trade event carrying left.
  5. Forward. Once every maker is settled, the contract sends the total bought to the trader in one transfer(AXIS, trader, total). If the trader cannot take it, for example a trustline with no room left, the call fails with CannotReceive (708) and no maker is flagged.
  6. Skip events for every listed order skipped at admission or at settlement, after all trade events.

The legs are aggregated per maker: a maker with five orders in the list costs one transfer per leg, not five. trade events follow the makers' address order rather than the list order, which does not matter to indexers since they apply events by order ID. In a crossfill the contract keeps what the makers delivered instead of forwarding it, and pays the taker order's owner and the caller out of it (see Crossfill).

A taker trade listing orders of makers A and B. Maker A's asset is collected into the contract and the taker pays maker A directly, maker B lacks backing and is skipped with a skip event while its order stays untouched, and the contract forwards what it collected to the taker.

Skips#

Skipped with a skip event Skipped silently
The maker's backing left does not cover the fill An ID listed twice (it fills once)
The maker cannot receive the taker's asset A missing, filled, removed or expired order
The maker's asset could not be collected (maker leg failed, trade and crossfill only) An order on another pair
In crossfill, the taker order's owner cannot back it or receive the bought asset A maker price worse than the taker's limit
A fill that computes to zero, or whose cost does not fit i128
In crossfill, a fill that would underpay the taker order

A skip event has the topic skip and a single u128 value, the order ID. The order itself is untouched. Silent skips are routine, since lists go stale between quote and inclusion. A skip event means a router believed a maker could settle when it could not. The event layout is in Events.

Two settlement failures are not skips. A payment to the maker that fails (the taker leg) fails the whole call with the token's own error, since the contract cannot tell a taker who cannot pay from a maker who cannot be credited. In a swap, a maker leg that fails fails the swap too. Failure modes sorts every case by who is at fault.

Effective depth#

The book does not guarantee backing, so routers must compute what each order can actually deliver:

  1. Per maker and token sold, take min(balance, allowance) as the budget. Use zero when either value is unknown, when the trustline is missing or unauthorized, or when the allowance's live_until_ledger has passed.
  2. Split the budget across the maker's orders selling that token. The indexer allocates it oldest order first and exposes each order's share as backed.
  3. Check that the maker can receive the asset the taker pays with, and that the maker's trustline for it has room for the payment. authorized does not reveal a full line, and a payment that does not fit fails the whole call instead of skipping the maker. The indexer exposes the room left as headroom in its backing records.
  4. Place each partially backed order where the taker's remaining amount fits its backing, or leave it out. The contract admits a fill only when the backing left covers all of it.
  5. After a skip event, stop proposing that maker's orders in that token until the backing has been read again. The indexer records the time of the last skip per maker and token and reloads the backing.

For classic accounts, balance() includes amounts locked by the account's own selling liabilities on the Classic DEX and, for XLM, the reserve. Fills that need those amounts pass admission and are then skipped at settlement. The indexer and the REST API expose the backing they track.

Authorization#

Call Signed authorization tree
trade trade(...) by trader, plus approve on approve.asset when given
swap swap(...) by trader, plus approve when given
update update(...) by the owner, plus one approve per entry of approvals
crossfill crossfill(...) by the caller
subsidize subsidize(...) by sponsor, plus the oracle's track and the XRF burn it performs

Every token movement during trading is a transfer_from with the AXIS contract as spender, or a transfer out of the contract's own balance. The token checks spender.require_auth(), which the Soroban host satisfies for the contract that makes the call, so transfer amounts never appear in a user's signature. The only values a trader signs are the call arguments: amounts, the price, the listed IDs and the Approval.

That is why a signed trade survives changes to the book between simulation and inclusion. If a listed order was partially filled, removed, repriced or lost its backing in the meantime, the contract fills what is left or skips the order, and the signature stays valid. Declaring the ledger entries those changes touch is a separate concern, covered in Failure modes.

Strict settlement in swaps#

swap settles every hop in strict mode, which differs from trade and crossfill:

  • Before matching, the contract checks that it can hold every hop asset, and fails with IntermediaryCannotReceive (712) otherwise.
  • There is no receiver pre-check. A trader who cannot receive the final asset gets the token contract's error instead of CannotReceive (708).
  • The maker leg uses transfer_from instead of try_transfer_from. A maker whose transfer fails fails the whole swap instead of being skipped.
  • Admission still applies, in planning and in execution alike. A maker without enough backing, or one who cannot receive, is skipped in both, so a route can list a fallback order behind a doubtful one.
  • Every hop settles into the contract: the makers deliver the hop's output to the contract's balance. The trader pays the first hop's makers with transfer_from, and the contract pays the later hops' makers with a plain transfer out of what the previous hop delivered.
  • The last hop's output is forwarded to the trader in one transfer, and the swap event comes last.

Every hop settles exactly as planned or the swap fails, so the contract never ends a swap holding an intermediate asset. Trades, swaps and crossfills describes the planning passes.

Assets that require authorization#

Some classic assets, typically regulated ones, are issued with AUTH_REQUIRED: an account or a contract can hold them only after the issuer has authorized it. Buying such an asset on AXIS passes it through the contract, since the makers deliver it to the contract in a trade and a crossfill, and every swap hop lands in the contract's balance. The issuer therefore has to authorize the AXIS contract itself as a holder, by calling set_authorized(<AXIS contract>, true) on the asset contract, on top of authorizing the traders.

Until the issuer does, every purchase of the asset fails with IntermediaryCannotReceive (712) before any transfer: trade checks the asset it buys, crossfill the taker order's buying asset, and swap every asset of its path. This is neither the trader's nor a maker's fault, and other orders will not help. The AXIS API refuses to quote routes that buy such an asset.

Selling an AUTH_REQUIRED asset needs no authorization of the contract, because the taker pays each maker directly. The traders themselves still need the issuer's authorization as usual: a maker who cannot receive the taker's asset is skipped, and a trader who cannot receive the bought asset gets CannotReceive (708).

Custom tokens#

Stellar Asset Contracts behave as described on this page. A custom SEP-41 token works with AXIS when it implements:

Function Used for
balance Backing (a failing call reads as zero)
allowance Backing
approve In-call approvals
transfer_from Both settlement legs
transfer Contract payouts: the forward to the trader, later swap hops and crossfill
decimals Assets the oracle quotes, when a market is opened or requoted
authorized Optional. Checked for makers, the trader and the contract itself. When the call fails with anything but a contract error, the holder is assumed able to receive

A buggy or malicious token can burn the whole resource budget of a transaction that touches it, since a cross-contract call has no budget of its own. It can also refuse transfers or report balances it does not honor, which turns its makers into skips or fails the calls that pay with it. The contract keeps no balance across calls, so a bad token cannot reach funds in other tokens. Only orders and trades involving that token are affected.