AXIS Docs

DevelopersSmart contract

Trades, swaps and crossfills

How trade, update, swap and crossfill execute, step by step, with the checks, error codes and events of each stage.

Signatures are in Functions, error codes in Errors, fill arithmetic in Prices and rounding, token movements in Settlement and allowances.

The trade function#

trade matches a taker against the order IDs it lists, then handles what is left according to kind:

Kind Listed orders fill everything Something is left
Limit Returns, no order stored The remainder is stored as a new order, unless it is dust
Fill Returns Returns, the remainder is dropped (immediate-or-cancel)
FillOrKill Returns Fails with NotFilled (709), nothing moves

A Buy trade is executed in full when it bought amount. A Sell trade is executed in full when it sold amount. A Sell Fill-or-Kill also counts as executed when what is left of amount cannot buy one base unit of buying at the highest order price it filled, and that leftover stays with the trader. A Limit remainder that is dust at the trader's price is not stored.

Validation#

  1. trader must authorize the call. The contract's own TTL is topped up if it runs low.
  2. The contract must not be frozen (Frozen, 730).
  3. amount must be positive (InvalidAmount, 706), price within [1, 10^36] (InvalidPrice, 705) and selling different from buying (InvalidMatch, 704).
  4. If approve is given, the contract grants that allowance, as a sub-invocation the trader signs.
  5. For Limit only: expires must be 0 or in the future (InvalidExpiration, 707), the pair must have a market with at least one asset quoted by the oracle (AssetsNotVerifiedByOracle, 721), and the whole amount must meet the minimum order value at the cached oracle price (OrderSizeTooSmall, 720, or AssetPriceOracleFetchFailed, 722, without a usable price).

Fill and FillOrKill skip step 5: they need no market and ignore nonce and expires.

Matching#

The contract walks the orders list in the given order. It silently skips repeated IDs, missing, expired or empty orders, orders of another pair and orders priced beyond the taker's threshold. Every other fill goes to the dispatcher, which skips it with a skip event if its maker cannot back it or cannot receive the taker's asset. Matching stops once the taker's amount is used up, and later orders are never read.

Fill-or-Kill checks#

Before any transfer, the admitted fills must execute a Fill-or-Kill in full, or it fails with NotFilled (709) and nothing moves. The check runs again after settlement, since a maker whose asset cannot be collected is skipped there, and a failure then reverts the whole call.

Settlement and the remainder#

Before the first transfer, the trader must be able to receive buying (CannotReceive, 708) and the contract must be able to hold it (IntermediaryCannotReceive, 712). Settlement then goes maker by maker: the maker's asset is collected into the contract, the trader pays the maker directly, and each fill emits one trade event. Once every maker is settled, the contract forwards everything bought to the trader in one transfer, and one skip event follows per skipped order (see Settlement order). The call then returns, unless it is a Limit trade with something left. For that remainder:

  1. It is converted to its stored form: amount - sold at price for Sell, the sell-equivalent form for Buy.
  2. If it is dust, the trade fails with OrderSizeTooSmall (720) when nothing was filled, and returns without storing anything otherwise.
  3. The ID is derived from trader and nonce. A live order with that ID fails the trade with OrderExists (711).
  4. The trader's balance and allowance must each cover the remainder after the fills (InsufficientBalance, 702, InsufficientAllowance, 703), and the trader must be able to receive buying (CannotReceive, 708).
  5. The order is stored and a new event is emitted after the trade and skip events. Its lifetime is described in Order lifecycle and rent.

trade returns (sold, bought, id): the total paid, the total received and the ID of the stored remainder, if any. A failure in the remainder steps reverts the fills too.

Updating orders#

update(trader, updates, approvals) changes or removes several orders of one owner in place. Each OrderUpdate carries an id and the new amount (0 removes), price and expires (0 for none). Steps:

  1. The owner authorizes the call, and every entry of approvals is granted before any order is touched.
  2. Entries are processed in list order. A repeated ID is processed once, from its first entry, and an ID with no stored order is skipped and left out of the result.
  3. An order owned by someone else fails the whole call with NotAuthorized (701).
  4. An amount of 0 removes the order and emits mod with amount 0. No other check applies, so removals work on expired orders and while the contract is frozen.
  5. Any other change fails with Frozen (730) while frozen. It also needs a non-negative amount (706), a valid price (705), an expiration of 0 or in the future (707), an amount that is not dust (720), a market with at least one asset quoted by the oracle (AssetsNotVerifiedByOracle, 721), the minimum order value (720 or 722) and an owner who can receive buying (708).
  6. The order is rewritten in place under the same ID, its TTL and the market's are extended (see TTL policy), and a mod event is emitted.
  7. Finally, the balance and the allowance in each selling asset must each cover the sum of the batch's new amounts in that asset (702, 703).

update returns the IDs it updated or removed. It cannot change an order's pair or owner, and a non-zero amount on an expired order revives it. One failing entry reverts the whole batch, so a frozen contract rejects a batch that mixes removals with changes. A batch holds about 110 orders (see Resource limits and costs).

Absolute amounts and fills in flight#

update sets the amount to the value given, it does not apply a change to the current amount. A fill that lands between signing and execution is therefore not taken into account. A maker who shrinks an order from 1,000 to 800 while a fill of 500 is in flight ends up with 800 on the book after that fill, 1,300 sold in total instead of 800, at the maker's price and within the maker's backing. To cut exposure whatever is in flight, remove the order (amount 0) or lower the allowance in the same call through approvals, which caps what all of the maker's orders selling that asset can still deliver. Read the order again before resizing it.

Swaps#

swap trades along a path of markets in one call. Every hop's output passes through the contract, which holds it only inside the call.

pub struct TradeStep {
    // asset to buy at this step
    pub asset: Address,
    // maker order IDs to match
    pub orders: Vec<u128>,
}

Step 0 sells selling for path[0].asset, every later step sells what the previous step bought, and the trader receives the asset of the last step. The bounds depend on the direction:

Direction selling_amount buying_amount
Sell Input to send, the most the route will take Minimum output
Buy Maximum input Exact output

An empty path or a non-positive amount fails with InvalidMatch (704), a frozen contract with Frozen (730). An optional approve is granted before planning. The contract then checks that it can hold every asset of the path, and fails with IntermediaryCannotReceive (712) otherwise (see Assets that require authorization).

Planning#

Every hop is planned read-only first. Hops have no price limit: the amount bounds protect the trader.

  1. Forward pass, Sell only. Each hop sells the previous hop's output. The swap fails with NotFilled (709) if a hop buys nothing, or leaves input that could still buy a base unit at the highest price it filled. The final output must reach buying_amount.
  2. Backward pass, both directions. Starting from the output, each hop computes exactly what it must buy, which fixes the input of the hop before it. A hop that cannot deliver its exact target fails with NotFilled, and the first hop's input must not exceed selling_amount.

Exact intermediate amounts leave no asset in the contract, and input the route cannot use stays with the trader.

Execution#

The plan runs front to back, each hop as a Buy of its planned amount in strict settlement mode. The trader pays the first hop's makers with transfer_from, and the contract pays later hops with transfer out of what the previous hop delivered. Makers always deliver to the contract, and the last hop's output is forwarded to the trader in one transfer. A hop that does not settle exactly as planned fails with NotFilled, and a failed maker transfer reverts the swap with the token's error.

Each fill emits a trade event, with the trader as taker on the first hop and the AXIS contract on later hops. Skipped makers get skip events after their hop's fills, and one swap event (topics ["swap", selling, final asset], data [trader, sold, bought]) closes the call, which returns (sold, bought).

A two-hop swap: the trader sells XLM, hop 1 makers sell USDC, the contract holds USDC and EURC only inside the call, hop 2 makers sell EURC and the contract forwards it to the trader. A planning pass runs before execution and the bounds are checked.

Swap example#

A trader sells 1,000 XLM for at least 230 EURC through USDC, all with 7 decimals. Hop 1 lists maker A, selling 300 USDC at 4 XLM per USDC. Hop 2 lists maker B, selling 500 EURC at 1.08 USDC per EURC.

  1. Forward pass: 1,000 XLM buy 250 USDC from A, which buy floor(2_500_000_000 / 1.08) = 2_314_814_814 EURC stroops from B, above the minimum.
  2. Backward pass: B delivers exactly that for 250 USDC, so A must deliver exactly 250 USDC, for 1,000 XLM.
  3. Execution: A sends 250 USDC to the contract and the trader pays A. B sends the EURC to the contract and the contract pays B 250 USDC. The contract forwards the EURC to the trader, ending at a zero balance in both assets.

The call returns (10_000_000_000, 2_314_814_814).

Crossfill#

A Limit trade only matches the orders it lists, so a new order can be created at a price that crosses orders its taker never listed. crossfill turns such a cross into an open arbitrage opportunity: anyone can match the crossing orders and keep the spread, without funds of their own. A trader can also place a large order on the book at their own price and leave its execution to arbitragers, who compete to fill it in chunks against the cheaper orders on the other side.

How it works#

  1. The caller (trader) authorizes the call, and the contract must not be frozen (730).
  2. The taker order must be live, otherwise the call fails with OrderNotFound (710).
  3. The contract must be able to hold the asset the taker order buys (IntermediaryCannotReceive, 712).
  4. The taker order's owner pays, with a budget of min(order amount, owner's backing in the asset it sells). If the budget is zero, or the owner cannot receive the asset the order buys, the contract emits skip for the taker order and returns (0, 0, 0) without moving anything.
  5. The caller must be able to receive the asset the taker order buys (CannotReceive, 708), even if there is no surplus in the end.
  6. The listed orders are matched as a Sell taker spending the budget, against the taker order's price threshold. A profitability filter applies on top: a fill is admitted only if the maker's delivery covers what the owner is owed for the payment, ceil(sold * order.price / 10^18). A fill that rounding would leave short is skipped silently.
  7. The makers deliver to the contract, and the owner pays each maker with transfer_from against the owner's allowance. A maker whose asset cannot be collected is skipped. A payment that fails, because the owner cannot pay or the maker cannot be credited, fails the whole call with the token's error, which is never the caller's fault.
  8. The contract pays the owner exactly ceil(paid * order.price / 10^18) with a plain transfer, its own limit and nothing better. The rest of what the makers delivered goes to the caller with a second transfer.
  9. The taker order keeps amount - paid, and is removed when that is dust.

crossfill returns (paid, received, surplus). The caller pays the transaction fee and needs no balance in either asset. Each maker fill emits a trade event with the owner as taker, and the taker order's own fill emits a final trade event with the caller as taker and the owner as maker, an ordinary fill at the order's price. Its topics are ["trade", taker order's buying, taker order's selling] and its amounts are the ones the owner received and paid.

An existing order T crossed by cheaper maker orders M1 and M2. Anyone calls crossfill with T and the maker IDs, T's owner pays the makers, the makers deliver to the contract, the contract pays T's owner exactly at T's limit price and the surplus goes to the caller.

Crossfill example#

Order T sells 1,000 USDC for at least 0.9 EURC each (price = 900_000_000_000_000_000). Order M sells 1,200 EURC at 1.05 USDC each, about 0.952 EURC per USDC. A bot calls crossfill(bot, T, [M]):

  1. T's owner is fully backed, so the budget is 10,000,000,000 USDC stroops. M's price is below the threshold floor(10^36 / (9 * 10^17)) = 1_111_111_111_111_111_111.
  2. All of M would cost 12,600,000,000, so the fill is partial: bought = floor(10_000_000_000 / 1.05) = 9_523_809_523 EURC stroops for sold = 10_000_000_000 USDC stroops.
  3. The owner is owed ceil(10_000_000_000 * 0.9) = 9_000_000_000 for that payment, which M's delivery covers.
  4. M sends 952.3809523 EURC to the contract and T's owner pays M 1,000 USDC.
  5. T's owner receives 900 EURC, exactly its limit, and the bot receives the surplus of 52.3809523 EURC.

T is filled and removed, M keeps 2,476,190,477 EURC stroops, and the call returns (10_000_000_000, 9_523_809_523, 523_809_523). See Bots and arbitrage for strategies and Axis, markets and accounts for the JS client's crossfill(takerOrderId, orders?).