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#
tradermust authorize the call. The contract's own TTL is topped up if it runs low.- The contract must not be frozen (
Frozen, 730). amountmust be positive (InvalidAmount, 706),pricewithin[1, 10^36](InvalidPrice, 705) andsellingdifferent frombuying(InvalidMatch, 704).- If
approveis given, the contract grants that allowance, as a sub-invocation the trader signs. - For
Limitonly:expiresmust 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 wholeamountmust meet the minimum order value at the cached oracle price (OrderSizeTooSmall, 720, orAssetPriceOracleFetchFailed, 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:
- It is converted to its stored form:
amount - soldatpriceforSell, the sell-equivalent form forBuy. - If it is dust, the trade fails with
OrderSizeTooSmall(720) when nothing was filled, and returns without storing anything otherwise. - The ID is derived from
traderandnonce. A live order with that ID fails the trade withOrderExists(711). - 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 receivebuying(CannotReceive, 708). - The order is stored and a
newevent is emitted after thetradeandskipevents. 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:
- The owner authorizes the call, and every entry of
approvalsis granted before any order is touched. - 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.
- An order owned by someone else fails the whole call with
NotAuthorized(701). - An amount of 0 removes the order and emits
modwith amount 0. No other check applies, so removals work on expired orders and while the contract is frozen. - 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 receivebuying(708). - The order is rewritten in place under the same ID, its TTL and the market's are extended (see TTL policy), and a
modevent is emitted. - 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.
- Forward pass,
Sellonly. Each hop sells the previous hop's output. The swap fails withNotFilled(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 reachbuying_amount. - 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 exceedselling_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).
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.
- Forward pass: 1,000 XLM buy 250 USDC from A, which buy
floor(2_500_000_000 / 1.08) = 2_314_814_814EURC stroops from B, above the minimum. - Backward pass: B delivers exactly that for 250 USDC, so A must deliver exactly 250 USDC, for 1,000 XLM.
- 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#
- The caller (
trader) authorizes the call, and the contract must not be frozen (730). - The taker order must be live, otherwise the call fails with
OrderNotFound(710). - The contract must be able to hold the asset the taker order buys (
IntermediaryCannotReceive, 712). - 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 emitsskipfor the taker order and returns(0, 0, 0)without moving anything. - The caller must be able to receive the asset the taker order buys (
CannotReceive, 708), even if there is no surplus in the end. - The listed orders are matched as a
Selltaker 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. - The makers deliver to the contract, and the owner pays each maker with
transfer_fromagainst 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. - The contract pays the owner exactly
ceil(paid * order.price / 10^18)with a plaintransfer, its own limit and nothing better. The rest of what the makers delivered goes to the caller with a secondtransfer. - 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.
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]):
- 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. - 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_523EURC stroops forsold = 10_000_000_000USDC stroops. - The owner is owed
ceil(10_000_000_000 * 0.9) = 9_000_000_000for that payment, which M's delivery covers. - M sends 952.3809523 EURC to the contract and T's owner pays M 1,000 USDC.
- 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?).