AXIS Docs

DevelopersContract reference

Types

Argument and record types of the AXIS contract, the order ID formula, the on-chain order encoding and the units of amounts and prices.

The definitions below are adapted from the contract source (order.rs, trade.rs, market.rs, pricing.rs). On the wire every type is an XDR ScVal:

  • Integer enums (TradeDirection, OrderKind) are u32 values.
  • Structs are map values keyed by the field names as symbols.
  • Option<T> is the value itself or void. Vec<T> and tuples (the return values) are vec values.
  • i128, u128 and u64 decode to bigint with scValToNative from @stellar/stellar-sdk, u32 to a number and Address to a G... or C... string.

The JS client keeps the contract's snake_case field names in decoded records such as Config (listing_min_days, market_listing_fee, min_trade_size) and uses camelCase in its own argument objects (liveUntil for live_until, sellingAmount for selling_amount).

Enums#

TradeDirection#

#[repr(i16)]
pub enum TradeDirection {
    // Sell a fixed amount of asset
    Sell = 1,
    // Buy a fixed amount of asset
    Buy = 2,
}
Variant Value Meaning
Sell 1 The trader spends a fixed amount of selling. price is the minimum buying per 1 selling.
Buy 2 The trader acquires a fixed amount of buying. price is the maximum selling per 1 buying.

OrderKind#

#[repr(i16)]
pub enum OrderKind {
    // Execute trade, create a limit order if not executed in full
    Limit = 1,
    // Execute trade without creating a limit order
    Fill = 2,
    // Execute trade, fail if it cannot be executed in full
    FillOrKill = 3,
}
Variant Value Meaning
Limit 1 Match the listed orders, then store the remainder as an order. Needs a market, and a fresh cached price while the minimum order value is enabled.
Fill 2 Match what the listed orders allow and stop (immediate-or-cancel). A market order in trader terms.
FillOrKill 3 Fill-or-Kill: match in full or fail with NotFilled (709). Nothing moves on failure.

There are no separate immediate-or-cancel or post-only flags.

Trading types#

Order#

pub struct Order {
    // Unique order identifier, derived from the owner and the client nonce
    pub id: u128,
    // Selling token address
    pub selling: Address,
    // Buying token address
    pub buying: Address,
    // Amount left to sell
    pub amount: i128,
    // Maker address
    pub owner: Address,
    // Order price (`buying` per 1 `selling`, 18 decimals)
    pub price: i128,
    // Expiration timestamp (0 = no expiration)
    pub expires: u64,
}
Field Type Unit Meaning
id u128 See order ID derivation.
selling Address Token the maker sells.
buying Address Token the maker receives.
amount i128 base units of selling Amount left to sell.
owner Address Maker.
price i128 18 decimals buying base units per 1 selling base unit, times 10^18.
expires u64 UNIX seconds 0 means no expiration. Otherwise the order is expired once expires <= now in ledger time.

The order view returns this struct. Orders are always stored sell-equivalent: a Buy remainder becomes an order selling the trade's selling asset (see trade). The ledger entry uses a smaller positional encoding.

Approval#

pub struct Approval {
    // Token the allowance is granted on
    pub asset: Address,
    // Absolute allowance amount
    pub amount: i128,
    // Ledger sequence the allowance lives until
    pub live_until: u32,
}
Field Type Unit Meaning
asset Address Token to approve, normally the selling asset of the call.
amount i128 base units of asset New allowance of the AXIS contract. Absolute, not a delta. 0 revokes.
live_until u32 ledger sequence Expiration of the allowance entry.

The contract calls approve(trader, AXIS, amount, live_until) on the token as a sub-invocation the trader signs. For a Stellar Asset Contract, live_until may not exceed the network maximum entry TTL (about 180 days ahead) and may not be in the past when amount is positive. The allowance reads as zero after it expires and has to be granted again. See Settlement and allowances.

OrderUpdate#

pub struct OrderUpdate {
    // Order ID
    pub id: u128,
    // New amount to sell, 0 removes the order
    pub amount: i128,
    // New order price (ignored when the order is removed)
    pub price: i128,
    // New expiration timestamp, 0 = no expiration (ignored when the order is removed)
    pub expires: u64,
}
Field Type Unit Meaning
id u128 Order to change. Missing IDs are skipped.
amount i128 base units of the order's selling New amount. 0 removes the order.
price i128 18 decimals New price. Ignored for a removal.
expires u64 UNIX seconds New expiration, 0 for none. Ignored for a removal.

The selling and buying assets of an order never change.

TradeStep#

pub struct TradeStep {
    // Asset to buy at this step
    pub asset: Address,
    // Maker order IDs to match
    pub orders: Vec<u128>,
}
Field Type Meaning
asset Address Asset bought at this hop. The hop sells the previous step's asset, or selling for the first step.
orders Vec<u128> Maker orders to match at this hop, in order. Orders of another pair are skipped.

Market and configuration types#

Market#

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,
}
Field Type Unit Meaning
base MarketSide Base asset, the first in canonical order.
quote MarketSide Quote asset, the second in canonical order.
created u64 UNIX seconds When subsidize opened the market.

There is one market per unordered pair. Canonical order compares the encoded ScAddress bytes of the two assets, which is not the order of the C... strings. On Testnet, EURC (CCUU...) sorts before CETES (CC72...). The JS helpers canonicalPair and compareAssets reproduce this order.

MarketSide#

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,
}
Field Type Meaning
asset Address Token contract.
listed bool Whether the oracle quoted the asset at the last check (subsidize or requote) and its token decimals plus the oracle decimals stay within 37.
decimals u32 Token decimals, read only for listed assets. 0 when not listed.

Config#

pub struct Config {
    // Account allowed to freeze the contract and change the configuration
    pub safety_admin: Address,
    // Reflector Beam price oracle contract address
    pub oracle: Address,
    // Days of price feeds a new market must buy, zero opens markets without a fee
    pub listing_min_days: u32,
    // Amount of XRF stroops burned by the market creator to provision the oracle price
    // feeds for the listed market assets: the oracle's daily fee times `listing_min_days`
    pub market_listing_fee: i128,
    // Minimum value a trade must sell, in USD with 7 decimals (1 USD = `MIN_TRADE_SIZE_UNIT`),
    // zero disables the limit
    pub min_trade_size: i128,
    // Expected average ledger close time in seconds
    pub ledger_time: u32,
}
Field Type Unit Meaning Testnet value
safety_admin Address Holder of the six safety admin calls. GBDCULE53LUPK4XHUCXBI35MAZFQHENMZ3JRKAJS2PPYBV646M6XKVHG
oracle Address Reflector Beam oracle. CDEIQLTE3Y3XQMYPSZFJC2OXIS67F4MURGP73NCE2C2NZZ22RNSPV7ZI
listing_min_days u32 Days Days of price feeds the listing fee buys, counted per asset. 90 at deployment, 0 to 255, set with set_listing_min_days. 0 opens markets without a fee. 90
market_listing_fee i128 XRF base units Oracle daily per-asset fee times listing_min_days, derived, never set directly. 180000000000 (18,000 XRF)
min_trade_size i128 USD, 7 decimals Minimum order value. 0 disables it. 10000 (0.001 USD)
ledger_time u32 Seconds Expected ledger close time, used to convert every lifetime the contract sets into ledgers. 5 at deployment, 1 to 20, set with set_ledger_time. 5

The minimum order value applies to Limit trades (the whole amount) and to updates, valued at the cached oracle price. See Minimum order value for the valuation rule and its errors.

Order ID derivation#

Order IDs are chosen by the client through a u64 nonce and computed off-chain. There is no on-chain view for them.

id = u128::from_be_bytes(sha256(xdr(ScVec[owner: Address, nonce: u64]))[0..16])

The hashed bytes are the XDR of the ScVal vector [owner, nonce]. The first 16 bytes of the SHA-256 digest, read big-endian, form the full 128-bit ID. Consequences:

  • IDs are not sequential and do not include the pair. The same nonce collides on every pair.
  • A nonce must be unique among the owner's live orders, otherwise trade fails with OrderExists (711). Once an order is removed or expired its nonce can be reused. A new order under the ID of a removed order starts a fresh entry at the network minimum lifetime. One under the ID of an expired order overwrites the old entry, whose lifetime is then extended as for an update.
  • The ID is known before the transaction is simulated, so clients can declare the new order entry in the footprint.

The JS client exports orderId. Its sell and buy methods generate the nonce themselves as (Date.now() << 22) | 22 random bits.

import {orderId} from '@axis-markets/client'

const id = orderId('GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI', 12345n)
console.log(id) // 104716339259087188276464058217096427616n

The same computation with @stellar/stellar-sdk only:

import {hash, nativeToScVal} from '@stellar/stellar-sdk'

function computeOrderId(owner, nonce) {
    const payload = nativeToScVal([owner, nonce], {type: ['address', 'u64']}).toXDR()
    const digest = hash(payload) // SHA-256
    let id = 0n
    for (const byte of digest.subarray(0, 16)) {
        id = (id << 8n) | BigInt(byte)
    }
    return id
}

Test vectors from the client test suite:

Owner Nonce Order ID
GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI 0 87503119175631750522483667960259619256
GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI 12345 104716339259087188276464058217096427616
GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI 18446744073709551615 239482216881556756373704228044123948254
CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC 777 260602579416723494792706352430735587842

Order storage encoding#

An order is a persistent contract data entry of the AXIS contract. The key is the raw u128 order ID. The value is a positional Vec without the ID:

[owner: Address, selling: Address, buying: Address, amount: i128, price: i128, expires: u64]

expires is appended only when it is not zero, so an order without expiration has five elements. An order without expiration takes about 172 to 176 bytes of value and about 250 bytes of ledger entry, and expires adds 12 bytes. The positional form keeps rent and write fees low (see Order lifecycle and rent).

Read an order entry directly over RPC, for example to inspect its TTL:

import {rpc, scValToNative} from '@stellar/stellar-sdk'
import {orderLedgerKey} from '@axis-markets/client/footprint'

const AXIS = 'CA6P26K4QNNIMTYP22ILTSCXQQDEKNWJIZJPC34YEZYOLBNT7YUPX7XS'
const server = new rpc.Server('https://soroban-testnet.stellar.org')

const id = 137718931818025766751180795007231811965n
const {entries} = await server.getLedgerEntries(orderLedgerKey(AXIS, id))
if (entries.length) {
    const [owner, selling, buying, amount, price, expires = 0n] = scValToNative(entries[0].val.contractData.val)
    console.log({owner, selling, buying, amount, price, expires, liveUntil: entries[0].liveUntilLedgerSeq})
}

A raw read ignores expiration. The order view returns None once expires <= now, and matching skips the order.

Storage keys#

Entry Storage Key Value Lifetime
Order persistent u128 order ID positional Vec Network minimum TTL at creation (about 120 days on mainnet, 7 days on Testnet). update extends it, and so does a new order written over an expired entry.
Market persistent DataKey::Market(base, quote) Market Extended to 120 days when fewer than 30 days are left whenever the contract reads it, and to 121 days when fewer than 120 are left on every Limit trade and every update that changes an order on it.
Price cache temporary asset Address PriceCache 3 days, set on every write.
Configuration instance DataKey::Config Config Contract instance TTL.
Oracle decimals instance DataKey::OracleDecimals u32 Contract instance TTL.
Frozen flag instance DataKey::Frozen bool Absent until the first freeze call (reads as false).
pub enum DataKey {
    // Contract configuration (instance)
    Config,
    // Price oracle decimals (instance)
    OracleDecimals,
    // Withdrawal-only mode flag (instance)
    Frozen,
    // Market record for the canonically ordered asset pair (persistent)
    Market(Address, Address),
}

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,
}

DataKey variants encode as vectors that start with the variant name: ["Config"], ["OracleDecimals"], ["Frozen"] and ["Market", base, quote] with the assets in canonical order. Build a market key like this:

import {Address, xdr} from '@stellar/stellar-sdk'
import {canonicalPair} from '@axis-markets/client'

function marketLedgerKey(contractId, x, y) {
    const [base, quote] = canonicalPair(x, y)
    return xdr.LedgerKey.contractData(new xdr.LedgerKeyContractData({
        contract: Address.fromString(contractId).toScAddress(),
        key: xdr.ScVal.scvVec([
            xdr.ScVal.scvSymbol('Market'),
            Address.fromString(base).toScVal(),
            Address.fromString(quote).toScVal()
        ]),
        durability: xdr.ContractDataDurability.persistent
    }))
}

A cached price is usable while its oracle timestamp is at most 72 hours old and it was cached with the current oracle's price decimals. While the minimum order value is enabled, Limit trades and updates read the cache entries of both assets of the pair, listed or not, so their footprint does not depend on the listing flags. Only requote and subsidize write the cache (see Price cache). The TTL rules of every entry are in TTL policy.

Price and amount units#

Amounts are i128 integer base units of a token. All Testnet assets (XLM, USDC, EURC, CETES) have 7 decimals, so 1 token is 10,000,000 base units ("stroops").

Prices are i128 fixed point numbers: buying base units per 1 selling base unit, times 10^18 (PRECISION in the contract, PRICE_SCALE in the JS client). The accepted range is 1 to 10^36, anything else fails with InvalidPrice (705). The contract never adjusts for token decimals. For a human price P in buying tokens per selling token:

price = P * 10^(18 + buyingDecimals - sellingDecimals)
Order Human price price argument
Sell XLM for USDC (7 and 7 decimals) 0.25 USDC per XLM 250000000000000000
Buy XLM with USDC, Buy direction, selling = USDC at most 0.25 USDC per XLM 250000000000000000
Sell USDC for XLM 4 XLM per USDC 4000000000000000000
Sell a 6-decimal token for a 7-decimal token 2 per token 20000000000000000000

The same price seen from the other side of the market is floor(10^36 / price) (invertPrice in the JS client). The contract rounds the other way, ceil(10^36 / price), when it stores a Buy remainder. An amount is dust when it is worth less than one base unit of the counter asset at the order price, floor(amount * price / 10^18) == 0. See Prices and rounding for the fill formulas.

Other units:

Value Type Unit
expires, created u64 UNIX seconds (ledger close time)
price cache timestamp u64 UNIX seconds (time of the oracle record)
live_until u32 Ledger sequence, about 5 seconds per ledger and 17,280 ledgers per day
ledger_time u32 Seconds per ledger, the contract's conversion factor for lifetimes
listing_min_days u32 Days of price feeds per listed asset
min_trade_size i128 USD with 7 decimals (1 USD = 10,000,000)
market_listing_fee, subsidize amount i128 XRF base units (7 decimals)
Price cache price i128 USD in the oracle's decimals