All pages

Token, AMM and escrow

This page is the reference for the three contracts in chain/contracts/: every exported function with its arguments, return value, checks and events, every storage key, the market's constants and exact formulas with their rounding, and what the tests hold them to. It is written for contract developers, integrators and auditors. How to call them from the command line is on Writing contracts.

Conventions

Arguments are the concatenated values of the SDK, read in the order given. address is 32 bytes, u128 16 and u64 8, both little-endian, and bytes has a u32 length in front. A return of u128 is 16 bytes in output.

Amounts are whole base units. The native token is the all-zero address, NOBODY in the SDK and native on the command line.

A function finishes Done, or fails with Reverted and one of the reasons listed for it, undoing everything. Two reasons apply throughout and are not repeated. Too few argument bytes give missing arguments, and arithmetic overflow, caught by overflow-checks, gives panic. Running out of gas ends OutOfGas. See Outcomes and gas.

Only open, add_liquidity and swap look at qb::value(). Native tokens sent with any other call are accepted and stay in that contract's balance, where nothing can reach them.

The shipped builds, as qubits contract show reports them:

ModuleBytesCode hash
out/token.wasm6,677003b52fed25b748080c8d446d4eac5cbe5272b9b14b405625e9cd58581a60c75
out/escrow.wasm5,290f64173f6468b0efac4d666b5554f9f5799022fd2f258302bfbc8c0067a16aa15
out/amm.wasm14,28212d180af9b431aaeefbf817ddfee5d52b975c8b6d446d416a4847d20ec310185

Token

A fungible token: what a bridged or issued asset is on this chain. The owner mints, and can hand that on or give it up for good. Anyone can spend what they hold, or let someone else spend it. decimals only says how to show amounts.

Functions

FunctionArgumentsReturnsReverts withEvent
initowner: address, supply: u128, name: bytes, symbol: bytes, decimals: u64more decimals than a u128 has digits if decimals is over 38None
transferto: address, amount: u128balance too lowtransfer
approvespender: address, amount: u128approve
transfer_fromfrom: address, to: address, amount: u128allowance too low, balance too lowtransfer
mintto: address, amount: u128only the owner can do thatmint
burnamount: u128balance too lowburn
set_ownernew_owner: addressonly the owner can do thatowner, and supply_fixed if new_owner is NOBODY
balance_ofwho: addressu128
allowanceowner: address, spender: addressu128
total_supplyu128
owner32 bytes
nameThe name's bytes, with no length
symbolThe symbol's bytes, with no length
decimalsu64, 8 bytes
  • init credits supply to owner and emits nothing, so the first supply has no mint event.
  • approve sets the allowance to amount and does not add to it. A zero deletes the key.
  • transfer_from spends the allowance from gave the caller. There is no unlimited allowance: every use reduces it.
  • A transfer of 0, and one to the sender, succeed and emit an event, as a test run confirmed.
  • set_owner with NOBODY ends minting for good. No one can sign as the zero address, because an address is a hash of a policy.

Storage

KeyLengthValue
owner532-byte address
supply6u128, absent when 0
name4Raw bytes
symbol6Raw bytes
decimals8u64, 8 bytes
b + holder33u128 balance, absent when 0
a + owner + spender65u128 allowance, absent when 0

Events

TopicDataBytes
transferfrom, to, amount80
approveowner, spender, amount80
mintto, amount48
burnfrom, amount48
ownernew_owner32
supply_fixedTotal supply, u12816

Escrow

Settlement between two parties who don't trust each other. The payer locks funds, and they go to the payee or back, never anywhere else. An arbiter can decide either way. After the deadline the payer can take them back alone, so a silent payee can't hold them forever.

The contract has no init. asset is a token contract, or NOBODY for the native token, which comes with the call. Before locking a token the payer approves this contract for the amount.

Functions

FunctionArgumentsReturnsWho may call
openpayee: address, arbiter: address, asset: address, amount: u128, deadline_ms: u64Deal id, u64Anyone, who becomes the payer
releaseid: u64The payer or the arbiter
refundid: u64The payee or the arbiter, or the payer once the block time reaches deadline_ms
dealid: u64152 bytesAnyone

open checks in this order:

  1. nothing to lock if amount is 0.
  2. deadline has passed unless deadline_ms is after the block time.
  3. For the native token, send the amount with the call unless value equals amount.
  4. For a token, native tokens sent with a token deal unless value is 0, and token transfer failed if asset.transfer_from(payer, this, amount) fails.
  5. It takes the next id, starting at 0, stores the deal and emits open.

release and refund run the same way:

  1. close reads the deal and deletes it, or reverts with no such deal. Taking it off the books first means it can only ever be settled once.
  2. The caller is checked. The reasons are only the payer or the arbiter can release and only the payee or the arbiter, or the payer after the deadline, can refund.
  3. The funds go out, by pay for the native token or asset.transfer(to, amount) for a token. If that fails, the reason is payment failed, which undoes the deletion too.
  4. The event is emitted.

deal reverts with no such deal for an id never opened or already settled, because a settled deal leaves nothing in state.

Storage and events

KeyLengthValue
next4The next id, u64, 8 bytes
d + id (u64 LE)9The deal, 152 bytes

A deal, as stored and as deal returns it:

OffsetBytesField
032payer
3232payee
6432arbiter
9632asset
12816amount
1448deadline_ms

The events are open, release and refund, each with the id as 8 bytes of data. A token deal also has the token's transfer event, before the escrow's own.

AMM

A constant product market on the Uniswap V2 model: each pool holds two assets, and any swap leaves the product of its reserves no lower than it was. A swap pays 0.30%, which stays in the pool for its liquidity providers. There is no protocol share.

One contract, every pool

Only governance deploys contracts, so no factory can create a contract per pool. This one contract holds every pool, and anyone can open one by adding its first liquidity. A pool is token against token, or token against native. A pair of assets has one pool, whichever way it is named. The contract has no init.

Constants

ConstantValueReason
FEE_BPS30The swap fee in basis points, 0.30%
BPS10,000One whole
MAX_RESERVE1 << 112, 5,192,296,858,534,827,628,530,496,329,220,096Keeps every price calculation within u128, with room for the basis points
LOCKED_SHARES1,000Burned from a pool's first shares, so a pool can never shrink to a few indivisible units

Pool identity and storage

A pool is always kept in address order, as low and high, compared as bytes. The all-zero native address therefore is always low.

pool id = hash(low || high)     the `hash` host call over 64 bytes
KeyLengthValue
p + pool id3348 bytes: reserve_low, reserve_high, shares, each u128
s + pool id + holder65The holder's shares, u128, absent when 0

shares in the pool record is the total, including the 1,000 locked shares. No holder owns those, so the total never goes below 1,000 once a pool is open.

Reserves are this contract's own books, never its balances. Sending tokens here directly changes no price and buys no shares, which closes the usual way to steal from the first depositors.

Formulas

All divisions round down, through mul_div, which computes the product in 256 bits. Rounding down always favors the pool, because amounts out and shares minted are the rounded values. mul_div returning nothing, for a result too large or a zero divisor, reverts with amount out of range.

A swap, for reserve_in and reserve_out:

after_fee = amount_in × (BPS - FEE_BPS) = amount_in × 9,970
out = floor(reserve_out × after_fee / (reserve_in × BPS + after_fee))

amount_in is at most 2^112, which keeps after_fee and reserve_in × BPS below 2^126 each and their sum below 2^127. Only the numerator needs more than 128 bits.

After the swap, the contract refuses to continue unless this holds, compared in 256 bits:

(reserve_in + amount_in) × (reserve_out - out) >= reserve_in × reserve_out

The formula already keeps this. It costs little to refuse if the formula is ever wrong.

The first liquidity sets the price:

root   = floor(sqrt(low × high))     must exceed 1,000
shares = root                        the pool's total, 1,000 of them locked
minted = root - 1,000

Later liquidity takes the pool's own ratio, using all of one side and as much of the other as that needs:

candidate = floor(want_low × reserve_high / reserve_low)
if candidate <= want_high:
    low = want_low
    high = candidate
else:
    low = floor(want_high × reserve_low / reserve_high)
    high = want_high
minted = min(floor(low × shares / reserve_low), floor(high × shares / reserve_high))

Removing gives each side in proportion:

low_out  = floor(burned × reserve_low  / shares)
high_out = floor(burned × reserve_high / shares)

Functions

FunctionArgumentsReturns
add_liquiditya: address, b: address, want_a: u128, want_b: u128, min_a: u128, min_b: u128, deadline_ms: u64Shares minted, u128
remove_liquiditya: address, b: address, shares: u128, min_a: u128, min_b: u128, deadline_ms: u64Nothing
swapasset_in: address, asset_out: address, amount_in: u128, min_out: u128, deadline_ms: u64Amount out, u128
quoteasset_in: address, asset_out: address, amount_in: u128Amount out, u128
poola: address, b: address48 bytes: reserve of a, reserve of b, total shares. All zero for no pool
sharesa: address, b: address, who: addressShares held, u128

a and b may come in either order, and each want and min goes with its own asset. For a token, approve this contract for the amount first.

The limits are what protect a trade. Anyone can see it coming and trade first, so min_out says how bad a price is acceptable and the deadline how late. A deadline passes when the block time goes above deadline_ms. Set min_out from quote, a little lower.

add_liquidity checks in this order:

  1. deadline has passed.
  2. a pool needs two assets if a equals b.
  3. nothing to add if either want is 0.
  4. send the native amount with the call unless value equals the native side of the wants, or 0 for two tokens.
  5. The amounts, by the formula. amount out of range is possible here.
  6. price moved past the limit if either amount is below its min.
  7. too small for this pool if either amount is 0.
  8. Shares, with too small to open a pool if the root is 1,000 or less, or too small for this pool if none would be minted.
  9. pool is full if a reserve would pass MAX_RESERVE.
  10. The books are saved, then both tokens are pulled, then unused native tokens go back to the provider.

remove_liquidity checks in this order:

  1. deadline has passed.
  2. a pool needs two assets.
  3. not that many shares if shares is 0 or more than the caller holds.
  4. price moved past the limit, then too small for this pool if either amount is 0.
  5. The books are saved, then both assets are sent.

swap checks in this order:

  1. deadline has passed.
  2. nothing to swap if amount_in is 0.
  3. send the native amount with the call unless value is amount_in for native in, or 0 for a token in.
  4. a pool needs two assets, then no such pool.
  5. pool is full if amount_in is over MAX_RESERVE.
  6. price moved past the limit if out is below min_out.
  7. too small for this pool if out is 0.
  8. pool is full if the input reserve would pass MAX_RESERVE.
  9. the product fell if the product check fails.
  10. The books are saved, then the input is pulled and the output sent.

Pulling a token that fails gives token transfer failed; approve this contract first, and a payment that fails gives payment failed. Either undoes the whole call. quote uses the same formula as swap, with a pool needs two assets, no such pool and pool is full as its reasons. pool and shares revert only on a pool needs two assets.

Events

TopicDataBytes
addPool id, provider, low amount, high amount96
removePool id, provider, low amount, high amount96
swapPool id, trader, amount_in, out96

Amounts in add and remove are in low and high order, not a and b. Each token moved adds its own transfer event before the market's event.

The native token

An asset of NOBODY is the native token, and it trades like any other. It comes with the call as value, so only tokens have to be fetched, and it goes out with pay.

add_liquidity must receive the whole native want as value. What the pool's ratio does not take goes back in the same call. remove_liquidity and swap pay native tokens out to the caller.

256-bit math

qubits-contract-math holds what a price needs and u128 can't hold, since the product of two amounts is 256 bits wide. Everything rounds down, and overflow is an answer, not a wrapped number.

/// `a * b` as its high and low 128 bits.
pub const fn mul_wide(a: u128, b: u128) -> (u128, u128);

/// `a * b / divisor`, rounded down, or None if the divisor is zero or the result doesn't fit.
pub const fn mul_div(a: u128, b: u128, divisor: u128) -> Option<u128>;

/// The square root of `a * b`, rounded down.
pub fn sqrt_product(a: u128, b: u128) -> u128;

/// Whether `a * b >= c * d`.
pub const fn product_at_least(a: u128, b: u128, c: u128, d: u128) -> bool;

mul_wide multiplies 64-bit limbs. mul_div divides directly when the high half is zero, refuses when the high half is at least the divisor, and otherwise does long division a bit at a time, 128 rounds. The remainder stays below the divisor, so at most one bit falls off the top of the shift, and that one means the divisor goes in.

sqrt_product uses isqrt when the product fits in 128 bits. Otherwise it runs Newton's method from above, starting at 2^ceil((bits(a) + bits(b)) / 2), or u128::MAX when that does not fit. The start is above the root because a × b is below 2^(bits(a) + bits(b)). It stops when the next value is not smaller.

The crate is pure, so build.sh tests it on the host before building anything. The tests check mul_div as quotient × divisor <= product < (quotient + 1) × divisor without trusting the division, and sqrt_product as the largest root, over all pairs of 12 edge values and 4,000 pseudo-random pairs from a fixed seed.

Worked example

These numbers were computed in Python's unbounded integers, then produced by amm.wasm on a test chain, and the two agreed. The pool has two tokens, with reserves in (t, u) order.

StepCalculationResultReservesTotal shares
Alice adds 1,000,000 t and 4,000,000 ufloor(sqrt(4,000,000,000,000)) = 2,000,0001,999,000 shares1,000,000 and 4,000,0002,000,000
Bob swaps 10,000 tfloor(4,000,000 × 99,700,000 / 10,099,700,000)39,486 u1,010,000 and 3,960,5142,000,000
Bob swaps 39,486 u backfloor(1,010,000 × 393,675,420 / 39,998,815,420)9,940 t1,000,060 and 4,000,0002,000,000
Bob offers 100,000 t and 1,000,000 ufloor(100,000 × 4,000,000 / 1,000,060) = 399,976199,988 shares, for 100,000 and 399,9761,100,060 and 4,399,9762,199,988
Bob removes 199,988 sharesfloor(199,988 × 1,100,060 / 2,199,988) and floor(199,988 × 4,399,976 / 2,199,988)99,999 t and 399,976 u1,000,061 and 4,000,0002,000,000

The first swap raises the product from 4,000,000,000,000 to 4,000,119,140,000, which is the fee staying in. Bob's round trip gave him 9,940 t for his 10,000. His add and remove left 1 t in the pool to rounding.

At the far end of the range, with reserves of 2^111 + 12,345 and 2^110 - 6,789, the first add gives a root of 1,835,754,159,301,792,232,857,008,151,836,913. A swap of 2^100 gives 631,616,343,015,536,765,749,889,438,610. Those products are far past 128 bits. huge_amounts_are_exact pins both, and the_market_costs_exactly_this pins the second.

A session against the native token, on a private chain:

qubits contract call 'qb1token….approve(qb1amm…, 100000)' --key alice.json --password-file pw --wait
qubits contract call 'qb1amm….add_liquidity(qb1token…, native, 100000, 400000, 0, 0, deadline:600)' \
    --amount 400000 --key alice.json --password-file pw --wait
qubits contract view 'qb1amm….pool(qb1token…, native)' --as u128s      # 100000 400000 200000
qubits contract view 'qb1amm….quote(native, qb1token…, 40000)' --as u128   # 9066
qubits contract call 'qb1amm….swap(native, qb1token…, 40000, 9000, deadline:600)' \
    --amount 40000 --key bob.json --password-file pw --wait
qubits contract view 'qb1amm….pool(qb1token…, native)' --as u128s      # 90934 440000 200000
qubits contract view 'qb1amm….shares(qb1token…, native, qb1alice…)' --as u128   # 199000

Running that swap a second time ended reverted: price moved past the limit at 258,070 gas, because the first had moved the price below the 9,000 it asked for. The fee was paid and the pool did not change.

What protects a pool

  • Reserves are books, so a direct transfer moves nothing.
  • The first 1,000 shares are locked for good.
  • Each swap checks that the product did not fall, apart from the formula.
  • Reserves stop at 2^112, and intermediate values are 256 bits.
  • Everything rounds in the pool's favor.
  • A fake token reaches only its own pool. Each pool has its own books, and the native tokens backing another pool cannot be paid out of this one.
  • Calls out can't come back in, because the chain refuses re-entry.

A pool's price is what the last trade left it at, not a fair price. Another contract must not trust it as an oracle.

What the tests check

qubits-execution/tests/amm.rs runs amm.wasm under the real chain rules, with exact amounts worked out outside the code.

TestHolds
the_first_liquidity_sets_the_price_and_locks_a_little_for_goodThe first add, and pools read the same either way round
later_liquidity_takes_the_pools_ratio_and_only_what_it_needsBoth branches of the ratio
a_provider_is_protected_from_a_moved_pricemin on adding
liquidity_comes_back_with_its_share_of_the_fees40 swaps later, a provider leaves with more, and the locked shares stay
only_a_holder_takes_liquidity_outZero, too many, and someone else's shares
a_swap_pays_the_formula39,486 from unbounded integers, in quote and swap
a_trade_is_protected_by_its_limitsmin_out one above the result fails, equal passes, the deadline fails, and refused trades take nothing
what_makes_no_sense_is_refusedEach reason for bad input
taking_a_token_needs_its_approvalNo approval, no trade
the_native_token_trades_like_any_otherNative in, out and removed, with books equal to balances
unused_native_tokens_come_backThe refund, exact to the gas
sending_to_the_contract_moves_no_price_and_buys_no_sharesA donation of 500,000,000 changes nothing
a_round_trip_never_makes_moneyFrom 1,000 to 50,000,000
a_fake_token_only_reaches_its_own_poolA token that moves nothing cannot drain the honest pool
a_vaults_trade_waits_like_everything_elseA vault's swap waits for its delay
huge_amounts_are_exactResults past 128 bits
a_pool_stops_at_its_limitpool is full on add and swap
random_trading_never_breaks_the_books250 random operations on 3 seeds. Each time the books equal the balances, every share has an owner, and k / shares^2 never falls
everything_the_market_does_fits_in_a_transactionAll four operations stay under 3,000,000 gas

token and escrow are covered in qubits-execution/tests/contracts.rs: token_moves_between_accounts, token_rules_hold, a_token_says_what_it_is_and_minting_can_end, escrow_settles_the_native_token, escrow_gives_the_payer_a_way_out_only_after_the_deadline and escrow_settles_a_token_through_a_call_between_contracts.

Gas

gas_used from a test run of the shipped builds. Each cost starts with loading the module: 109,332 gas for token, 87,140 for escrow and 231,012 for amm. The AMM rows are for two tokens, so they include calls to token.

CallGas
token.transfer, to a new holder190,562
token.transfer, to an existing holder190,678
token.approve133,082
token.transfer_from231,856
token.mint209,640
token.burn188,181
token.set_owner150,063
token.balance_of, in a view130,597
escrow.open, native154,028
escrow.release, native168,812
escrow.open, token429,127
escrow.refund, token378,754
escrow.deal, in a view130,487
amm.add_liquidity, first874,453
amm.add_liquidity, later877,274
amm.swap793,196
amm.remove_liquidity791,496
amm.quote, in a view256,360
amm.pool, in a view256,051
amm.shares, in a view275,068

Against the native token one side needs no call: opening a pool used 594,906 gas, and a swap of native in used 518,717. Gas is paid in the fee at 16 gas to a byte. See Gas and fees.

Source

  • chain/contracts/token/src/lib.rs
  • chain/contracts/escrow/src/lib.rs
  • chain/contracts/amm/src/lib.rs
  • chain/contracts/math/src/lib.rs
  • chain/contracts/README.md
  • chain/crates/qubits-execution/tests/amm.rs
  • chain/crates/qubits-execution/tests/contracts.rs
  • chain/crates/qubits-vm/tests/vm.rs