AXIS Docs

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 nonce argument of trade is used only when the trade stores a remainder, that is a Limit trade that does not fill completely. Fill, FillOrKill and fully filled Limit trades 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 subsidize burns 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 update with 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.