AXIS Docs

DevelopersSmart contract

Markets and oracle

A market is the on-chain record of a pair that accepts orders on the book. Markets are opened with XRF, gated by the Reflector oracle and kept fresh by keepers.

Market record#

pub struct MarketSide {
    // token contract address
    pub asset: Address,
    // whether the asset is quoted by the oracle (with token decimals the valuation can handle)
    pub listed: bool,
    // token decimals (fetched only for listed assets)
    pub decimals: u32,
}

pub struct Market {
    // base asset, the first of the pair in canonical order
    pub base: MarketSide,
    // quote asset, the second of the pair in canonical order
    pub quote: MarketSide,
    // creation timestamp
    pub created: u64,
}

There is one market per unordered pair. It is stored under DataKey::Market(base, quote) with the two assets in the contract's canonical order, both trading directions use it, and market(x, y) finds it whichever way the pair is given. The canonical order compares serialized ScAddress values (for token contracts, their 32-byte contract IDs). It is not the alphabetical order of the C... strings: on Testnet, EURC (CCUU...) sorts before CETES (CC72...). The JS client's canonicalPair and compareAssets reproduce the contract's order.

The record is a persistent entry of about 460 bytes. It is rewritten only when a side's listing or decimals change, and created never changes. The record has no "last checked" field. Instead, every check against the oracle emits a refresh event with the topics ["refresh", base, quote] and no data, whether or not the record changed.

Reading a market (requote, subsidize, the market view) extends its entry to 120 days once fewer than 30 are left. Writing an order to it (a Limit trade, or an update that changes an order) extends it to 121 days once fewer than 120 are left, so the market outlives every order written to it. A busy market pays for one such extension a day.

Why markets exist#

Takers do not need markets. Fill and FillOrKill trades, swap and crossfill never load one. Markets exist for orders that stay on the book, which occupy ledger state and show up in every view of the book:

  • Listing gate. A market can be opened only when the Reflector oracle quotes at least one of its assets, and its sponsor burns the listing fee in XRF. A pair in which the oracle quotes neither token cannot accept orders on the book, and opening a pair has a real cost, which helps to keep trash tokens and wash-trading pairs out.
  • Minimum order value. The oracle quote lets the contract value every new order in USD and reject orders below the configured floor, so the book cannot fill up with dust-sized orders (see Prices and rounding).
  • Feed funding. The listing fee pays for the oracle price feeds that the contract reads to perform those checks.

Oracle calls#

AXIS uses a Reflector Beam oracle, through the interface in src/reflector_beam.rs:

Oracle call Called by Purpose
decimals() Constructor, set_oracle Price decimals, stored in instance storage
fee_config() Constructor, set_oracle, set_listing_min_days Fee token and daily per-asset fee, which set the listing fee
expires(asset) subsidize, requote Listing check: an asset the call fails for is not quoted
lastprice(caller, asset) subsidize, requote Latest price. Needs active tracked access for the AXIS contract
track(sponsor, consumer, assets, amount) subsidize Burns XRF from the sponsor, split evenly across the assets, and extends access
tracked_until(consumer, assets) subsidize Current access expirations, when no XRF beyond the listing fee is spent

trade and update never call the oracle. They read the price cache only, so their cost and their outcome never depend on the oracle contract.

Opening a market#

Only subsidize(sponsor, selling, buying, amount) opens a market:

  1. The sponsor authorizes the call, including the oracle's track and the XRF burn that track performs. The contract must not be frozen (Frozen, 730), amount must be positive (InvalidAmount, 706) and the two assets must differ (InvalidMatch, 704).
  2. If the pair has no market yet, amount must cover market_listing_fee (InvalidAmount, 706).
  3. Both assets are checked against the oracle, and the contract reads the token decimals of each listed one. At least one must be listed (AssetsNotVerifiedByOracle, 721).
  4. The listing fee is burned with one track call for the listed assets (no call when the fee is zero), the market record is stored, and a refresh event is emitted.
  5. Whatever amount holds beyond the fee is spent on a second track call.
  6. The prices of the listed assets are fetched into the cache.

subsidize returns the new access expiration (UNIX seconds) of each listed asset. The sponsor never burns more than amount. A sponsor without enough XRF fails in the token's burn, and nothing is created.

Listing fee#

market_listing_fee = daily_fee * listing_min_days

listing_min_days is 90 at deployment, and the safety admin can set it from 0 to 255 with set_listing_min_days (InvalidAmount, 706, above 255). The constructor, set_oracle and set_listing_min_days derive the fee from the oracle's fee_config. It is never set directly, and an oracle without a positive daily fee is rejected (InvalidOracleConfig, 723). Because track splits its amount evenly across the assets it covers, listing_min_days counts asset-days: at 90, the fee buys 90 days of feed for a pair with one listed asset, and 45 days of each feed for a pair with two. A later change of the oracle's own daily fee reaches AXIS only through the next set_oracle or set_listing_min_days.

With listing_min_days at 0 the listing fee is zero. subsidize then opens a market for any positive amount and burns only what it is given. A zero or negative amount still fails with InvalidAmount (706).

On Testnet the configured market_listing_fee (marketListingFee in the AXIS API) is 180000000000 stroops, that is 18,000 XRF at the default 90 days, so the daily fee is 200 XRF per asset. Opening a pair of two listed assets buys 45 days of each feed, and every additional day of both feeds costs 400 XRF.

Subsidizing an existing market#

On an existing market, subsidize first re-checks both sides against the oracle exactly as requote does, and fails with AssetsNotVerifiedByOracle (721) if neither is listed any more. It then spends the whole amount on one track call for the assets listed now and refreshes their prices. Anyone can top up any market. While access is still active, the oracle adds the purchased time to the current expiration.

Requote#

requote(selling, buying) is permissionless: it checks no signature.

  1. It returns None for a pair without a market, and fails with Frozen (730) while the contract is frozen.
  2. It re-checks both sides against the oracle, rewrites the record only if a listing or decimals changed, extends the market entry's TTL when it runs low and emits refresh.
  3. It reads lastprice for each listed side. A failed call, an empty result or a non-positive price leaves the previous cached record in place.
  4. It returns the market record.

A requote with nothing new writes no contract state. The JS client's requote simulates first and submits a transaction only when the simulation writes something.

Price cache#

pub struct PriceCache {
    // price in oracle decimals
    pub price: i128,
    // price record timestamp (in seconds)
    pub timestamp: u64,
    // price decimals of the oracle the price was read from
    pub decimals: u32,
}

The cache is a temporary entry per asset, keyed by the asset address and shared by every market that contains the asset. requote and subsidize write it only when the oracle's record or decimals differ from the cached ones, and every write sets its TTL to 72 hours. A cached price is usable for valuing orders when both conditions hold:

  • it was cached with the current oracle's price decimals (decimals equals the stored oracle decimals),
  • its oracle timestamp is at most 72 hours old (MAX_PRICE_AGE).

Every valuation reads the cache entries of both market assets, whatever their listing flags and ages, so the transaction footprint does not change between simulation and inclusion. Between refreshes, orders are valued at the last cached price. That affects only the minimum order value check, never the price of a fill.

What needs a market#

Operation Market Listed asset Usable cached price
trade with Limit Required (721) At least one (721) On the valuation side (722), unless the floor is 0
update changing an order Required (721) At least one (721) On the valuation side (722), unless the floor is 0
update removing an order No No No
Fill, FillOrKill, swap, crossfill No No No

Delisting#

No admin call delists a market. Listing follows the oracle: when the oracle stops quoting an asset, the next requote or subsidize records that side as unlisted (when no side stays listed, only requote can record it, because subsidize then fails with 721), and when it starts quoting one, the next check records it as listed.

  • One side still listed. New orders are valued on that side.
  • Both sides unlisted. Limit trades, subsidize and every update that changes an order fail with AssetsNotVerifiedByOracle (721). Fills, swaps, crossfills, removals and approvals keep working, so the market's orders stay fillable and cancelable.

Until a check records the change, cached prices keep serving for at most 72 hours after their oracle timestamp. A check also records as unlisted an asset the oracle quotes but whose decimals the valuation cannot handle (see Decimal limits).

Changing the oracle#

The safety admin's set_oracle(oracle) validates the new oracle (InvalidOracleConfig, 723, for more than 24 price decimals or no positive daily fee), stores it with its decimals, re-derives the listing fee from its daily fee and listing_min_days and emits a config event. Its effects on markets:

  • When the new oracle quotes with the same decimals, cached prices keep valuing orders until they reach the 72-hour age limit. When the decimals differ, every cached price reads as missing, and while the floor is non-zero, Limit trades and order changes fail with AssetPriceOracleFetchFailed (722) until prices from the new oracle are cached.
  • Feed access is held by each oracle contract, so access bought from the previous oracle does not apply to the new one. A market needs a subsidize on the new oracle before requote can read prices again.
  • Market records keep their listing flags until a requote or subsidize checks them against the new oracle. After a switch to an oracle with more decimals, that check delists an asset whose decimals no longer fit instead of failing.

Decimal limits#

  • The oracle quotes with at most 24 decimals, checked by the constructor and set_oracle (InvalidOracleConfig, 723).
  • An asset can be valued only when oracle decimals plus token decimals do not exceed 37, which keeps 10^(oracle_decimals + token_decimals) within i128, so 23 token decimals under Reflector's 14. The check runs whenever a side is resolved (market creation, requote, subsidize). An asset beyond it is recorded as unlisted, with zero decimals, even when the oracle quotes it.

The USD floor#

Config.min_trade_size is a USD value with 7 decimals (1 USD = 10,000,000). The constructor sets it, set_floor changes it, and 0 disables the check. On Testnet it is 10000, that is 0.001 USD. The contract never checks the oracle's base asset: it assumes USD, so an oracle quoting in another base would turn the floor into an amount of that base.

Keeper duties#

Markets stay usable for new orders only while someone calls requote within the 72-hour price age, calls subsidize before feed access lapses and extends the contract lifetime. All three are permissionless or open to any sponsor. Running a keeper describes the duties, schedules and tooling. The Testnet markets and the oracle address are listed in Networks and deployments.