AXIS Docs

DevelopersSmart contract

Prices and rounding

AXIS prices are 18-decimal ratios of base units. This page covers price units, decimal conversion, fill arithmetic, rounding, dust and the order value floor.

Price representation#

The constants live in src/math.rs:

// fixed-point price precision (18 decimals)
pub const PRECISION: i128 = 10i128.pow(18);
// lowest accepted order price
pub const MIN_PRICE: i128 = 1;
// highest accepted order price, the inverse of `MIN_PRICE`
pub const MAX_PRICE: i128 = PRECISION * PRECISION;

A stored order's price is the number of buying base units per one selling base unit, multiplied by 10^18. Prices outside [1, 10^36] fail with InvalidPrice (705). Matching never adjusts for token decimals: every amount and price is in base units. Products go through mul_div_floor and mul_div_ceil, which fall back to 256-bit arithmetic and fail with Overflow (740) only when the result itself does not fit i128.

Price in trade calls#

Direction amount counts price means Stored remainder
Sell selling to send Minimum buying per 1 selling Sells the rest of amount at price
Buy buying to receive Maximum selling per 1 buying Converted to a sell order, see below

In both directions price is units of the other asset per unit of the asset that amount counts, so selling 100 XLM for at least 0.25 USDC each and buying 100 XLM for at most 0.25 USDC each use the same value. swap takes amount bounds instead of a price, and crossfill uses the taker order's price.

Converting decimal prices#

For a price P in whole tokens of buying per whole token of selling:

price = P * 10^(18 + d_buying - d_selling)
P     = price / 10^(18 + d_buying - d_selling)

For a Buy limit the roles swap: P is whole selling tokens per whole buying token, and the exponent is 18 + d_selling - d_buying. With two 7-decimal tokens the decimals cancel: selling 100 XLM at 0.25 USDC each is amount = 1_000_000_000, price = 250_000_000_000_000_000. Selling an 18-decimal token TKN for USDC at 2.5 USDC per TKN gives 2.5 * 10^(18 + 7 - 18) = 25_000_000, and selling USDC for TKN at 0.4 TKN per USDC gives 4 * 10^28. The accepted range covers whole-token prices from 10^(-18 - D) to 10^(18 - D), where D = d_buying - d_selling.

// decimal price string to the contract scale, extra digits truncated
function toContractPrice(price, sellingDecimals, buyingDecimals) {
    const [whole, fraction = ''] = price.split('.')
    const digits = BigInt(whole + fraction)
    const exponent = 18 + buyingDecimals - sellingDecimals - fraction.length
    return exponent >= 0 ? digits * 10n ** BigInt(exponent) : digits / 10n ** BigInt(-exponent)
}

toContractPrice('0.25', 7, 7) // 250000000000000000n
toContractPrice('2.5', 18, 7) // 25000000n

Matching thresholds#

Every listed order is compared with a threshold derived from the taker's limit (get_price_threshold in src/orderbook.rs):

Taker direction Maker order accepted when
Sell order.price <= floor(10^36 / price)
Buy order.price <= price

A maker order priced above the threshold is skipped without an error or an event. A fill always executes at the maker's stored price, never at the taker's limit.

To accept every maker up to a worst maker price w, such as the worstPrice of an AXIS API quote, pass price = w for Buy and price = floor(10^36 / w) for Sell, as the JS client does for market orders. The Sell round trip never excludes w, since floor(10^36 / floor(10^36 / w)) >= w.

Fill amounts#

The maker order sells asset Y, has amount A and price p (X per Y, times 10^18), and the taker pays with X. left is what the taker still has to trade, in X for Sell and in Y for Buy.

Sell taker:
  cost = ceil(A * p / 10^18)
  if left >= cost:  sold = cost, bought = A
  else:             bought = floor(left * 10^18 / p)
                    sold   = ceil(bought * p / 10^18)

Buy taker:
  bought = min(left, A)
  sold   = ceil(bought * p / 10^18)

sold is what the taker pays, bought what the maker delivers. A fill where either value is zero, or whose cost does not fit i128, is skipped. Pricing the whole order first keeps a huge left against a very cheap order from overflowing. After each admitted fill left drops by sold (Sell) or bought (Buy).

Rounding policy#

  • The taker's output is rounded down and the taker's cost is rounded up.
  • A maker is never underpaid: sold * 10^18 >= bought * p holds for every fill.
  • The taker pays less than one base unit above the exact price, per fill.
  • A partial Sell fill can leave part of left unspent, less than the price of one more base unit of the maker's asset. It stays with the taker, or joins the remainder of a Limit trade.

Rounding and the taker's limit#

The taker's price limit is compared with the order prices (see Matching thresholds), not with the rate each fill ends up at after rounding. Rounding can therefore take a fill past the taker's limit, by less than one base unit of the asset the taker pays per fill, so by under 20 base units per trade at the fill cap.

For example, take two tokens without decimals. A maker sells 2 EUR at 0.6 USD per EUR, so the whole order costs 1.2 USD, rounded up to 2 USD. A taker selling USD for at least 1.6 EUR per USD accepts the order, since 0.6 is below 1 / 1.6 = 0.625, and receives 2 EUR for 2 USD: 1 EUR per USD instead of the 1.6 it asked for.

With 7-decimal Stellar assets the overshoot is a few stroops and does not matter. A base unit of a token with few decimals can carry real value, though. Fills are deterministic, so routers trading such tokens should compute each fill with the fill formulas before listing an order, and leave out orders whose rounding exceeds what the taker accepts. Selling into an order buys floor(left * 10^18 / p) for ceil(bought * p / 10^18), or takes the whole order for ceil(A * p / 10^18) when left covers it. Buying pays ceil(bought * p / 10^18). A swap is also bounded as a whole by selling_amount and buying_amount.

The contract does not enforce the limit on the rounded amounts, on purpose. That would refuse a fill at exactly the limit price whenever the amounts do not divide evenly, and the 18-decimal inverse of a Sell limit is itself rounded, so even exact fills against an order priced at the inverted limit would fall a fraction of a unit short of it.

Sell-equivalent Buy remainders#

The remainder of a Buy Limit trade is stored as a sell order of the asset the buyer pays with:

remainder amount = ceil((amount - bought) * price / 10^18)   of selling
remainder price  = ceil(10^36 / price)                        buying per selling

Both values round up: the amount is the most the rest of the purchase can cost at the limit, and the inverse price makes whoever fills the order deliver at least what the limit implies. The buyer never pays more than the limit per unit received.

For example, buying 30 XLM for at most 0.3 USDC each, with nothing filled, stores an order selling ceil(300_000_000 * 0.3) = 90_000_000 USDC stroops at ceil(10^36 / (3 * 10^17)) = 3_333_333_333_333_333_334. Filling it completely costs ceil(90_000_000 * 3_333_333_333_333_333_334 / 10^18) = 300_000_001 stroops of XLM: one stroop more than 30 XLM, never less.

Dust#

An amount is dust at a price when floor(amount * price / 10^18) == 0 (is_dust in src/math.rs): it is worth less than one base unit of the counter asset. Costs round up, so any fill against it would cost a whole base unit, more than it is worth. The contract never stores such an amount:

Situation Result
A Limit trade fills nothing and its remainder is dust Fails with OrderSizeTooSmall (720)
A Limit trade fills something and leaves a dust remainder Succeeds without storing an order
A fill leaves dust on a maker order The order is removed, its trade event shows left = 0
A crossfill leaves dust on the taker order The taker order is removed
An update sets a dust amount Fails with OrderSizeTooSmall (720)
A Sell Fill-or-Kill leaves an unspent part too small to buy one base unit at the highest order price it filled Counts as fully executed, the trader keeps the unspent part

Dust depends on the price and the decimals. Between two 7-decimal tokens priced near 1, at most a single stroop is dust, and nothing is dust at a price of 1 or more. An order selling USDC for a 2-decimal token TKN at 2,500 USDC per TKN (price 4_000_000_000) treats anything under 25 USDC as dust, because 25 USDC is the cost of one TKN base unit.

Minimum order value#

The floor (Config.min_trade_size, USD with 7 decimals, 0 disables it) applies to Limit trades, on the whole amount before any matching, and to each new amount in update. Fills, swaps, crossfills and removals never check it. enforce_min_order_value in src/pricing.rs values the order on the selling asset when the market lists it and a usable price is cached, otherwise on the buying asset when that one is listed and priced. Both trade and update load the market first, and fail with AssetsNotVerifiedByOracle (721) when it is missing or has no listed asset. When no listed side has a usable cached price, the call fails with AssetPriceOracleFetchFailed (722).

Both amounts come from the trader's price. For Sell, and for every update, the selling amount is amount and the buying amount ceil(amount * price / 10^18). For Buy it is the other way around. With tokens the amount on the valuation side, in base units:

tokens * oracle_price >= ceil(min_trade_size * 10^(oracle_decimals + token_decimals) / 10^7)

oracle_price is the cached price in the oracle's decimals, token_decimals comes from the market record, and the oracle's base asset is assumed to be USD. The threshold rounds up, so the floor is never undershot. An order below it fails with OrderSizeTooSmall (720).

On Testnet the floor is 10_000, that is 0.001 USD. Assume an oracle with 14 decimals and a cached XLM price of 0.25 USD (oracle_price = 25_000_000_000_000):

threshold = ceil(10_000 * 10^(14 + 7) / 10^7) = 10^18
tokens   >= 10^18 / 25_000_000_000_000 = 40_000 stroops (0.004 XLM)

0.004 XLM at 0.25 USD is exactly 0.001 USD: the oracle decimals cancel out. Since only the whole amount is checked, the remainder of a Limit trade can be created below the floor. trade and update read the price cache and never call the oracle.

Worked examples#

All three use the exact integer arithmetic of src/math.rs.

A partial fill for a Sell taker#

A maker sells 100 XLM for USDC at 0.2501 USDC per XLM (amount = 1_000_000_000, price = 250_100_000_000_000_000). A taker sells 10 USDC (100_000_000) with a Fill trade and a limit of at least 3.99 XLM per USDC (price = 3_990_000_000_000_000_000).

  1. Threshold: floor(10^36 / 3_990_000_000_000_000_000) = 250_626_566_416_040_100, above the maker's price.
  2. The whole order costs ceil(1_000_000_000 * 0.2501) = 250_100_000, more than the taker has, so the fill is partial.
  3. bought = floor(100_000_000 * 10^18 / 250_100_000_000_000_000) = 399_840_063 stroops of XLM.
  4. sold = ceil(399_840_063 * 0.2501) = ceil(99_999_999.7563) = 100_000_000.

The taker pays 10 USDC for 39.9840063 XLM, 0.2437 of a base unit above the exact cost. The trade event reports sold = 100_000_000, bought = 399_840_063, left = 600_159_937.

A Buy limit order with a remainder#

A taker buys 50 XLM with USDC for at most 0.25 USDC per XLM (direction = Buy, selling = USDC, buying = XLM, amount = 500_000_000, price = 250_000_000_000_000_000). One listed maker sells 20 XLM at 0.249 USDC per XLM.

  1. Minimum order value: with USDC listed and priced, the selling side is valued at ceil(500_000_000 * 0.25) = 125_000_000, that is 12.5 USDC.
  2. The maker price 249_000_000_000_000_000 is at most price, so the order is accepted.
  3. Fill: bought = min(500_000_000, 200_000_000) = 200_000_000, sold = ceil(200_000_000 * 0.249) = 49_800_000. The maker order is filled completely.
  4. Remainder: for the 30 XLM still wanted, the stored order sells ceil(300_000_000 * 0.25) = 75_000_000 USDC stroops at ceil(10^36 / (2.5 * 10^17)) = 4 * 10^18, that is 4 XLM per USDC.

The buyer needs 7.5 USDC of balance and allowance left after paying the maker. The call returns (49_800_000, 200_000_000, Some(id)) and emits a trade event with left = 0, then a new event.

A Fill-or-Kill with an unspendable leftover#

A maker sells 1 TKN, a 2-decimal token, at 2,000 USDC per TKN (amount = 100, price = 2000 * 10^(18 + 7 - 2) = 2 * 10^26). A taker sends a Sell Fill-or-Kill of 50 USDC (500_000_000) with a limit of at least 0.0004 TKN per USDC (price = 0.0004 * 10^(18 + 2 - 7) = 4_000_000_000).

  1. Threshold: floor(10^36 / 4_000_000_000) = 2.5 * 10^26, above the maker's price.
  2. The whole order costs 2,000 USDC, so the fill is partial: bought = floor(500_000_000 * 10^18 / (2 * 10^26)) = 2 base units (0.02 TKN) and sold = ceil(2 * 2 * 10^26 / 10^18) = 400_000_000 (40 USDC).
  3. The unspent 10 USDC cannot buy one more base unit at the highest order price filled: floor(100_000_000 * 10^18 / (2 * 10^26)) = floor(0.5) = 0.

The Fill-or-Kill counts as executed and returns (400_000_000, 2, None). The trader keeps the 10 USDC and the maker order keeps 98 base units. A Limit trade with the same arguments would store nothing either, since its remainder is dust at the trader's price: floor(100_000_000 * 4_000_000_000 / 10^18) = floor(0.4) = 0.