AXIS Docs

DevelopersSmart contract

Safety admin and invariants

The AXIS contract has one privileged role, with six calls and no access to funds or orders. This page covers it, frozen mode, trust assumptions and invariants.

The safety admin#

The safety admin is the address in Config.safety_admin, set by the constructor. Each of its six calls checks that address's authorization, emits an event and works while the contract is frozen. The Testnet safety admin is listed in Networks and deployments.

freeze#

freeze(blocked: bool) is the emergency kill-switch. It sets the frozen flag in instance storage and emits a freeze event carrying the new value. true blocks trading, false resumes it, and repeating the current value leaves the state unchanged. The frozen mode matrix lists what still works.

delegate#

delegate(new_safety_admin) hands the role to another address and emits a config event. The previous admin loses the role in the same call. A handover keeps the frozen state as it is, so a frozen contract stays frozen under its new admin.

set_oracle#

set_oracle(oracle) points the contract at another Reflector Beam oracle. The new oracle must quote with at most 24 decimals and charge a positive daily fee, otherwise the call fails with InvalidOracleConfig (723). The call stores the oracle and its decimals, re-derives market_listing_fee as the daily fee times listing_min_days and emits a config event. Cached prices keep counting until they age out when the new oracle uses the same decimals, and stop counting at once when it does not. Fresh prices need a subsidize on the new oracle first, or only a requote if the contract already has feed access there (see Markets and oracle).

set_floor#

set_floor(minimum: i128) sets min_trade_size, the minimum order value in USD with 7 decimals. 0 disables the check and a negative value fails with InvalidAmount (706). The call emits a config event. The floor applies to new Limit trades and to order changes through update, never to fills or removals.

set_listing_min_days#

set_listing_min_days(days: u32) sets listing_min_days, the days of price feeds the listing fee of a new market buys, and re-derives market_listing_fee as the oracle's current daily fee times days. A value above 255 fails with InvalidAmount (706), and an oracle that charges no daily fee fails the call with InvalidOracleConfig (723). 0 makes listing free: subsidize then opens a market for any positive amount. The call emits a config event. Existing markets and the feed access they already hold are not affected. The default is 90 (see Listing fee).

set_ledger_time#

set_ledger_time(ledger_time: u32) sets the expected ledger close time in seconds, which the contract uses to convert every lifetime it sets (contract, markets, orders, cached prices) into ledgers. It starts at 5 seconds, and 0 or a value above 20 fails with InvalidAmount (706). The call emits a config event. Lifetimes set or extended afterwards use the new value, existing TTLs stay as they are (see TTL policy).

What the safety admin cannot do#

  • Move, spend or freeze user funds. The contract holds none between calls, and an allowance can only be spent through fills of the owner's own orders at their prices, or through calls the owner signs.
  • Create, change, fill or remove anyone's orders.
  • Upgrade or replace the contract code.
  • Open, list, delist or close markets directly. Markets are opened with subsidize, and their listing follows the configured oracle, which the safety admin chooses with set_oracle.
  • Set prices or fees directly. The contract charges no trading fee, and the listing fee is derived from the configured oracle's daily fee and listing_min_days. It is burned by the oracle, never paid to AXIS or to the admin.
  • Stop makers from leaving. Removals and approvals keep working while frozen, and an allowance can always be revoked directly on the token.

Frozen mode#

Entry point While frozen
trade, all kinds Blocked with Frozen (730)
swap Blocked
crossfill Blocked
update changing an order Blocked, and the whole batch fails
update removing orders Allowed
update granting approvals Allowed
subsidize Blocked
requote Blocked
order, market, config, frozen Allowed
freeze, delegate, set_oracle, set_floor, set_listing_min_days, set_ledger_time Allowed

Freezing touches no order and no allowance. Every call that could spend an allowance is blocked, and since the contract holds no funds between calls, nothing is stranded however long the freeze lasts. Orders keep expiring and their entries keep aging while the contract is frozen. Once trading resumes, every order that is still live and backed can be filled again. A maker who wants out during a freeze sends update batches that contain only removals and approvals.

No upgrade path#

The contract is non-upgradeable. No entry point replaces its Wasm, so the deployed code cannot change, and nobody can deploy code that spends the allowances behind open orders. A fix or a new feature ships as a new deployment with a new address. Orders do not migrate: makers remove them on the old contract and create new ones on the new contract. Allowances are granted per spender, so a new deployment also needs new allowances, and the old ones should be revoked.

Together with freeze, this is the recovery path. If something goes wrong, the safety admin freezes the contract, makers can still remove their orders and revoke their allowances, and their tokens never leave their wallets. Trading then continues on a new deployment, where makers recreate their orders.

Trust assumptions#

Safety admin#

The safety admin is trusted not to misuse its six calls. A freeze stops trading until it is lifted. A bad set_oracle can leave the contract without usable prices or listed assets, which blocks new Limit orders and order changes (see Changing the oracle). set_floor has no upper bound, so an excessive floor makes every new Limit order and every order change on a listed market fail with OrderSizeTooSmall (720). set_listing_min_days makes opening a market free or as dear as 255 days of feeds. A set_ledger_time far from the real close time makes the lifetimes the contract grants shorter or longer than intended. None of these calls can move funds, touch orders or allowances, or change fill prices, and makers can always remove orders and revoke allowances.

Oracle#

The oracle decides which assets count as listed, values new orders for the minimum order value check and sells the price feeds that the listing fee pays for. It plays no part in fill prices, settlement amounts or backing, and trade and update never call it. The contract assumes its base asset is USD.

An oracle that stops providing prices for more than 72 hours straight blocks new Limit orders and order changes with AssetPriceOracleFetchFailed (722) while the minimum order value is enabled, until a requote caches a fresh price. Fills, swaps, crossfills and removals keep working. The oracle cannot move user funds or change the price of a fill. Sponsors of subsidize authorize the oracle's track and the token call it makes, so they should check the configured oracle and the authorization tree before signing.

Token contracts#

Stellar Asset Contracts are part of the network and behave as documented. A custom SEP-41 token can burn the resource budget of transactions that touch it, report balances it does not honor or refuse transfers. Because the contract keeps no balance across calls, such a token affects only orders and trades in that token (see Custom tokens).

Routers and the AXIS API#

Routers choose which order IDs a taker lists and in which order. They are trusted for execution quality, never for safety. The contract enforces every maker's price and the taker's limit or bounds, and the trader signs the call arguments, so a router can at worst propose worse or stale orders, which leads to a worse price within the limit, or to skipped orders and a smaller fill. Effective depth from the indexer is advisory: the contract checks backing again at every fill.

Invariants#

  1. No custody. AXIS calls move tokens into the contract's balance only within a call: the makers' assets on every trade, swap hop and crossfill. Every call pays out exactly what it received, to the trader, or in a crossfill to the taker order's owner and the caller. The test suites for trades, swaps, crossfills, dust handling, allowances and removals assert a zero contract balance after each call.
  2. Order amounts change only through fills, updates and removals. A fill lowers the amount to amount - bought, an update by the owner sets it, and a removal deletes the order. An order is deleted exactly when its amount reaches zero or becomes dust.
  3. Prices are respected. No fill executes at a maker price beyond the taker's threshold. Every fill pays the maker at least the maker's price, and the taker's rounding loss stays under one base unit per fill. A crossfill pays the taker order's owner at least that order's price.
  4. Skipped is untouched. A listed order whose maker cannot back the fill, cannot receive the taker's asset or whose asset cannot be collected is left unchanged and reported with a skip event. Matching never removes, trims or caps an order.
  5. Events mirror storage. Every stored order emits one new, every fill one trade whose left is the stored amount (0 when the order is deleted), and every other change or removal of an order one mod. Expiry writes nothing and emits nothing.
  6. No user-signed amounts in trading. The only sub-invocation a trader authorizes is an approve whose amount is a call argument. Every settlement transfer runs under the contract's own authority, so a signed trade survives any change of the book.
  7. Spending needs the owner. An allowance is spent only to fill an order its owner created, at that order's price, or within a call its owner signed. An allowance with no open orders behind it cannot be spent by anyone else.
  8. Expired is gone until revived. Once expires <= now, no entry point except update acts on the order. It is never filled, crossed or returned by order, and its ID accepts a new order.

Known edge cases#

  • Self-trades are allowed. This is deliberate: the taker chooses the orders it lists, so it decides whether to include its own, and the contract does not compare the taker with the maker. A self-trade moves tokens from one account through the contract and back, and shrinks the order, and its trade event shows the same address as taker and maker. Volume statistics may want to filter such events.
  • The book can be crossed. A Limit trade sees only the orders it lists, so its remainder can end up crossing better orders it never listed. A maker skipped at settlement can also leave the remainder crossing that maker's order. Anyone can match the crossing orders with crossfill and keep the difference.
  • Valuation through the buying side. When the selling asset is not listed or has no usable cached price, an order is valued on the buying amount computed from the trader's own price. An absurd price can therefore get a tiny order past the floor. The dust rule still applies, and the order still trades only at its own price, so such an order is merely unlikely to fill.
  • No price improvement for crossed orders. A crossfill pays the taker order's owner exactly its limit price. The whole crossed spread goes to the caller.
  • Custom tokens. Their failures surface as skips, as the token's own errors, or as exhausted transaction budgets.
  • Failed payments blame nobody. When the payment to a maker fails, the call fails with the token's own error, which cannot tell a taker who cannot pay from a maker who cannot be credited, and no skip event is emitted. A swap ends with the token's error on any failed transfer, since it settles strictly (see Failure modes).
  • Assets that require authorization. Buying one fails with IntermediaryCannotReceive (712) until the issuer authorizes the AXIS contract (see Settlement and allowances).
  • Tokens sent to the contract are lost. No entry point pays out more than the same call received, so a direct transfer to the AXIS contract address can never be recovered.