DevelopersSmart contract
Contract design
The AXIS contract verifies prices and settles trades between wallets, passing the makers' assets through its own balance within the call. It does not search for liquidity, hold funds between calls or keep an index of the book.
Role of the contract#
AXIS splits an exchange into three parts. The AXIS contract stores orders, checks that every proposed fill respects the prices of both sides, and settles the token transfers atomically. The indexer rebuilds the book from contract events and tracks how much each maker can actually deliver. Routers (the AXIS API, wallets, bots) decide which orders a taker should cross and pass their IDs to the contract. Architecture shows how the parts connect.
Three properties follow from this split:
- Verification and settlement only. A taker lists order IDs, and the contract matches exactly those orders, in the given order, skipping any that no longer qualify. There is no on-chain price-time priority.
- No custody. An order is backed by the owner's balance and a standing allowance granted to the contract. Every fill is settled with
transfer_from, and nothing stays in the contract after the call. See Settlement and allowances. - No index. Contract storage has no price levels, sorted lists or counters. An order is a single ledger entry keyed by its ID, and nothing else is written per order.
The source lives in the public repository axis-markets/orderbook:
| File | Contents |
|---|---|
src/lib.rs |
Entry points |
src/order.rs |
Order, IDs, storage encoding, backing and receive checks |
src/orderbook.rs |
Matching and crossfill |
src/dispatcher.rs |
Settlement, aggregated per maker |
src/math.rs |
Fixed-point arithmetic and the dust rule |
src/market.rs |
Markets, Config, oracle validation |
src/pricing.rs |
Price cache and minimum order value |
src/events.rs |
Events |
src/admin.rs |
Safety admin calls and the frozen flag |
src/errors.rs |
Error codes |
src/ttl.rs |
TTL policy |
src/trade.rs |
Argument types and the in-call approve |
src/reflector_beam.rs |
Interface of the Reflector Beam oracle |
The tests in src/tests/ cover the behavior described in this section, and include the resource measurements quoted in Resource limits and costs.
Entry points#
| Group | Function | Purpose | Who signs | While frozen |
|---|---|---|---|---|
| Trading | trade |
Match listed orders, then store what is left as an order (Limit), drop it (Fill) or reject it (FillOrKill) |
trader, plus the optional approve |
Blocked |
| Trading | swap |
Trade along a path of markets, spending at most selling_amount (Sell) or receiving exactly buying_amount (Buy) |
trader, plus the optional approve |
Blocked |
| Trading | crossfill |
Cross an existing order against listed orders and pay the crossed spread to the caller | The caller (trader) |
Blocked |
| Order management | update |
Change the amount, price and expiration of own orders, remove them, grant allowances | The owner (trader), plus each approve |
Changes blocked, removals and approvals allowed |
| Markets | subsidize |
Open a market or extend its oracle feed access by burning XRF | sponsor, plus the oracle track and XRF burn it triggers |
Blocked |
| Markets | requote |
Re-check a market's assets against the oracle and cache their prices | Nobody | Blocked |
| Views | order, market, config, frozen |
Read an order, a market, the configuration or the frozen flag | Nobody | Allowed |
| Safety admin | freeze, delegate, set_oracle, set_floor, set_listing_min_days, set_ledger_time |
Emergency switch, role handover, oracle, minimum order value, days of feeds the listing fee buys, ledger close time | Safety admin | Allowed |
| Deployment | __constructor |
Store the safety admin, the oracle and the minimum order value, with listing_min_days at 90 and ledger_time at 5 seconds |
Runs once at deployment | Not applicable |
"Who signs" is the address whose require_auth the call checks, with the sub-invocations that address must also authorize. Every state-changing entry point also tops up the contract's own TTL when it runs low (see Contract lifetime). Exact signatures are in Functions, argument and return types in Types, and the safety admin role in Safety admin and invariants.
Storage layout#
| Entry | Durability | Key | Value | Size | Lifetime |
|---|---|---|---|---|---|
| Order | Persistent | Raw u128 order ID |
[owner, selling, buying, amount, price], plus expires when non-zero |
172-176 B value without expiration, about 250 B ledger entry | Network minimum at creation (about 120 days on mainnet, 7 days on Testnet), extended by update and by a new order written over an expired entry |
| Market | Persistent | DataKey::Market(base, quote) |
Market { base, quote, created } |
About 460 B | Extended to 120 days when read with less than 30 days left, and to 121 days when an order is written to it with less than 120 days left |
| Price cache | Temporary | Asset Address |
PriceCache { price, timestamp, decimals } |
Small | 72 hours from each write |
| Configuration | Instance | DataKey::Config |
Config |
Small | Contract instance |
| Oracle decimals | Instance | DataKey::OracleDecimals |
u32 |
Small | Contract instance |
| Frozen flag | Instance | DataKey::Frozen |
bool |
Small | Contract instance |
An order entry is a positional Vec without the ID, which is already the key, and expires is appended only when it is set, which adds 12 bytes. The order view rebuilds the full Order from the entry and its key. Trades write nothing in instance storage. A trade does write the contract's own token balance of the asset it buys, so trades buying the same asset share that entry, while trades that buy different assets from different makers share no write key (see Concurrency). The key definitions are in Storage keys, and the TTL rules for every entry in TTL policy.
Order IDs and nonces#
The ID of the order a Limit trade stores is derived from the owner and a nonce chosen by the client:
id = u128::from_be_bytes(sha256(xdr(ScVal::Vec([owner: Address, nonce: u64])))[0..16])
The contract exposes no view for this computation, so clients compute IDs themselves, with orderId from the JS client or as shown in Order ID derivation. The rules for nonces:
- The
nonceargument oftradeis used only when the trade stores a remainder, that is aLimittrade that does not fill completely.Fill,FillOrKilland fully filledLimittrades ignore it. - A nonce must be unique among the owner's live orders. A remainder whose ID belongs to a live order fails the whole trade with
OrderExists(711). - An ID becomes unallocated when its order is filled, removed or expired. A new order under the ID of an expired order overwrites that entry.
- The pair is not part of the ID. The same nonce on another pair still collides with a live order, and after an owner reuses a nonce on another pair, a listed ID can point at an order of a different market. Matching skips such an ID without an event.
- The JS client generates a fresh nonce for every trade,
Date.now() << 22 | 22 random bits.
Client-chosen IDs let a client declare the entry of the order its trade will create before simulating it, and they avoid an ID counter that every order-creating transaction would write, which would serialize them all. See Design rationale.
Sell-equivalent orders#
Every stored order is a sell order: it sells amount of selling for buying at price, expressed in buying base units per selling base unit times 10^18. The remainder of a Buy trade is converted before it is stored. It sells ceil((amount - bought) * price / 10^18) of the asset the buyer pays with, at the price ceil(10^36 / price). Prices and rounding has the details and an example, and Types the Order struct.
One representation keeps matching, events and the view simple. The new event and the order view carry the sell-equivalent form, not the original Buy arguments. A client that displays bids and asks for a market treats an order selling the quote asset as a bid and inverts its price for display.
What is deliberately absent#
- Counters. Order IDs are provided by clients. Trades and swaps have no on-chain ID: indexers derive one from the ledger, the transaction and the event position.
- An index. The contract cannot enumerate orders by market or price. It only loads the IDs it is given. Discovery belongs to the indexer and the AXIS API.
- Custody. There is no deposit and no withdrawal. Assets pass through the contract only within a call, so a frozen contract strands nothing.
- Fees. The contract takes nothing from trades and has no fee switch. The only payment it triggers is the XRF that
subsidizeburns through the oracle. - An upgrade path. The contract is non-upgradeable: no function replaces its Wasm code, and any future protocol version means a new deployment.
- Admin power over orders, funds and markets. The safety admin has six calls, none of which touches an order, an allowance or a market record (see Safety admin and invariants).
- A cancel function and an expiry sweeper. Removal is an
updatewith a zero amount. Expired orders stay in storage, unusable, until their owner removes or revives them, a new order overwrites them, or they archive.
Build and versions#
| Item | Value |
|---|---|
| Contract version | 0.6.0 (crate axis-markets-orderbook) |
| SDK | soroban-sdk 28.0.0 |
| Wasm target | wasm32v1-none |
stellar contract build --optimize
The release profile uses opt-level = "z", link-time optimization and overflow-checks = true. Plain integer overflow therefore traps as a host error, while a fixed-point product whose result does not fit i128 fails with the contract error Overflow (740). TypeScript bindings can be generated from the built Wasm with stellar contract bindings typescript. Deployed addresses and the live configuration are listed in Networks and deployments.