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_liquidityandswaplook atqb::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:
| Module | Bytes | Code hash |
|---|---|---|
out/token.wasm | 6,677 | 003b52fed25b748080c8d446d4eac5cbe5272b9b14b405625e9cd58581a60c75 |
out/escrow.wasm | 5,290 | f64173f6468b0efac4d666b5554f9f5799022fd2f258302bfbc8c0067a16aa15 |
out/amm.wasm | 14,282 | 12d180af9b431aaeefbf817ddfee5d52b975c8b6d446d416a4847d20ec310185 |
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
| Function | Arguments | Returns | Reverts with | Event |
|---|---|---|---|---|
init | owner: address, supply: u128, name: bytes, symbol: bytes, decimals: u64 | more decimals than a u128 has digits if decimals is over 38 | None | |
transfer | to: address, amount: u128 | balance too low | transfer | |
approve | spender: address, amount: u128 | approve | ||
transfer_from | from: address, to: address, amount: u128 | allowance too low, balance too low | transfer | |
mint | to: address, amount: u128 | only the owner can do that | mint | |
burn | amount: u128 | balance too low | burn | |
set_owner | new_owner: address | only the owner can do that | owner, and supply_fixed if new_owner is NOBODY | |
balance_of | who: address | u128 | ||
allowance | owner: address, spender: address | u128 | ||
total_supply | u128 | |||
owner | 32 bytes | |||
name | The name's bytes, with no length | |||
symbol | The symbol's bytes, with no length | |||
decimals | u64, 8 bytes |
initcreditssupplytoownerand emits nothing, so the first supply has nomintevent.approvesets the allowance toamountand does not add to it. A zero deletes the key.transfer_fromspends the allowancefromgave the caller. There is no unlimited allowance: every use reduces it.- A
transferof 0, and one to the sender, succeed and emit an event, as a test run confirmed. set_ownerwithNOBODYends minting for good. No one can sign as the zero address, because an address is a hash of a policy.
Storage
| Key | Length | Value |
|---|---|---|
owner | 5 | 32-byte address |
supply | 6 | u128, absent when 0 |
name | 4 | Raw bytes |
symbol | 6 | Raw bytes |
decimals | 8 | u64, 8 bytes |
b + holder | 33 | u128 balance, absent when 0 |
a + owner + spender | 65 | u128 allowance, absent when 0 |
Events
| Topic | Data | Bytes |
|---|---|---|
transfer | from, to, amount | 80 |
approve | owner, spender, amount | 80 |
mint | to, amount | 48 |
burn | from, amount | 48 |
owner | new_owner | 32 |
supply_fixed | Total supply, u128 | 16 |
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
| Function | Arguments | Returns | Who may call |
|---|---|---|---|
open | payee: address, arbiter: address, asset: address, amount: u128, deadline_ms: u64 | Deal id, u64 | Anyone, who becomes the payer |
release | id: u64 | The payer or the arbiter | |
refund | id: u64 | The payee or the arbiter, or the payer once the block time reaches deadline_ms | |
deal | id: u64 | 152 bytes | Anyone |
open checks in this order:
nothing to lockifamountis 0.deadline has passedunlessdeadline_msis after the block time.- For the native token,
send the amount with the callunlessvalueequalsamount. - For a token,
native tokens sent with a token dealunlessvalueis 0, andtoken transfer failedifasset.transfer_from(payer, this, amount)fails. - It takes the next id, starting at 0, stores the deal and emits
open.
release and refund run the same way:
closereads the deal and deletes it, or reverts withno such deal. Taking it off the books first means it can only ever be settled once.- The caller is checked. The reasons are
only the payer or the arbiter can releaseandonly the payee or the arbiter, or the payer after the deadline, can refund. - The funds go out, by
payfor the native token orasset.transfer(to, amount)for a token. If that fails, the reason ispayment failed, which undoes the deletion too. - 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
| Key | Length | Value |
|---|---|---|
next | 4 | The next id, u64, 8 bytes |
d + id (u64 LE) | 9 | The deal, 152 bytes |
A deal, as stored and as deal returns it:
| Offset | Bytes | Field |
|---|---|---|
| 0 | 32 | payer |
| 32 | 32 | payee |
| 64 | 32 | arbiter |
| 96 | 32 | asset |
| 128 | 16 | amount |
| 144 | 8 | deadline_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
| Constant | Value | Reason |
|---|---|---|
FEE_BPS | 30 | The swap fee in basis points, 0.30% |
BPS | 10,000 | One whole |
MAX_RESERVE | 1 << 112, 5,192,296,858,534,827,628,530,496,329,220,096 | Keeps every price calculation within u128, with room for the basis points |
LOCKED_SHARES | 1,000 | Burned 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
| Key | Length | Value |
|---|---|---|
p + pool id | 33 | 48 bytes: reserve_low, reserve_high, shares, each u128 |
s + pool id + holder | 65 | The 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
| Function | Arguments | Returns |
|---|---|---|
add_liquidity | a: address, b: address, want_a: u128, want_b: u128, min_a: u128, min_b: u128, deadline_ms: u64 | Shares minted, u128 |
remove_liquidity | a: address, b: address, shares: u128, min_a: u128, min_b: u128, deadline_ms: u64 | Nothing |
swap | asset_in: address, asset_out: address, amount_in: u128, min_out: u128, deadline_ms: u64 | Amount out, u128 |
quote | asset_in: address, asset_out: address, amount_in: u128 | Amount out, u128 |
pool | a: address, b: address | 48 bytes: reserve of a, reserve of b, total shares. All zero for no pool |
shares | a: address, b: address, who: address | Shares 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:
deadline has passed.a pool needs two assetsifaequalsb.nothing to addif eitherwantis 0.send the native amount with the callunlessvalueequals the native side of the wants, or 0 for two tokens.- The amounts, by the formula.
amount out of rangeis possible here. price moved past the limitif either amount is below itsmin.too small for this poolif either amount is 0.- Shares, with
too small to open a poolif the root is 1,000 or less, ortoo small for this poolif none would be minted. pool is fullif a reserve would passMAX_RESERVE.- The books are saved, then both tokens are pulled, then unused native tokens go back to the provider.
remove_liquidity checks in this order:
deadline has passed.a pool needs two assets.not that many sharesifsharesis 0 or more than the caller holds.price moved past the limit, thentoo small for this poolif either amount is 0.- The books are saved, then both assets are sent.
swap checks in this order:
deadline has passed.nothing to swapifamount_inis 0.send the native amount with the callunlessvalueisamount_infor native in, or 0 for a token in.a pool needs two assets, thenno such pool.pool is fullifamount_inis overMAX_RESERVE.price moved past the limitifoutis belowmin_out.too small for this poolifoutis 0.pool is fullif the input reserve would passMAX_RESERVE.the product fellif the product check fails.- 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
| Topic | Data | Bytes |
|---|---|---|
add | Pool id, provider, low amount, high amount | 96 |
remove | Pool id, provider, low amount, high amount | 96 |
swap | Pool id, trader, amount_in, out | 96 |
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.
| Step | Calculation | Result | Reserves | Total shares |
|---|---|---|---|---|
Alice adds 1,000,000 t and 4,000,000 u | floor(sqrt(4,000,000,000,000)) = 2,000,000 | 1,999,000 shares | 1,000,000 and 4,000,000 | 2,000,000 |
Bob swaps 10,000 t | floor(4,000,000 × 99,700,000 / 10,099,700,000) | 39,486 u | 1,010,000 and 3,960,514 | 2,000,000 |
Bob swaps 39,486 u back | floor(1,010,000 × 393,675,420 / 39,998,815,420) | 9,940 t | 1,000,060 and 4,000,000 | 2,000,000 |
Bob offers 100,000 t and 1,000,000 u | floor(100,000 × 4,000,000 / 1,000,060) = 399,976 | 199,988 shares, for 100,000 and 399,976 | 1,100,060 and 4,399,976 | 2,199,988 |
| Bob removes 199,988 shares | floor(199,988 × 1,100,060 / 2,199,988) and floor(199,988 × 4,399,976 / 2,199,988) | 99,999 t and 399,976 u | 1,000,061 and 4,000,000 | 2,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.
| Test | Holds |
|---|---|
the_first_liquidity_sets_the_price_and_locks_a_little_for_good | The first add, and pools read the same either way round |
later_liquidity_takes_the_pools_ratio_and_only_what_it_needs | Both branches of the ratio |
a_provider_is_protected_from_a_moved_price | min on adding |
liquidity_comes_back_with_its_share_of_the_fees | 40 swaps later, a provider leaves with more, and the locked shares stay |
only_a_holder_takes_liquidity_out | Zero, too many, and someone else's shares |
a_swap_pays_the_formula | 39,486 from unbounded integers, in quote and swap |
a_trade_is_protected_by_its_limits | min_out one above the result fails, equal passes, the deadline fails, and refused trades take nothing |
what_makes_no_sense_is_refused | Each reason for bad input |
taking_a_token_needs_its_approval | No approval, no trade |
the_native_token_trades_like_any_other | Native in, out and removed, with books equal to balances |
unused_native_tokens_come_back | The refund, exact to the gas |
sending_to_the_contract_moves_no_price_and_buys_no_shares | A donation of 500,000,000 changes nothing |
a_round_trip_never_makes_money | From 1,000 to 50,000,000 |
a_fake_token_only_reaches_its_own_pool | A token that moves nothing cannot drain the honest pool |
a_vaults_trade_waits_like_everything_else | A vault's swap waits for its delay |
huge_amounts_are_exact | Results past 128 bits |
a_pool_stops_at_its_limit | pool is full on add and swap |
random_trading_never_breaks_the_books | 250 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_transaction | All 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.
| Call | Gas |
|---|---|
token.transfer, to a new holder | 190,562 |
token.transfer, to an existing holder | 190,678 |
token.approve | 133,082 |
token.transfer_from | 231,856 |
token.mint | 209,640 |
token.burn | 188,181 |
token.set_owner | 150,063 |
token.balance_of, in a view | 130,597 |
escrow.open, native | 154,028 |
escrow.release, native | 168,812 |
escrow.open, token | 429,127 |
escrow.refund, token | 378,754 |
escrow.deal, in a view | 130,487 |
amm.add_liquidity, first | 874,453 |
amm.add_liquidity, later | 877,274 |
amm.swap | 793,196 |
amm.remove_liquidity | 791,496 |
amm.quote, in a view | 256,360 |
amm.pool, in a view | 256,051 |
amm.shares, in a view | 275,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.rschain/contracts/escrow/src/lib.rschain/contracts/amm/src/lib.rschain/contracts/math/src/lib.rschain/contracts/README.mdchain/crates/qubits-execution/tests/amm.rschain/crates/qubits-execution/tests/contracts.rschain/crates/qubits-vm/tests/vm.rs