Introduction
OISY TRADE is an order-book DEX on the Internet Computer.
Key features
- CEX-like experience — deposit once, trade as much as you want, withdraw anytime.
- Fully onchain order book — central limit order book (CLOB) running entirely within a single canister.
- Permissionless trading — any principal can trade on any active pair, no allowlisting required.
Architecture at a glance
- Single canister. All order-book state, matching, and settlement live in one canister.
- Synchronous matching engine. Token transfers only happen at the deposit and withdrawal edges; the matching engine operates entirely on internal balances, with no async complexity.
- Event-sourced state. Every state change is recorded in an append-only log in stable memory and replayed on upgrade, providing full auditability and simpler upgrades.
For the full design, see Design.
Deployment
| Environment | Canister ID | Listings |
|---|---|---|
| Production | sy2xe-miaaa-aaaar-qb7sq-cai | Coming soon! |
| Staging | proc5-daaaa-aaaar-qb5va-cai | Trade test tokens |
Where to next?
- New to OISY TRADE? Start with For Users.
- Want your agent to place orders? See For Agents.
- Operating the canister? See For Admins.
- Contributing or reviewing? See Design and Specs.
Interact with OISY TRADE
Walkthrough of the core OISY TRADE flows against the staging canister, using only the icp CLI:
- List trading pairs
- Approve and deposit
- Place a limit order
- Check order status
- Withdraw
The walkthrough uses two configurable identities — one for buy-side operations, one for sell-side — so the flow can exercise both halves of a matched trade. Run every command below from the same shell: steps share exported variables (BUYER_IDENTITY, SELLER_IDENTITY, BASE_LEDGER, OISY_TRADE, …).
Prerequisites
icpCLI installed and onPATH- Run these commands from the project root so
--environment stagingresolves theoisy_tradecanister name (defined inicp.yaml) - Two identities configured via
icp identity neworicp identity import— one acts as the buyer, one as the seller. They may be the same identity if you just want to see the commands run end-to-end against yourself; using two distinct identities lets you demonstrate an actual matched trade.
Identities
Export a buyer and a seller identity. OISY TRADE keys internal balances and order ownership off the caller principal, so every signing call targets one of these two.
export BUYER_IDENTITY=<buyer-name>
export SELLER_IDENTITY=<seller-name>
icp identity principal --identity "$BUYER_IDENTITY"
icp identity principal --identity "$SELLER_IDENTITY"
Limit Orders 101
- Trading Pair — two token ledgers: the base (what you’re buying or selling) and the quote (what prices are denominated in).
SOL / ETHmeans trading SOL priced in ETH. - Quantity — amount of the base token, in its base units (e.g. 1 SOL = 109 units at 9 decimals). Must be a positive multiple of
lot_size. - Price — quote base units per one whole base token (i.e. per
10^base_decimalsbase units). Must be a positive multiple oftick_size. A fill ofquantityatpricesettlesprice × quantity / 10^base_decimalsquote-token base units. - Side —
Buy(bid) reservesprice × quantity / 10^base_decimalsof the quote token from your free balance;Sell(ask) reservesquantityof the base token. The order fills when a crossing order arrives, otherwise it rests in the book until canceled. - Notional — an order’s value,
price × quantity / 10^base_decimalsquote base units, must clear the pair’smin_notionaland stay under its optionalmax_notional.
List trading pairs
get_trading_pairs returns every listed pair along with its tick_size (price must be a positive multiple) and lot_size (quantity must be a positive multiple, in base-token base units).
icp canister call oisy_trade get_trading_pairs '()' --environment staging --query --identity anonymous
Pick a pair
Copy the base and quote ledger principals from the output above and export them. The pair parameters below follow Binance’s SOLETH market for the ckDevnetSOL / ckSepoliaETH pair (see the admin guide for the derivation); confirm them against the get_trading_pairs output and adjust if you picked a different pair.
export BASE_LEDGER=la34w-haaaa-aaaar-qb5na-cai # ckDevnetSOL (base_decimals = 9)
export QUOTE_LEDGER=apia6-jaaaa-aaaar-qabma-cai # ckSepoliaETH (quote_decimals = 18)
export OISY_TRADE=proc5-daaaa-aaaar-qb5va-cai # staging OISY TRADE canister
# Pair parameters, from Binance SOLETH (tickSize 0.00001 ETH/SOL, stepSize 0.001 SOL):
export TICK_SIZE=10_000_000_000_000 # 0.00001 × 10^18 = 10^13
export LOT_SIZE=1_000_000 # 0.001 × 10^9 = 10^6
# This walkthrough trades 0.1 SOL at 0.05 ETH/SOL:
export PRICE=50_000_000_000_000_000 # 0.05 ETH/SOL = 5000 × tick_size
export QUANTITY=100_000_000 # 0.1 SOL = 100 × lot_size
The pair’s notional bounds (admin-set, from Binance SOLETH’s NOTIONAL filter) are min_notional = 0.001 ETH = 1_000_000_000_000_000 and max_notional = 9_000_000 ETH = 9_000_000_000_000_000_000_000_000 quote base units. Our order’s notional is PRICE × QUANTITY / 10^9 = 5_000_000_000_000_000 (0.005 ETH), comfortably between them.
Approve and deposit
Every limit order reserves one side of the pair before the matching engine can fill it:
- Sell orders reserve
quantitybase tokens → the seller needs base in their on-OISY-TRADE free balance - Buy orders reserve
price × quantity / 10^base_decimalsquote tokens → the buyer needs quote in their on-OISY-TRADE free balance
So which identity approves and deposits which token depends on the side it plans to trade.
deposit moves tokens by calling icrc2_transfer_from on the token ledger, so OISY TRADE must first be approved to spend on the caller’s behalf. Two fees are charged to the on-ledger balance:
icrc2_approvecharges the ledger fee onceicrc2_transfer_from(triggered later bydeposit) charges the ledger fee again, on top of the amount transferred
So to deposit N, approve at least N + ledger_fee and hold at least N + 2 × ledger_fee on the ledger. Fees vary widely per ledger — this walkthrough assumes ckDevnetSOL = 50 base units (≈ 5 × 10-8 SOL) and ckSepoliaETH = 10_000_000_000 (= 10-8 ETH). If the live icrc1_fee values below differ from these, adjust the literal amount fields in the approve / deposit calls accordingly before running them:
# Seller — base ledger (needs base to sell)
icp token "$BASE_LEDGER" balance --network ic --identity "$SELLER_IDENTITY"
icp canister call "$BASE_LEDGER" icrc1_fee '()' --query --identity anonymous --network ic
# Buyer — quote ledger (needs quote to buy)
icp token "$QUOTE_LEDGER" balance --network ic --identity "$BUYER_IDENTITY"
icp canister call "$QUOTE_LEDGER" icrc1_fee '()' --query --identity anonymous --network ic
Then approve and deposit — run each subsection as the appropriate identity. icrc2_approve calls the token ledger directly (--network ic); deposit targets OISY TRADE on staging (--environment staging).
Seller — approve + deposit base
Deposits QUANTITY base-token base units (= 100 × LOT_SIZE = 0.1 SOL; a base unit is the token’s smallest indivisible unit, not to be confused with the base token in the trading pair), enough for one sell at quantity = $QUANTITY. Approval is QUANTITY + ckDevnetSOL_fee = 100_000_000 + 50.
icp canister call "$BASE_LEDGER" icrc2_approve --args-file /dev/stdin --network ic --identity "$SELLER_IDENTITY" <<EOF
(
record {
spender = record { owner = principal "$OISY_TRADE"; subaccount = null };
amount = 100_000_050 : nat
}
)
EOF
icp canister call oisy_trade deposit --args-file /dev/stdin --environment staging --identity "$SELLER_IDENTITY" <<EOF
(
record {
token_id = record { ledger_id = principal "$BASE_LEDGER" };
amount = 100_000_000 : nat
}
)
EOF
Buyer — approve + deposit quote
Deposits the order’s notional price × quantity / 10^base_decimals = PRICE × QUANTITY / 10^9 = 5_000_000_000_000_000 quote-base-units (= 0.005 ETH), enough for one buy at price = $PRICE, quantity = $QUANTITY. Approval is notional + ckSepoliaETH_fee = 5_000_000_000_000_000 + 10_000_000_000. The buyer’s ckSepoliaETH on-ledger balance must be at least notional + 2 × fee = 5_000_020_000_000_000 (≈ 0.005 ETH) to cover both the icrc2_approve fee and the icrc2_transfer_from fee on top of the deposit itself.
icp canister call "$QUOTE_LEDGER" icrc2_approve --args-file /dev/stdin --network ic --identity "$BUYER_IDENTITY" <<EOF
(
record {
spender = record { owner = principal "$OISY_TRADE"; subaccount = null };
amount = 5_000_010_000_000_000 : nat
}
)
EOF
icp canister call oisy_trade deposit --args-file /dev/stdin --environment staging --identity "$BUYER_IDENTITY" <<EOF
(
record {
token_id = record { ledger_id = principal "$QUOTE_LEDGER" };
amount = 5_000_000_000_000_000 : nat
}
)
EOF
Candid’s record literals use
{/}, which collide with bash variable-expansion rules. Passing the candid via--args-file /dev/stdinand a heredoc sidesteps every shell-quoting pitfall — the unquotedEOFterminator still expands$VARinside the body.
Check on-OISY-TRADE balances
OISY TRADE tracks balance per (caller, token):
free— available for new orders or withdrawalreserved— locked by open orders
# Seller's base balance
icp canister call oisy_trade get_balances --args-file /dev/stdin --environment staging --query --identity "$SELLER_IDENTITY" <<EOF
(opt vec { variant { ById = record { ledger_id = principal "$BASE_LEDGER" } } })
EOF
# Buyer's quote balance
icp canister call oisy_trade get_balances --args-file /dev/stdin --environment staging --query --identity "$BUYER_IDENTITY" <<EOF
(opt vec { variant { ById = record { ledger_id = principal "$QUOTE_LEDGER" } } })
EOF
Place a limit order
price must be a positive multiple of TICK_SIZE; quantity a positive multiple of LOT_SIZE.
Submission is asynchronous: the call returns an order ID immediately, the matching engine processes it on the next canister tick. Two resting orders with crossing prices fill as soon as they meet.
Sell (as seller)
icp canister call oisy_trade add_limit_order --args-file /dev/stdin --environment staging --identity "$SELLER_IDENTITY" <<EOF
(
record {
pair = record { base = principal "$BASE_LEDGER"; quote = principal "$QUOTE_LEDGER" };
side = variant { Sell };
price = $PRICE : nat;
quantity = $QUANTITY : nat
}
)
EOF
Paste the returned 32-char hex order ID:
export SELL_ORDER_ID=<paste-the-order-id-here>
Buy (as buyer)
Same price/quantity, so this order crosses the seller’s resting ask and fills both.
icp canister call oisy_trade add_limit_order --args-file /dev/stdin --environment staging --identity "$BUYER_IDENTITY" <<EOF
(
record {
pair = record { base = principal "$BASE_LEDGER"; quote = principal "$QUOTE_LEDGER" };
side = variant { Buy };
price = $PRICE : nat;
quantity = $QUANTITY : nat
}
)
EOF
export BUY_ORDER_ID=<paste-the-order-id-here>
Check order status
icp canister call oisy_trade get_my_orders --args-file /dev/stdin --environment staging --query --identity "$SELLER_IDENTITY" <<EOF
(opt record { filter = variant { ById = "$SELL_ORDER_ID" } })
EOF
icp canister call oisy_trade get_my_orders --args-file /dev/stdin --environment staging --query --identity "$BUYER_IDENTITY" <<EOF
(opt record { filter = variant { ById = "$BUY_ORDER_ID" } })
EOF
get_my_orders returns the caller’s orders; the ById filter selects a single order by id and returns it (or an empty vector if the caller does not own it). Each returned order carries a status field:
| Status | Meaning |
|---|---|
Pending | Accepted, awaiting the matching engine |
Open | Resting in the order book |
Filled | Fully filled |
Canceled | Canceled |
Both orders should reach Filled once the matching engine has ticked. The engine typically processes within a few seconds — if you see Pending, wait a moment and re-run the query.
Re-check balances to confirm the trade settled. The pre-trade balances from §4 (seller’s base, buyer’s quote) should now both read 0 — fees are charged on the asset each side receives, so the spent side nets to zero either way.
Each side pays a trading fee in the asset it receives, at the maker rate if its order was resting or the taker rate if it crossed. Here the seller’s sell rested (maker) and the buyer’s buy crossed (taker); fees round up, in the protocol’s favor:
- seller’s quote
free=notional − ⌈maker_bps × notional / 10_000⌉ - buyer’s base
free=quantity − ⌈taker_bps × quantity / 10_000⌉
This walkthrough assumes the pair is configured with maker_fee_bps = 0 and taker_fee_bps = 20 (0.2%):
| Side | Gross | Fee | Net free |
|---|---|---|---|
| Seller (quote, maker 0 bps) | 5_000_000_000_000_000 | 0 | 5_000_000_000_000_000 |
| Buyer (base, taker 20 bps) | 100_000_000 | 200_000 | 99_800_000 |
A pair’s fee rates are returned by get_trading_pairs, and the fees the canister has collected are queryable with get_fee_balances.
# Seller's quote (credited by the fill)
icp canister call oisy_trade get_balances --args-file /dev/stdin --environment staging --query --identity "$SELLER_IDENTITY" <<EOF
(opt vec { variant { ById = record { ledger_id = principal "$QUOTE_LEDGER" } } })
EOF
# Buyer's base (credited by the fill)
icp canister call oisy_trade get_balances --args-file /dev/stdin --environment staging --query --identity "$BUYER_IDENTITY" <<EOF
(opt vec { variant { ById = record { ledger_id = principal "$BASE_LEDGER" } } })
EOF
Withdraw
Debits amount from your on-OISY-TRADE free balance and sends amount − ledger_fee to your principal on the ledger. amount must be strictly greater than the current ledger fee — otherwise the call returns AmountTooSmall; re-check icrc1_fee if unsure. Only free funds are eligible; reserved funds (locked by open orders) aren’t withdrawable until the order fills or is canceled.
After the matched trade in §5 the seller now holds quote and the buyer now holds base. Each withdraws what they received:
Seller withdraws quote
Withdraws the seller’s full 5_000_000_000_000_000 quote-base-units balance; after the ckSepoliaETH fee (10_000_000_000), the seller receives 4_999_990_000_000_000 on-ledger.
icp canister call oisy_trade withdraw --args-file /dev/stdin --environment staging --identity "$SELLER_IDENTITY" <<EOF
(
record {
token_id = record { ledger_id = principal "$QUOTE_LEDGER" };
amount = 5_000_000_000_000_000 : nat
}
)
EOF
Buyer withdraws base
Withdraws the buyer’s full 99_800_000 base-base-units balance (the 100_000_000 quantity minus the 200_000 taker fee); after the ckDevnetSOL fee (50), the buyer receives 99_799_950 on-ledger.
icp canister call oisy_trade withdraw --args-file /dev/stdin --environment staging --identity "$BUYER_IDENTITY" <<EOF
(
record {
token_id = record { ledger_id = principal "$BASE_LEDGER" };
amount = 99_800_000 : nat
}
)
EOF
For AI agents
If you’re an AI agent translating a user’s intent (“sell 0.01 SOL at X”, “what pairs are listed?”) into OISY TRADE calls, the For Users walkthrough is your command template. A few things it doesn’t spell out but you must respect:
Before you start
Load these into your session first — the rest of this guide assumes them:
- Fetch
https://skills.internetcomputer.org/llms.txtand follow its guidance for building on the Internet Computer. - Load the
canhelpskill — retrieves a canister’s live Candid interface by name or ID. - Load the
icp-cliskill — covers theicpCLI you’ll be invoking throughout.
Discover the live interface via canhelp
Don’t rely on memory or snapshots. Before planning a call sequence, run:
/canhelp oisy_trade
or by canister ID:
/canhelp proc5-daaaa-aaaar-qb5va-cai
The Candid // doc comments are binding — they encode the rules types alone don’t: ICRC-2 allowance math on deposit, tick/lot constraints, what each Side reserves, async lifecycle of add_limit_order, the AmountTooSmall floor on withdraw, and when each error variant is triggered. If canhelp isn’t available, fall back to icp canister metadata proc5-daaaa-aaaar-qb5va-cai candid:service --network ic.
Amount discipline
Users speak human (“0.01 SOL”); OISY TRADE speaks base units (10^decimals). Always:
- Convert with integer math — never floats.
- Query
icrc1_feebefore quoting any amount — fees vary wildly between ledgers (e.g. ckDevnetSOL50, ckSepoliaETH10_000_000_000). - Confirm both views with the user: “1_000_000 ckDevnetSOL base units = 0.001 SOL”.
Example dialogues
“What pairs can I trade?” → get_trading_pairs; summarize in human terms (“ckDevnetSOL vs ckSepoliaETH, min order 0.001 SOL, price tick 0.00001 ETH/SOL”).
“Sell 0.01 SOL for ckSepoliaETH at market.” → (1) confirm the pair is listed; (2) convert 0.01 SOL to 10_000_000 base units; (3) verify quantity is a multiple of lot_size; (4) pick a limit price — consult get_order_book_ticker (best bid/ask) or get_order_book_depth (aggregated levels); OISY TRADE is limit-only, so translate “at market” into a marketable limit price (ask if unclear); (5) check the seller’s on-OISY-TRADE free base ≥ quantity; (6) place the order as the seller identity; (7) poll get_my_orders (with the ById filter for that order id) for Filled.
On any error → translate the variant name into plain language plus a concrete next step (e.g. InsufficientAllowance { allowance } → “your allowance is X but you need X + fee — let me re-approve”).
Absolute don’ts
- Don’t fabricate canister IDs, method names, field names, or error variants. Run
/canhelp oisy_trade. - Don’t skip
icrc1_feebefore quoting an approve / deposit / withdraw amount. - Don’t use floating point for token-amount math.
- Don’t invoke a signing call with an identity the user hasn’t authorized for this conversation.
- Don’t claim an order filled because
add_limit_orderreturnedOk— that’s acceptance, not execution. Confirm viaget_my_orders(with theByIdfilter). - Don’t over-deposit “for safety” — on ledgers with high fees, it’s expensive and usually not what the user wants.
OISY TRADE admin
Administrative operations that require the caller to be a controller of the OISY TRADE canister:
- Upgrade the canister to a new WASM
- Reinstall the canister (wipes stable memory)
- Add a new trading pair
- Halt and resume trading
- Halt and resume a single trading pair
Calls will fail when run from a non-controller identity.
Run every command below from the same shell — steps share exported variables (IDENTITY, PIN_FILE, BASE_LEDGER, …).
Prerequisites
icpCLI installed and onPATH- Run these commands from the project root so
--environment stagingresolves theoisy_tradecanister name (defined inicp.yaml) - An identity that is a controller of the OISY TRADE canister. The repo convention is the
hsmidentity — adjustIDENTITYbelow if you use a different one. - Optional:
curlandjq— only needed for the Binance sizing sanity check in §3.
Setup
Identity
export IDENTITY=hsm # controller identity; matches the default in the justfile
Unlock the identity (HSM PIN / PEM password)
If your identity is an unencrypted PEM (common in dev), skip this whole subsection and drop
--identity-password-file "$PIN_FILE"from every command below.
Every signing call below passes --identity "$IDENTITY". If the identity is HSM-linked or password-encrypted, icp would prompt on a TTY for its unlock secret. --identity-password-file <FILE> reads the secret from a file instead, which is what we use here. Two ways to populate the file:
A. You already have the unlock secret in a local file — just point at it:
export PIN_FILE=~/.config/icp/hsm.pin # adjust to your file
B. Prompt once and write a chmod-600 temp file (removed in the Clean up step at the end):
export PIN_FILE=$(mktemp -t icp-identity-XXXXXX)
chmod 600 "$PIN_FILE"
read -rs -p "Unlock $IDENTITY (HSM PIN or PEM password): " pin && echo
printf '%s' "$pin" > "$PIN_FILE"
unset pin
Check you’re a controller
canister status lists the controllers. Your principal must appear there, otherwise every update call below is rejected.
icp identity principal --identity "$IDENTITY" --identity-password-file "$PIN_FILE"
icp canister status oisy_trade --environment staging --identity "$IDENTITY" --identity-password-file "$PIN_FILE"
Upgrade the canister
Upgrades preserve stable memory: balances, open orders, listed pairs, and the event log all survive. The canister’s post_upgrade takes an opt OisyTradeArg; passing (null) keeps the current configuration. To change the access mode (GeneralAvailability ↔ RestrictedTo), pass a structured Upgrade arg instead.
Build the WASM
just build compiles oisy_trade_canister to wasm32-unknown-unknown in release mode and produces wasms/oisy_trade_canister.wasm.gz. The icp CLI picks up that artifact automatically via the recipe declared in icp.yaml.
just build
Deploy
Two equivalent paths:
A. just deploy recipe — keeps config unchanged (--args '(null)'):
just deploy "$IDENTITY" "$PIN_FILE"
B. Full command — gives you control over the upgrade arg:
icp deploy oisy_trade --mode upgrade --args '(null)' \
--identity "$IDENTITY" --identity-password-file "$PIN_FILE" \
--environment staging -y
Reinstall the canister
Reinstall wipes stable memory: balances, open orders, listed trading pairs, and the event log are all lost. The canister principal, controllers, and cycles balance are preserved. Use this only when a clean slate is the actual goal — typically on staging during early development, or to recover from a broken post-upgrade. Never reinstall a canister holding user funds you cannot afford to lose.
Because the canister boots through canister_init rather than post_upgrade,
reinstall takes an OisyTradeArg::Init variant — not the (null) upgrade arg
used above. The shape of InitArg is defined in
canister/oisy_trade.did;
the staging defaults live in icp.yaml
under environments.staging.init_args.
Build the WASM
Same as for upgrade:
just build
Reinstall
Use icp deploy so the WASM artifact is resolved via the icp.yaml recipe —
icp canister install fails with could not find artifact for canister 'oisy_trade' since it doesn’t run the recipe. --args is omitted: icp deploy
picks up environments.staging.init_args from icp.yaml automatically. Pass
--args explicitly to override (e.g. to boot in RestrictedTo mode).
icp deploy oisy_trade --mode reinstall \
--identity "$IDENTITY" --identity-password-file "$PIN_FILE" \
--environment staging
max_orders_per_chunk and instruction_budget (set in icp.yaml) cap how
much work the matching engine does per chunked-execution slice; the
icp.yaml values
match the conservative production defaults (DEFAULT_MAX_ORDERS_PER_CHUNK and
DEFAULT_INSTRUCTION_BUDGET in
libs/types-internal/src/lib.rs).
After a reinstall, every trading pair must be re-listed from scratch — see Add a trading pair below.
Add a trading pair
add_trading_pair is an update call restricted to controllers. The request takes multiple parameters that are explained below.
Ledger parameters
Base is the asset being bought/sold; quote is the asset prices are denominated in.
export BASE_LEDGER=la34w-haaaa-aaaar-qb5na-cai # ckDevnetSOL
export QUOTE_LEDGER=apia6-jaaaa-aaaar-qabma-cai # ckSepoliaETH
Fetch ledger metadata
The symbol and decimals you submit must match what each ledger reports via icrc1_symbol / icrc1_decimals — otherwise OISY TRADE rejects the call (if the token is already registered under different metadata) or, more insidiously, registers the pair with metadata that misrepresents the asset.
icp canister call "$BASE_LEDGER" icrc1_symbol '()' --query --network ic --identity anonymous
icp canister call "$BASE_LEDGER" icrc1_decimals '()' --query --network ic --identity anonymous
icp canister call "$QUOTE_LEDGER" icrc1_symbol '()' --query --network ic --identity anonymous
icp canister call "$QUOTE_LEDGER" icrc1_decimals '()' --query --network ic --identity anonymous
Record what the ledgers reported:
export BASE_SYMBOL=ckDevnetSOL
export BASE_DECIMALS=9
export QUOTE_SYMBOL=ckSepoliaETH
export QUOTE_DECIMALS=18
Tick and lot sizes
tick_size— the minimum price increment (in quote-token base units per whole base token). All order prices must be a positive multiple.lot_size— the minimum quantity (in base-token base units). All order quantities must be a positive multiple.
Constraints:
- Both are
natand must be > 0. - Currently, both are fixed for the lifetime of the pair.
tick_size × lot_sizemust be a multiple of10^base_decimals. This ensures that a fill with notionalprice × quantity / 10^base_decimals, where price is a multiple oftick_sizeand quantity is a multiple oflot_size, is exact (no rounding).
Guidelines for picking values
Tick size — aim for ~1 basis point (0.01%) of the asset’s price, with a healthy range of 0.1 bp to 10 bp:
- The tick should sit below the typical bid-ask spread so the spread can be wider than one tick — otherwise the book is always one-tick wide and there is no price competition.
- It should sit above noise (typical tick-by-tick volatility) so a quote at a given level carries meaning.
- Higher-priced assets tolerate larger absolute ticks (e.g. $0.01 on a $100k asset is 0.00001%); stablecoins need ultra-fine ticks because the whole interesting range lives within ±0.1% of peg.
Mapping back to the formula above (tick_size = price_increment × 10^quote_decimals):
| Asset price | Typical price increment | Resulting tick (bp) |
|---|---|---|
| ~$1 (stablecoin) | $0.00001 – $0.0001 | 0.1 bp – 1 bp |
| ~$10 (mid-cap) | $0.001 | 1 bp |
| ~$1,000 | $0.1 | 1 bp |
| ~$100,000 (BTC) | $0.01 – $1 | 0.0001 bp – 0.01 bp |
Lot size — aim for the minimum order (1 lot × current price) to be roughly $0.10–$1 in notional.
- That keeps the smallest possible trade non-dust (worth settling) but small enough that a retail user can place “1 lot” without thinking about it.
- Powers of 10 only (
10^-n) — traders expect base-10 grids; fractional lot increments like 0.5 are unusual. - Coarser lots for cheaper tokens, finer for expensive ones (same scaling principle as tick).
- Coinbase intentionally sets very fine lots (down to satoshi-level) and relies on
min_notional(see below) to do the dust filtering. You can pick a fine lot and let the notional floor enforce the no-dust rule.
General
- Both values should be powers of 10. Off-grid values (e.g.
tick_size = 7) work mechanically but break trader UX. - Reconsider both when the asset’s price has moved by more than ~5× since listing — what was a $1 minimum order at listing can become a $5 minimum (or $0.20, with no way to fix it short of relisting since these are immutable).
- When in doubt, copy a major centralized exchange and convert to integer base units using the formulas above.
Sanity check against Binance
Centralized exchanges have already picked these parameters for most major pairs, balancing price precision against spam-order resistance. Binance’s public REST endpoint is a convenient sanity check:
curl -sSf "https://api.binance.com/api/v3/exchangeInfo?symbol=SOLETH" \
| jq '{tickSize: (.symbols[0].filters[] | select(.filterType=="PRICE_FILTER") | .tickSize), stepSize: (.symbols[0].filters[] | select(.filterType=="LOT_SIZE") | .stepSize)}'
The filters array contains a PRICE_FILTER (tickSize) and a LOT_SIZE (stepSize). Those values are human-readable decimal token counts — convert to OISY TRADE’s integer base units using the ledger decimals you exported above:
tick_size = tickSize_binance × 10^quote_decimals—tick_sizeis the price increment in quote base units per 1 whole base token, so only the quote scale enters.lot_size = stepSize_binance × 10^base_decimals—lot_sizeis the quantity increment in base base units, so only the base scale enters.
For SOLETH at the time of writing: tickSize = 0.00001 ETH/SOL, stepSize = 0.001 SOL. Plugging into the formulas above (ckDevnetSOL base_decimals = 9, ckSepoliaETH quote_decimals = 18):
tick_size = 0.00001 × 10^18 = 10^13lot_size = 0.001 × 10^9 = 10^6 = 1_000_000
export TICK_SIZE=10_000_000_000_000
export LOT_SIZE=1_000_000
Min and max notional
min_notional— minimum order value, in quote-token base units. Avoid dust orders. Must be > 0.max_notional— optional ceiling on order value, in quote-token base units. Avoid fat-finger errors. When set, must be ≥min_notional. Passnullto disable.
Guidelines
Convert dollar amounts using quote_decimals:
min_notional— for a USD-pegged quote,≈ $1is1 × 10^quote_decimals(e.g.1_000_000for a 6-dp stablecoin); below ~$0.50 the order becomes uneconomic to settle. For a non-USD quote, take the value from that pair’s BinanceNOTIONAL.minNotional, denominated in the quote asset (forSOLETH,0.001 ETH).max_notional— a fat-finger guardrail. Binance’sNOTIONAL.maxNotionalis9_000_000quote units (9_000_000 × 10^quote_decimals— i.e. $9M for a USD-pegged quote, or9_000_000 ETHfor theSOLETHexample below). Passnullif you don’t want a ceiling; pass a value if your pair has enough volatility risk that single large orders could move the book sharply.
# Binance SOLETH NOTIONAL filter: minNotional 0.001 ETH, maxNotional 9_000_000 ETH (× 10^18):
export MIN_NOTIONAL=1_000_000_000_000_000 # 0.001 ETH × 10^18
export MAX_NOTIONAL='opt (9_000_000_000_000_000_000_000_000 : nat)' # 9_000_000 ETH × 10^18 — or 'null' for no ceiling
Fees
maker_fee_bps— fee charged to the resting (maker) side of a match, in basis points (nat16,0..=10_000, where10_000 = 100%).taker_fee_bps— fee charged to the incoming (taker) side, same scale.
Guidelines
Major centralized exchanges sit at roughly 10 bps maker / 10 bps taker at the entry tier (Binance, Coinbase, Kraken VIP 0). Conventions:
- Maker ≤ taker — incentivizes liquidity provision. A common pattern is
0 maker / 10–20 takerto court market makers;10 / 10is the neutral default. - Round numbers (5, 10, 20 bps) are typical and easier to communicate.
- Maker rebates (negative fees) are not supported — the field is unsigned.
export MAKER_FEE_BPS=0
export TAKER_FEE_BPS=20
Launch basket
OISY TRADE’s chosen parameters for the initial listings, with the equivalent figures
on the major venues for the same pair (or the closest substitute). The OISY TRADE
rows show decimal = nat in each cell: the decimal is the human-readable amount,
the nat is the integer in the unit convention from
canister/oisy_trade.did — quote-token smallest units
per one whole base token for tick, base-token smallest units for lot, and quote-token
smallest units for min notional. CEX rows are human-readable only.
Snapshot as of 2026-06-16; CEX parameters drift, so re-check via the APIs above
before copying.
| Pair | Source | Tick (price increment) | Lot (qty increment) | Min notional |
|---|---|---|---|---|
| ICP/ckUSDT | OISY TRADE | 0.001 USDT = 1_000 | 0.01 ICP = 1_000_000 | $5 = 5_000_000 |
Binance ICPUSDT | 0.001 | 0.01 | $5 | |
Coinbase ICP-USD | 0.001 | 0.0001 | $1 | |
Kraken ICPUSD | 0.001 | 0.00000001 | $0.50 | |
| ckBTC/ckUSDT | OISY TRADE | 0.01 USDT = 10_000 | 0.0001 BTC = 10_000 | $5 = 5_000_000 |
Binance BTCUSDT | 0.01 | 0.00001 | $5 | |
Coinbase BTC-USD | 0.01 | 0.00000001 | $1 | |
Kraken XBTUSD | 0.1 | 0.00000001 | $0.50 | |
| VCHF/ckUSDT | OISY TRADE | 0.0001 USDT = 100 | 0.01 VCHF = 1_000_000 | $5 = 5_000_000 |
| Binance / Coinbase | not listed | — | — | |
Kraken USDTCHF ‡ | 0.00001 (inverted) | 0.00000001 | $0.50 | |
| ckUSDC/ckUSDT | OISY TRADE | 0.00001 USDT = 10 | 1 USDC = 1_000_000 | $5 = 5_000_000 |
Binance USDCUSDT | 0.00001 | 1 | $5 | |
| Coinbase | not listed | — | — | |
Kraken USDCUSDT | 0.0001 | 0.00000001 | $0.50 | |
| ckETH/ckUSDT | OISY TRADE | 0.01 USDT = 10_000 | 0.0001 ETH = 100_000_000_000_000 | $5 = 5_000_000 |
Binance ETHUSDT | 0.01 | 0.0001 | $5 | |
Coinbase ETH-USD | 0.01 | 0.00000001 | $1 | |
Kraken ETHUSD | 0.01 | 0.00000001 | $0.50 |
‡ Kraken’s USDTCHF quotes USDT in CHF — the inverse direction of VCHF/ckUSDT.
At near-parity prices the precision implications transfer, so the tick is a
loose proxy for VCHF/USDT precision; treat as directional, not exact.
VCHF is a synthetic Swiss-franc stablecoin. There’s no direct CHF/USDT pair on
Binance, Coinbase, or Kraken; USDTCHF is the closest market for the same
underlying. The parameters were picked using the same sizing rules as the other
launch pairs (≈1 bp tick, sub-dollar lot).
Call add_trading_pair
icp canister call oisy_trade add_trading_pair --args-file /dev/stdin \
--identity "$IDENTITY" --identity-password-file "$PIN_FILE" \
--environment staging <<EOF
(
record {
base = record {
id = record { ledger_id = principal "$BASE_LEDGER" };
metadata = record { symbol = "$BASE_SYMBOL"; decimals = $BASE_DECIMALS : nat8 }
};
quote = record {
id = record { ledger_id = principal "$QUOTE_LEDGER" };
metadata = record { symbol = "$QUOTE_SYMBOL"; decimals = $QUOTE_DECIMALS : nat8 }
};
tick_size = $TICK_SIZE : nat;
lot_size = $LOT_SIZE : nat;
maker_fee_bps = $MAKER_FEE_BPS : nat16;
taker_fee_bps = $TAKER_FEE_BPS : nat16;
min_notional = $MIN_NOTIONAL : nat;
max_notional = $MAX_NOTIONAL
}
)
EOF
Verify the listing
icp canister call oisy_trade get_trading_pairs '()' --environment staging --query --identity anonymous
The new pair should appear in the output.
Halt and resume trading
The canister exposes a controller-gated global trading halt: a soft circuit breaker that pauses new orders and the matching engine across every pair while always preserving state and letting users exit their positions.
Mechanism
While the halt is in effect:
add_limit_orderis rejected withTradingHaltedonce the request passes pair resolution and validation — an unknown pair still returnsUnknownTradingPair, and an order failing the tick/lot or notional checks still returns the corresponding validation error, since those run before the halt gate.- The matching engine makes no progress: resting orders are left untouched and no crossing fills occur while the halt is in effect.
What the halt itself does not block:
cancel_limit_order— canceling resting orders.withdrawanddeposit— moving available balance.
These endpoints are not gated by the halt, so they are not rejected with
TradingHalted. Other canister-wide access controls (e.g. the Mode
restriction) still apply and can independently reject a caller.
The global halt is a single persisted flag. Each change is recorded as a
SetHalt event in the audit log (with no pair filter, i.e. book_ids = null),
so it is reproduced exactly on replay, and the flag is included in the upgrade
snapshot, so it survives canister upgrades.
Both endpoints take an optional pair filter (see section 5); passing null
targets the global halt. They are idempotent: halting an already-halted canister
(or resuming an already-active one) is a no-op success that still emits an event
for the audit trail. A global resume (resume_trading(null)) additionally clears
every per-pair halt. Non-controller callers are rejected with NotController.
When to use it
Use a global halt when a problem affects the whole exchange and trading must stop everywhere until it is resolved — for example, a suspected matching-engine bug. Halt trading, investigate and fix, then resume. Because cancels and withdrawals stay open, users can exit their positions while the halt is in effect.
Halt
icp canister call oisy_trade halt_trading '(null)' \
--identity "$IDENTITY" --identity-password-file "$PIN_FILE" \
--environment staging
Resume
icp canister call oisy_trade resume_trading '(null)' \
--identity "$IDENTITY" --identity-password-file "$PIN_FILE" \
--environment staging
Halt and resume a single trading pair
A per-pair halt stops new orders and matching on a single trading pair while every other pair keeps trading normally.
Mechanism
A per-pair halt reuses the same halt_trading / resume_trading endpoints as
the global halt, but with a list of trading pairs instead of null.
While a pair is halted:
add_limit_orderon the halted pair is rejected withTradingHalted(the same error as a global halt).- The matching engine skips the halted pair: its resting orders are left untouched and no crossing fills occur on it, while other pairs continue to match.
What stays open for the halted pair:
cancel_limit_order— users can always cancel resting orders on the pair.withdrawanddeposit— balances are never tied to a single pair and stay movable.
Halted pairs are tracked as a set of order-book identifiers. Each change is
recorded as a SetHalt event in the audit log (with the targeted pairs in
book_ids), so it is reproduced exactly on replay, and the set is included in
the upgrade snapshot, so it survives canister upgrades.
A pair is considered halted when the global flag is on or the pair is in the
set, and get_trading_pairs reports it as Halted in either case. Passing a
list of pairs to halt_trading adds them to the set; passing the same list to
resume_trading removes them; resume_trading(null) clears the entire set at
once (along with the global flag).
The endpoints are controller-gated; non-controller callers are rejected with
NotController. They trap if any listed pair is not a registered trading
pair (all pairs are validated up front, before anything is recorded), and are
idempotent: re-halting an already-halted pair is a no-op success that still emits
an event for the audit trail. A single call may list at most 100 pairs; passing
more traps.
When to use it
Use a per-pair halt when a problem is confined to one market rather than the whole exchange — for example, a compromised or suspect ledger backing one pair’s token. Halt just that pair so trading on every other pair continues uninterrupted, investigate, and resume the pair once it is safe. Because cancels and withdrawals stay open, users holding orders on the halted pair can still exit.
Halt a pair
icp canister call oisy_trade halt_trading \
"(opt vec { record { base = principal \"$BASE_LEDGER\"; quote = principal \"$QUOTE_LEDGER\" } })" \
--identity "$IDENTITY" --identity-password-file "$PIN_FILE" \
--environment staging
Resume a pair
icp canister call oisy_trade resume_trading \
"(opt vec { record { base = principal \"$BASE_LEDGER\"; quote = principal \"$QUOTE_LEDGER\" } })" \
--identity "$IDENTITY" --identity-password-file "$PIN_FILE" \
--environment staging
Clean up
If you used option B in Setup (prompt + temp file), remove the file:
rm -f "$PIN_FILE"
If you used option A (pointed at an existing file), leave it alone.
OISY TRADE: Fully Onchain Order Book
High-level design for OISY TRADE, an order-book DEX running entirely onchain as an Internet Computer canister.
Overview
The OISY TRADE canister implements a central limit order book (CLOB) that matches buy and sell orders for ICRC-2 token pairs. All order management, matching, and settlement happen onchain within a single canister.
There are three distinct flows:
1. Deposit
The user moves tokens from their wallet into the OISY TRADE canister. This is a prerequisite for trading.
User OISY TRADE ICRC-2 Ledger
| | |
|-- icrc2_approve ---------------------------------------->|
| | |
|-- deposit(token, amt) -->| |
| |-- icrc2_transfer_from ------>|
| | (user -> OISY TRADE) |
| | |
| | credit user's internal |
| | balance |
|<-- block_index ----------| |
2. Trade
The user places orders using their deposited balance. Matching and settlement are purely internal bookkeeping — no token transfers occur, no asynchronous calls.
User OISY TRADE
| |
|-- add_limit_order ------>|
| | debit user's available balance
| | queue order for matching
|<-- order_id -------------|
| |
| (timer fires) |
| | matching engine processes queue
| | insert/match against book
| | credit proceeds on fills
| |
|-- get_my_orders -------->|
|<-- orders with status |
| (Pending/Open/ |
| Filled/Canceled) -----|
3. Withdrawal
The user moves tokens from the OISY TRADE canister back to their wallet.
User OISY TRADE ICRC-2 Ledger
| | |
|-- withdraw(token, amt) ->| |
| | debit user's internal |
| | balance |
| |-- icrc1_transfer ----------->|
| | (OISY TRADE -> user) |
|<-- ok -------------------| |
This separation means the matching engine never waits on async inter-canister calls. Token transfers only happen at the edges (deposit/withdrawal), while trading operates entirely on internal balances.
Access Control
| Role | Capabilities |
|---|---|
| Admin (controller) | Add pairs (fees set at pair creation), halt/resume trading, upgrade canister |
| User (any principal) | Place orders, cancel own orders, deposit, withdraw own balance |
- No allowlisting: any principal can trade on any active pair.
- Admin operations are guarded by
ic_cdk::api::is_controller().
Trading
Trading Pairs
A trading pair consists of a base token and a quote token, each identified by their ICRC-2 ledger canister principal. A price is expressed in quote token smallest units per one whole base token (i.e. per 10^base_decimals base units). A fill of quantity base units (smallest denomination) at price settles to the following amount of quote token smallest units:
quote = price × quantity / 10^base_decimals
This lets pairs with very different decimals (e.g. ckETH(18)/ckUSDC(6)) express realistic rates that would otherwise round to zero.
Example: ckETH/ckUSDC (base ckETH = 18 decimals, quote ckUSDC = 6 decimals)
price = 3_000_500_000 (= 3000.50 USDC per whole ETH, scaled by 10^6)
buy 0.5 ETH (quantity = 5×10^17 wei):
quote = 3_000_500_000 × 5×10^17 / 10^18 = 1_500_250_000 (= 1500.25 USDC) — exact
For that division to be exact for every order and fill (no rounding, no dust), a pair is rejected at creation unless tick_size × lot_size is a multiple of 10^base_decimals. Since every price is a multiple of the tick and every fill quantity a multiple of the lot, price × quantity is then always a multiple of 10^base_decimals.
Pair Management
- An admin (the canister controller) can add trading pairs.
- Each pair has configurable parameters:
- Tick size: minimum price increment.
- Lot size: minimum order quantity.
- Min notional: minimum order value (
price × quantity / 10^base_decimals, in quote smallest units). Rejects dust orders worth less than the cost of settling them. Required; must be greater than zero. - Max notional: optional maximum order value (same units). Rejects fat-finger orders and caps single-order impact. When set, must be greater than or equal to the min notional.
- Status: active or halted.
- Tick, lot, min notional, and max notional are enforced independently: an order may fail any one of them, and none is implied by another.
- Orders can only be placed on active pairs.
Order Lifecycle
Since deposits are a separate step, the user’s balance is already available when placing an order. Orders are not matched immediately — they are queued and processed asynchronously by a timer-driven matching engine.
add_limit_order
|
v
+------------+
| Pending | <-- queued, awaiting processing
+------------+
|
timer fires,
matching engine
processes queue
|
v
+------------+
| Open | <-- resting in the book (unfilled remainder)
+--+----------+
^ | \
| filled cancel_limit_order
partial | |
fill v v
| +-----------+ +------------+
+--+->| Filled | | Canceled |
+-----------+ +------------+
- Pending: The order is submitted. The required funds are debited from the user’s available balance (quote tokens for buys, base tokens for sells). The order is placed in a queue and an order ID is returned immediately. If the user has insufficient balance, the order is rejected.
- Open: The timer-driven matching engine dequeues the order and matches it against the opposite side of the book. If the order is fully filled during this initial matching, it transitions directly to
Filledwithout ever resting in the book. If only partially filled, the filled portion is settled immediately (proceeds credited to the user’s available balance) and the remaining quantity rests in the book at the specified price level, where it can be matched against future incoming orders. - Filled: The order has been fully matched (either immediately or after resting in the book). Proceeds from the final fill are credited to the user’s available balance.
- Canceled: The user canceled the order via
cancel_limit_order. Reserved tokens are returned to the user’s available balance.
Order Book Data Structure
Each trading pair maintains an order book stored on the heap which consists of two sorted sides:
- Bids (buy orders):
- sorted by price descending, then by insertion order (price-FIFO priority).
- Implemented as
BTreeMap<Reverse<Price>, VecDeque<Order>>
- Asks (sell orders):
- sorted by price ascending, then by insertion order.
- Implemented by
BTreeMap<Price, VecDeque<Order>>
This gives O(log n) insertion/removal by price and O(1) access to the best bid/ask.
Memory Estimates
An Order instance contains:
- an ID (
u64): 8 bytes - a side (enum with 2 variants buy/sell): 1 byte
- a price (
u64): 8 bytes - a quantity (
u64): 8 bytes
totaling approximately 25 bytes per order. This could be reduced further to 17 bytes by removing the price from the Order struct given that it’s already used as key in the buy/sell orders. The following estimates upper bound the memory taken by an order by 32 bytes.
Per-price-level overhead consists of a BTreeMap node (~64 bytes per entry, amortized across B-tree nodes) plus a VecDeque header (~48 bytes including pointer, length, and capacity). The VecDeque backing buffer grows as needed and may over-allocate by up to 2x.
Estimated memory per order book side:
| Component | Memory |
|---|---|
| Per order | ~32 bytes |
Per price level (BTreeMap entry + VecDeque header) | ~112 bytes |
Per order (VecDeque buffer slot) | ~32 bytes (up to 2x with over-allocation) |
Real-world reference: Binance ICP/BTC order book snapshot (retrieved via GET /api/v3/depth?symbol=ICPBTC&limit=5000):
- 135 bid price levels, 1,310 ask price levels (1,445 total).
- Binance aggregates all orders at a price into a single entry. Assuming ~10 individual orders per price level on OISY TRADE (no aggregation), the estimated memory for this pair would be:
1,445 levels × 112 B + 14,450 orders × 64 B ≈ 1 MiB
This fits comfortably within the 4 GiB Wasm heap. Even with 100 trading pairs of similar depth, the total book state would remain well under 200 MiB.
Matching Engine
Matching runs on a timer and processes pending queued orders, which makes it possible to chunk the matching process into smaller batches.
Fees
Each trading pair has a maker fee and a taker fee, both expressed in basis points (1 bps = 0.01% = 0.0001):
- Either rate may be zero.
- Rates are non-negative. (They could be expanded to offer a rebate mechanism for the maker fee to incentivize liquidity).
The two rates may change over the lifetime of a trading pair without affecting any orders already resting in the book — the next fill on that pair uses whichever rates are in effect at fill time.
Each fill has two sides:
- Maker — the resting order that provided liquidity. Pays the maker fee.
- Taker — the incoming order that crossed the book. Pays the taker fee.
Each side pays its fee in the asset it receives (the side’s proceeds):
- the buyer receives the base asset and pays its fee in base;
- the seller receives the quote asset and pays its fee in quote.
The fee is deducted from the side’s proceeds at fill time. In smallest units, fee = ceil(proceeds * fee_bps / 10_000) and net_credit = proceeds - fee, so rounding (when needed) is always in favor of the protocol (see examples below).
(Not rounding in favor of the protocol was, for example, a problem for Aave before version 3.5).
Examples
Consider the following parameters (chosen for ease of computation):
- ICP/BTC, 10 ICP filled at 0.0001 BTC per ICP (both tokens use 8 decimals)
- maker fee of 10 bps (0.1%)
- taker fee of 25 bps (0.25%).
Taker is the buyer (incoming buy hits a resting sell):
Both tokens are assumed to have 8 decimals, so amounts are shown in base units (1 token = 10^8 base units) with the whole-token equivalent in parentheses.
| Side | Role | Receives | Fee rate | Fee paid | Net credit |
|---|---|---|---|---|---|
| Buyer | Taker | 1_000_000_000 (10 ICP) | 25 bps | 2_500_000 (0.025 ICP) | 997_500_000 (9.975 ICP) |
| Seller | Maker | 100_000 (0.001 BTC) | 10 bps | 100 (0.000001 BTC) | 99_900 (0.000999 BTC) |
Mirror-image fill — taker is the seller (incoming sell hits a resting buy): same sizes as the first table, with the roles reversed so each rate now applies to the opposite side:
| Side | Role | Receives | Fee rate | Fee paid | Net credit |
|---|---|---|---|---|---|
| Buyer | Maker | 1_000_000_000 (10 ICP) | 10 bps | 1_000_000 (0.01 ICP) | 999_000_000 (9.99 ICP) |
| Seller | Taker | 100_000 (0.001 BTC) | 25 bps | 250 (0.0000025 BTC) | 99_750 (0.0009975 BTC) |
Rounding made visible. The proceeds above are all multiples of 10_000, so the bps math comes out integer and no rounding occurs. The rounding direction matters only for a fill whose proceeds don’t divide evenly by 10_000. Two abstract examples (any token, any side):
| Proceeds (base units) | Fee rate | Exact proceeds * bps / 10_000 | Fee paid | Net credit |
|---|---|---|---|---|
| 1_000 | 47 bps | 4.7 | 5 | 995 |
| 1_000 | 33 bps | 3.3 | 4 | 996 |
Both fees are rounded up in the protocol’s favor.
Collection
Collected fees accumulate per token into an internal canister-owned balance, one entry per token,
exposed read-only through the get_fee_balances query. No fee-withdrawal endpoint is implemented yet.
Storage
The fee balances live on the heap, and are persisted across canister upgrades through the same pre/post-upgrade snapshot used for the trading-pair list and order books (CBOR-serialized into stable memory at pre_upgrade, restored at post_upgrade).
The alternative would have been to co-locate them with user balances in the canister’s stable-memory ledger. The trade-offs:
-
Stable memory.
- Auto-survives upgrades with no serialization step, and capacity is effectively unbounded.
- But every fee accrual at fill time triggers a stable-memory read + write (~thousands of instructions per accrual); across a chunked round of 1_000 fills this adds millions of instructions to the hot path.
-
Heap (chosen).
- Per-fill cost is a heap-map insert + integer add (~hundreds of instructions), so the hot path stays cheap. The cost is paid once per upgrade as CBOR (de)serialization, and its magnitude is bounded by the number of listed tokens, not by any user-driven dimension.
- Realistic upper bound: Binance lists ~400 spot tokens. Each fee-pool entry stores a
TokenId(aPrincipal, up to 29 bytes) and aQuantity(u256, 32 bytes), plusBTreeMapnode overhead. At ~125 bytes per entry on heap, 400 tokens occupy ≤ 50 KB — negligible against the 4 GiB Wasm heap. The CBOR-serialized form encoded into stable memory at each upgrade is even smaller (~30 KB worst case), with serialization cost ≤ a few hundred thousand instructions.
References
The receive-side fee mechanism is one way to accrue fees, but is not the only one. It is used by:
In contrast, Kraken uses a send-side fee mechanism (see add order):
fcib: prefer fee in base currency (default if selling)fciq: prefer fee in quote currency (default if buying, mutually exclusive with fcib)
Balances
The canister tracks per-user balances internally. Each user’s balance for a given token is split into:
- Available: funds that can be used to place new orders or be withdrawn.
- Reserved: funds locked by open orders (quote tokens for bids, base tokens for asks).
Balance transitions:
- On order placement: the required amount moves from available to reserved.
- On fill: the reserved amount of the filled side is consumed, and the proceeds are credited to the available balance of the corresponding token. For example, when a buy order fills, the reserved quote tokens are consumed and the base tokens are credited as available.
- On cancel: reserved tokens move back to available.
- On deposit: available balance increases.
- On withdrawal: available balance decreases.
Actual token transfers (inter-canister calls) only happen during deposits and withdrawals. All trading operations are purely internal bookkeeping.
Deposits
Deposits are independent from order placement. The user first approves the OISY TRADE canister on the ICRC-2 ledger, then calls deposit(token, amount). The canister executes icrc2_transfer_from to move tokens into its custody and credits the user’s internal balance.
Withdrawals
Users call withdraw(token, amount) to transfer tokens from their available balance to their wallet. The canister calls icrc1_transfer on the relevant ledger. The withdrawal fails if the requested amount exceeds the user’s available balance or if the ledger is not available.
Balance Memory Estimates
Assume 1M users with non-zero balances, and that almost all quantities fit in a u128 (per #59: Quantity is a stack-allocated (u128, u128) — 32 B — encoded as a plain CBOR integer when the value fits in u64 and as a PosBignum Tag 2 otherwise). Balances are stored token-first (per #60: BTreeMap<TokenId, BTreeMap<Principal, Balance>>).
Per-entry sizes:
| Item | Heap | CBOR |
|---|---|---|
TokenId / Principal | ~30 B | ~32 B |
Balance (2 × Quantity) | 64 B | ~40 B (u128 values via PosBignum) |
Totals at 1M users, varying the average number of tokens held per user. The outer TokenId map is dominated by a handful of listed tokens, so nearly all space is in the inner Principal → Balance maps:
| Tokens / user | Inner entries | Heap | CBOR snapshot |
|---|---|---|---|
| 2 | 2M | ~220 MB | ~150 MB |
| 5 | 5M | ~550 MB | ~370 MB |
| 10 | 10M | ~1.1 GB | ~740 MB |
Fits within the 4 GiB heap limit even at 10 tokens/user. The CBOR snapshot at 5 tokens/user is ~5_900 stable-memory pages — well within the 2 TiB stable budget, but large enough that the cost of serializing balances at every upgrade needs to be measured, not assumed.
Order History
Every order submitted to OISY TRADE is recorded in a map keyed by OrderId; keys are insert-only (one record per submission) while each record’s status is updated in place as the order transitions. Each OrderRecord captures:
- owner: the
Principalthat submitted the order. - side:
BuyorSell. - price: the limit price, a
u128(exposed asnatin the Candid interface). - quantity: the original submission size as a
Quantity. - filled_quantity: the cumulative quantity filled so far as a
Quantity. - status: the current lifecycle state —
Pending,Open,Filled, orCanceled. - created_at: the time the order was submitted, in nanoseconds since the Unix epoch.
- last_updated_at: the time of the most recent modifying event (fill, status transition, or cancel), in nanoseconds since the Unix epoch; optional —
nulluntil the order is first modified.
A record is inserted once at submission and its status field is updated as the order transitions through its lifecycle. The trading pair is not stored — it is derivable from the OrderBookId embedded in the OrderId via the canister’s trading-pair registry.
The history exists for a single purpose: serving the get_my_orders query (including its ById point lookup) so clients that have lost track of a submission can recover its outcome.
Order History Memory Estimates
Per-record size, assuming Quantity encodes mostly in the u128 range (see Balance Memory Estimates):
| Item | Heap | CBOR |
|---|---|---|
OrderId (key, (u64, u64)) | 16 B | ~18 B |
owner: Principal | ~30 B | ~32 B |
price: u64 | 8 B | ~5 B |
quantity: Quantity | 32 B | ~20 B (u128 via PosBignum) |
side + status | 2 B | ~2 B |
| Overhead | ~15 B (BTree amortized) | ~5 B (CBOR map) |
| Per record | ~100 B | ~85 B |
Applying the expected load — 0.7 orders/s steady-state, 40 orders/s sustained during peak-hour events:
| Horizon | Orders | Heap | CBOR |
|---|---|---|---|
| 1 day @ steady | 60 K | ~6 MB | ~5 MB |
| 1 yr @ steady | 22 M | ~2.2 GB | ~1.9 GB |
| 1 yr @ steady + ~50 peak hours | ~30 M | ~3.0 GB | ~2.6 GB |
| 2 yr @ steady | 44 M | ~4.4 GB | ~3.7 GB |
order_history grows monotonically with the total number of orders ever placed. It fits comfortably on the heap for the first year of steady-state traffic but crosses the 4 GiB heap limit at around two years. A retention or archival policy is required unless the history is stored in stable memory, where the 2 TiB per-subnet budget dominates.
Architecture
Single-Canister Design
All state lives in one canister. This avoids cross-canister call complexity but one remains bound to the canister resource limits:
- Instruction limit: matching must complete within a single message execution (~40B instructions on a fiduciary subnet). Very large order books may require to chunk the matching, which is possible because the matching is done on a timer.
- Memory limit:
- heap: limited to 4 GiB per canister. This can be a problem if an order book becomes too big or there are too many trading pairs (see the section on memory estimates).
- stable memory: limited to 2 TiB, shared across the whole subnet. This can be a problem for the replayable event log stored in stable memory.
Synchronous Trading
Since deposits and withdrawals are separate from trading, the matching engine operates entirely on internal balances with no inter-canister calls. This means:
- No async complexity during matching: order placement, matching, and settlement are fully synchronous within a single update call or timer execution. There are no reentrancy concerns during trading.
- Predictable execution: the matching engine’s instruction cost depends only on the number of price levels and orders matched, not on external canister latency.
Async Deposits and Withdrawals
Inter-canister calls (ICRC-2 transfer_from for deposits, ICRC-1 transfer for withdrawals) only occur at the edges. These calls are async, so:
- Deposit: the canister must handle the case where the
transfer_fromcall fails (e.g., insufficient allowance, ledger unavailable). The user’s internal balance is only credited after a successful transfer. - Withdrawal: similarly, the available balance is debited optimistically, and if the
transfercall fails, the balance must be restored. - Reentrancy: between an async transfer call and its response, other update calls can execute. Since deposits and withdrawals only affect the initiating user’s balance and do not interact with the order book, this is safe provided the implementation enforces that the same user cannot have multiple in-flight deposit/withdrawal requests for the same token (e.g., via a per-(user, token) in-flight guard).
Main Endpoints
The list below is illustrative, not exhaustive — canister/oisy_trade.did is the
authoritative interface. Endpoints not detailed here include get_trading_pairs,
get_order_book_ticker, get_order_book_depth, get_fee_balances, and the
controller-only add_trading_pair, halt_trading, and resume_trading.
Update calls (state-changing):
deposit(token, amount): transfers tokens into the canister viaicrc2_transfer_from. Credits the user’s available balance on success. Involves one async inter-canister call. Time: O(1) for balance bookkeeping, dominated by the async ledger call.withdraw(token, amount): transfers tokens from the canister to the user’s wallet viaicrc1_transfer. Debits the user’s available balance. Time: O(1) for balance bookkeeping, dominated by the async ledger call.add_limit_order(pair, side, price, quantity): validates the order (balance, tick/lot size), debits the required amount from the user’s available balance to reserved, enqueues the order, and returns an order ID. Fully synchronous — no inter-canister calls. Time: O(1). Memory: O(1) for the queued order.cancel_limit_order(order_id): removes a resting order from the book and returns reserved tokens to the user’s available balance. Time: O(log p + k) where p is the number of price levels and k is the queue depth at the order’s price level (to find and remove the order from theVecDeque). Memory: frees the canceled order.
Timer-driven (internal):
- Matching engine: dequeues pending orders and matches each against the opposite side of the book. Time per order: O(f · log p) where f is the number of fills and p is the number of price levels (each fill may remove a level, requiring a
BTreeMapoperation). Memory: O(f) for the fill records produced; net memory change depends on whether the order rests (adds to the book) or fills (removes from the book).
Query calls (read-only):
get_my_orders(opt GetMyOrdersArgs): returns the caller’s orders, each with its current status. The argument is optional; when absent it defaults to the first page (newest first,length = MAX_ORDERS_PER_RESPONSE). When present,GetMyOrdersArgs.filterselects the mode:ByIdperforms a point lookup of a single order;ByPagereturns a page over the caller’s orders, newest first. Time: O(1) forByIdwith an order-ID-indexed map; O(k) forByPageover the page length.get_balances(filter): returns the caller’s per-token balances. With no filter, iterates over all tokens registered with OISY TRADE, performs a balance lookup for each, and emits only non-zero entries; with a filter, returns one entry per requestedFilterToken(in submission order, including zero entries andTokenNotSupportedfor unknown tokens). Time: with no filter, O(t) over the number of registered tokens; with a filter, O(f) over the number of requested filter entries.list_supported_tokens(): returns the full list of tokens registered with OISY TRADE. Time: O(n) over the registered tokens.
Expected Load
Based on Binance ICP/USDT data (the most active ICP pair), two numbers drive the design:
- Steady-state: ~0.7 trades/sec (avg 2,600 trades/hour). A single canister handles this trivially.
- Peak: ~40 trades/sec sustained over a full hour during market-wide events, with p99 at ~4 trades/sec.
Peak load is the binding constraint. The timer-driven matching engine naturally absorbs bursts by queuing orders and processing them in batches — the exact mechanism to sustain peak load will be addressed in DEFI-2724.
See the Trading Data Analysis for the full analysis.
Upgrade Strategy
Canister state must survive upgrades. Different data structures have different cost profiles in terms of instructions and memory. The strategy is therefore chosen per data structure rather than applied uniformly.
Core Data Structures
Three hot-path data structures must be preserved across upgrades:
order_books— see Order Book Data Structure.balances— see Balances.order_history— see Order History.
Each structure can live either in stable memory or on the heap. The two placements are described in the following subsections and evaluated per-structure in the later sections.
Stable Memory
Stored typically in a StableBTreeMap; per-op durability at the cost of a stable-memory tax on every access.
- Pros:
- Per-op durability.
- Zero upgrade cost.
- Size bounded only by the 2 TiB per-subnet stable budget.
- Cons:
- Roughly ~20× slower per operation (see #57), driven by:
- Every tree hop crosses the Wasm-to-host boundary to read or write stable memory — orders of magnitude more expensive than a heap pointer chase.
- Keys and values are serialized bytes, so each access pays a decode (and, on writes, a re-encode) of the full value.
- No in-place mutation: unlike heap
BTreeMap::get_mut,StableBTreeMapexposes onlyget/insert, so even a single-field update (e.g. incrementing aBalance) requires a full read-modify-write of the entire value.
- Stable-memory layout is harder to evolve than a heap struct.
- Roughly ~20× slower per operation (see #57), driven by:
Heap
The structure lives in memory for fast access; a dedicated mechanism preserves it across upgrades. Two variants:
- Event replay: state-changing events are appended to a stable log and replayed at
post_upgradeto reconstruct the in-memory state.- Pros:
- Hot path at heap speed.
- Free audit trail.
- Survives
pre_upgradetrap since events are already persisted.
- Cons:
- One stable write per state-changing operation.
- Replay cost grows with log size and may exceed the 300 B instruction limit for
pre_upgrade/post_upgrade:- If the log contains many events.
- If replaying some events is costly in instruction terms.
- Event schema must remain backwards-compatible.
- Pros:
- Pre Upgrade: the full structure is serialized to stable memory at
pre_upgradeand restored atpost_upgrade.- Pros:
- Zero per-op cost.
- Upgrade cost is bounded by current state size, not lifetime traffic.
- Cons:
- Footgun: a
pre_upgradetrap aborts the upgrade, so upgrades cannot proceed until the issue is fixed or an alternative recovery/deployment path is used. - Upgrade cost is linear in state size and can exceed the 300 B
pre_upgrade/post_upgradebudget for large or unbounded structures. - Serialization schema must remain backwards-compatible.
- Footgun: a
- Pros:
Summary
At-a-glance viability of each placement per data structure (🟢 = viable choice, 🟠 = possible but expensive, 🔴 = ruled out).
| Data structure | Stable memory | Heap + event replay | Heap + pre-upgrade snapshot |
|---|---|---|---|
order_books | 🔴 21×-29x the number of instructions for matching as when done on the heap (#57) | 🔴 replay complexity is O(matching) in the worst-case, e.g. insert resting orders which involves only tree operation that would need to be replayed. Over 22 M events / yr exceeds the upgrade budget | 🟢 Once per upgrade per order book: 60M instructions for Binance ICP/USDT (#58), about 2 bps of the 300 B instructions upgrade budget. Even snapshotting 1_000 order books would be ~60 B instructions (~20% of the budget) |
balances | 🟠 15× the number of instructions for settling as when done on the heap (#57). | 🔴 replay complexity is O(settling): need to update balances according to the fills. Over 22 M events / yr exceeds the upgrade budget | 🔴 Once per upgrade per traded token: 150M for balances needed for Binance ICP/USDT (#58). Doesn’t scale well: - Long tail: many users will have various tokens with small balances. - Adding a new trading pair adds 2 token balances to snapshot |
order_history | 🟢 no efficiency concern | 🔴 heap limit crossed at ~2 yr; replay blows the 300 B budget | 🔴 snapshot blows the 300 B budget at ~22 M records |
Auditability
- Every state-changing operation (deposit, order placement, fill, cancellation, withdrawal, pair configuration change, etc.) is recorded as an event in a persistent, append-only log in stable memory.
- Bugs can be reproduced by replaying the event log (may take a significant amount of time).
Monitoring
Metrics
The canister exposes Prometheus-format metrics over its /metrics HTTP endpoint:
- Pending and resting order counts per pair.
- Best bid and ask per pair.
- Number of registered trading pairs.
- Per-token accrued fee balances.
- Total event-log entry count.
- Cycle balance, and stable and heap memory size.
Potential Additional Features
- Market orders: execute at best available price with no resting in the book.
- Order expiry: support good-til-time orders that are automatically canceled after a specified deadline, in addition to the default good-til-canceled.
- Batch operations: place or cancel multiple orders atomically in a single call, reducing round trips for market makers.
Trading Data
The aim is to get a rough idea of the load that can be envisioned for a successful order book in the ICP ecosystem. We focus on Binance as the primary benchmark because it handles 25-40x more ICP volume than the next largest exchange (Kraken):
| Metric | Binance ICP/USDT | Kraken ICP/USD | Ratio |
|---|---|---|---|
| Trades/24h | 15,726 | 644 | 24x |
| Volume/24h (ICP) | 1,189,216 | 28,933 | 41x |
| Book depth (levels) | 5,697 | 761 | 7.5x |
| Peak trades/hour | 143,932 | 3,532 | 41x |
Both exchanges peaked at the same hour (2026-03-11 06:00 UTC) with similar peak-to-average ratios (~30-54x), confirming that burst patterns are market-wide, not exchange-specific. Kraken data was fetched from their public REST API (/0/public/Ticker, /0/public/OHLC, /0/public/Depth, and /0/public/Trades endpoints) on the same date.
The trading data below was obtained from Binance public API on 2026-04-04. Interesting pairs related to ICP:
- ICP/BTC: pure crypto trading pair
- ICP/USDT: most active trading pair involving ICP
Overview of ICP Trading Pairs on Binance (24h snapshot, 2026-04-04)
| Pair | Trades/24h | Volume (ICP) | Quote Volume |
|---|---|---|---|
| ICP/USDT | 15,726 | 1,189,216 | $2,727,915 |
| ICP/BNB | 4,000 | 36,058 | 567 BNB |
| ICP/BUSD | 4,000 | 112,270 | $442,288 |
| ICP/ETH | 4,000 | 70,381 | 84 ETH |
| ICP/USDC | 3,680 | 241,641 | $554,496 |
| ICP/TRY | 475 | 32,241 | 3,289,432 TRY |
| ICP/FDUSD | 184 | 2,879 | $6,597 |
| ICP/BTC | 132 | 6,993 | 0.24 BTC |
| ICP/EUR | 131 | 5,017 | 10,003 EUR |
| ICP/RUB | 0 | 0 | 0 RUB |
ICP/USDT is the busiest pair by far (~4x the runner-up). BNB, BUSD, and ETH pairs all show exactly 4,000 trades, likely reflecting Binance’s internal market-making bots rather than organic volume.
Fetched by querying all ICP pairs from the exchange info endpoint, then their 24h tickers:
# List all ICP pairs
curl -s 'https://api.binance.com/api/v3/exchangeInfo' | python3 -c "
import json,sys; info=json.load(sys.stdin)
for s in info['symbols']:
if s['baseAsset']=='ICP' or s['quoteAsset']=='ICP': print(s['symbol'])"
# Get 24h stats for each pair (example for one)
curl -s 'https://api.binance.com/api/v3/ticker/24hr?symbol=ICPUSDT'
Data Sources
All data was fetched from Binance public API v3 on 2026-04-04. Commands to recreate:
API="https://api.binance.com/api/v3"
DATE=$(date +%Y_%m_%d)
SYMBOLS="ICPBTC ICPUSDT"
for SYM in $SYMBOLS; do
# 24h ticker statistics
curl -s "$API/ticker/24hr?symbol=$SYM" -o "${DATE}_binance_ticker_24hr_${SYM}.json"
# Hourly candlestick data (last 1000 hours ~ 41 days)
curl -s "$API/klines?symbol=$SYM&interval=1h&limit=1000" -o "${DATE}_binance_klines_1h_${SYM}.json"
# Order book depth (up to 5000 levels)
curl -s "$API/depth?symbol=$SYM&limit=5000" -o "${DATE}_binance_depth_${SYM}.json"
# Aggregated trades (last 1000)
curl -s "$API/aggTrades?symbol=$SYM&limit=1000" -o "${DATE}_binance_agg_trades_${SYM}.json"
# Individual historical trades (last 1000)
curl -s "$API/historicalTrades?symbol=$SYM&limit=1000" -o "${DATE}_binance_historical_trades_${SYM}.json"
done
Analysis Results
1. Volume Comparison (24h snapshot)
| Pair | Trades/24h | Volume (ICP) | Quote Volume | Trades/hour |
|---|---|---|---|---|
| ICP/BTC | 132 | 6,993 | 0.24 BTC | 5.5 |
| ICP/USDT | 15,726 | 1,189,216 | $2,727,915 | 655.2 |
ICP/USDT dominates with ~80% of all trades and ~80% of dollar volume. ICP/BTC is negligible by comparison.
2. Trade Rate and Burstiness
From the last 1000 historical trades of each pair:
| Pair | Period covered | Avg trades/hour | Avg gap between trades | Min gap (burst) | Max gap (quiet) |
|---|---|---|---|---|---|
| ICP/BTC | 5.0 days | 8.4 | 7 min 8s | 0ms | 1h 53min |
| ICP/USDT | 1.5 hours | 656.8 | 5.5s | 0ms | 2.8 min |
All pairs exhibit bursty behavior: the minimum observed gap is 0ms (multiple trades share the same millisecond timestamp when a single market order hits several resting orders), while quiet periods can stretch to nearly 2 hours on ICP/BTC.
3. Aggregation Ratio (aggTrades vs individual trades)
| Pair | Individual trades per aggTrade |
|---|---|
| ICP/BTC | 1.25 |
| ICP/USDT | 2.09 |
A ratio above 1 means a single incoming order frequently matches against multiple resting orders at different price levels. ICP/USDT’s ratio of 2.09 means the average market order eats through ~2 price levels. This is relevant for matching engine design: each incoming order triggers on average 1-2 fills, not just one.
4. Order Book Depth (resting orders)
| Pair | Bid levels | Ask levels | Total levels | Bid depth (ICP) | Ask depth (ICP) | Spread |
|---|---|---|---|---|---|---|
| ICP/BTC | 92 | 1,180 | 1,272 | 61,857 | 78,557 | 0.291% |
| ICP/USDT | 697 | 5,000+ | 5,697 | 924,901 | 1,289,920 | 0.043% |
ICP/USDT has the deepest book with at least 5,697 price levels of resting orders (the ask side hit the API’s 5,000-level limit, so the true count is higher). Both pairs are heavily ask-skewed, meaning more liquidity is offered on the sell side. ICP/USDT has a tight spread (0.043%), while BTC has a wider spread (0.291%), reflecting lower liquidity.
5. Peak Load Analysis (from 1000 hours of kline data)
| Pair | Avg trades/hour | Median | p95 | p99 | Peak hour | Peak trades/hour | Peak trades/sec |
|---|---|---|---|---|---|---|---|
| ICP/BTC | 38.5 | 17 | 134 | 297 | 2026-03-11 06:00 | 3,955 | 1.10 |
| ICP/USDT | 2,648 | 1,779 | 6,229 | 13,483 | 2026-03-11 06:00 | 143,932 | 39.98 |
Both pairs saw their peak at the exact same hour (2026-03-11 06:00 UTC), suggesting a market-wide event. The peak-to-average ratio is 103x (BTC) and 54x (USDT), showing extreme spikiness.
Interpretation for ICP OISY TRADE Design
Steady-state load is very manageable. During normal conditions, ICP/USDT – the busiest pair – sees about 2,600 trades/hour (~0.7/sec). Even aggregating both pairs, the matching engine would process fewer than 1 trade per second on average. This is well within the capacity of a single canister on ICP.
Peak load is the real design constraint. The busiest hour recorded saw ~144,000 trades on ICP/USDT alone – that is 40 trades/sec sustained over an hour, with likely much higher sub-minute bursts. At p99 the rate is ~3.7 trades/sec. Designing for the p99 case (~15,000 trades/hour, ~4 trades/sec) is sensible; handling the extreme peak (40/sec) would require either batching or accepting some queuing delay.
Order book size is moderate. The deepest book (ICP/USDT) has at least ~5,700 price levels. An ICP canister can easily hold this in-memory. Even 10x this depth (57,000 levels) would be manageable.
Fan-out is cheap in our design. Each incoming order generates ~2 fills on average (ICP/USDT). Since our OISY TRADE settles fills via internal balance updates (no ledger calls), the fan-out only adds in-canister bookkeeping cost, which is negligible. Ledger transfers are only needed at deposit/withdrawal time, not per fill.
ICP/USDT should be the primary benchmark. It represents ~80% of volume and depth. ICP/BTC is essentially a rounding error in comparison and could be deprioritized in capacity planning.
Specs
Specs live one-per-ticket as DEFI-XXXX-short-slug.md and follow the template. Each spec drives a spec-driven build loop (implementer → reviewer → ready) — see the project’s CLAUDE.md for the loop itself.
Template
id: title: tags: []
<title>
Motivation
Why are we doing this? What problem or risk does it address? Include the operational context — e.g. who uses each mechanism and when it applies.
Requirements
The verifiable behavioral contract, enumerated: R1: if X, then Y. This is the
canonical list — the Test plan and the per-PR acceptance criteria reference these R#
rather than restating behavior.
Non-goals
What is explicitly out of scope, and any accepted residual limitations (so reviewers know they were considered, not overlooked).
Design Decisions
The few foundational decisions and their rationale (“chose X, not Y”). Keep at altitude: the detailed “how” lives in Implementation; the “why not Z” lives in Discussed Alternatives. Cross-reference instead of repeating.
Implementation
The “how”. One subsection per area. Collapses to a line — or “N/A” — for small specs;
expands for large ones. Prefer file paths and symbol names over brittle file.rs:NN
line anchors.
Constraints
Pre-existing architectural constraints that shape the design.
<module / area>
Concrete types, signatures, events, enforcement points.
Test plan
Integration + unit tests; tag each to an R#. Note the verification commands.
Delivery / PR sequence
Stacked PRs, each independently mergeable / compilable / testable, with per-PR
acceptance criteria expressed as R# coverage. This is the input the spec-driven build
loop consumes — keep it explicit.
Discussed Alternatives
Approaches considered and why each was discarded, so reviewers don’t relitigate.
DEFI-2801 — User-Facing Error Taxonomy
id: DEFI-2801 title: Consolidate user-facing errors into forward-compatible, disposition-tagged variants tags: [errors, candid, api]
Consolidate user-facing errors into forward-compatible, disposition-tagged variants
Motivation
Today every fallible endpoint returns a bare, flat error variant (DepositError, WithdrawError,
AddLimitOrderError, …). A caller that wants to decide whether to retry must enumerate every
variant and hard-code the mapping itself; the DEX gives it no machine-readable signal. Some failures
are the caller’s to fix (UnsupportedToken, InvalidPrice, InsufficientFunds), some are transient
(OperationInProgress, a ledger that is TemporarilyUnavailable, a global TradingHalted), and some
are the DEX’s own fault (LedgerInternalError, an accounting/ledger inconsistency).
The consumers here are multiple independent clients, written in different languages, that are not upgraded in lockstep with the DEX. That rules out a Rust-only helper and makes Candid forward-compatibility a hard requirement: a client built against today’s interface must keep working — without traps and without silently mishandling — after the DEX adds a new error case tomorrow.
Each error becomes a small record: a disposition (what the caller should do) carried as a variant,
plus an advisory free-text message:
RequestError— caller-side; the request will not succeed as-is. Correct the input, satisfy a precondition (fund / approve), or stop. Do not auto-retry unchanged.TemporaryError— transient; retry the same call after a backoff.InternalError— DEX-side fault; surface to operators. Do not retry.
Candid skeleton (every user-facing error follows this shape; DepositError shown):
type DepositError = record {
kind : variant {
RequestError : opt variant {
AmountExceedsMaximum;
UnsupportedToken : record { token_id : TokenId };
InsufficientFunds : record { balance : nat };
InsufficientAllowance : record { allowance : nat };
};
TemporaryError : opt variant {
OperationInProgress;
LedgerTemporarilyUnavailable;
CallFailed : record { ledger : principal; method : text; reason : text };
};
InternalError : opt variant { LedgerError : record { reason : text } };
};
message : opt text; // advisory; branch on `kind` + leaf, never parse `message`
};
Arms an endpoint can’t produce are still declared, as an always-null opt variant {} (R1). See
Disposition membership for the per-endpoint leaves.
Two adjacent input-handling bugs are folded in because they live in the same surface: cancel_limit_order
maps a malformed order_id to OrderNotFound (conflating bad input with a missing order), and
get_my_orders traps on a malformed order_id (user input can panic the canister).
Requirements
- R1: Every user-facing error is a
record { kind : variant { … }; message : opt text }, wherekindis a disposition variant whose arms are drawn fromRequestError/TemporaryError/InternalError, each carryingopt variant { … }of its specific leaves. Every error declares all three arms; an arm it cannot currently produce carries an always-nullopt variant {}, reserving the slot so that leaves can be added to any arm later without breaking clients (and an arm is never added). Applies toadd_limit_order,cancel_limit_order,deposit,withdraw,get_my_orders,get_order_book_ticker,get_order_book_depth, and both the per-token and request-level errors ofget_balances/get_fee_balances. Admin endpoints are out of scope. - R2: The
kindarm is the contract (documented inoisy_trade.did):RequestError⇒ caller-side, do not auto-retry unchanged;TemporaryError⇒ retry after backoff;InternalError⇒ DEX-side fault, surface, do not retry. - R3:
messageis advisory human-readable text. Its purpose is the forward-compat case: when a client hits an error it cannot decode — a future leaf that decodes tonull, or a reserved (always-null) arm —messagestill gives operators/UI something actionable and signals that the client should be updated. The canister populates it for every error from the underlying leaf’sDisplay/to_string(); clients must not parse it — programmatic handling is onkindand the inner leaf only. - R4: Each leaf error is assigned to exactly one arm per Disposition membership.
- R5: Each arm’s payload is
opt variant. A client built against an older interface decodes an unknown future leaf asnull, while still reading the arm (kind) andmessage. (Verified by a decode test feeding a superset-leaf value into the shipped type — innernull, arm + message intact.) - R6:
CallFailedis aTemporaryError, not indeterminate — see D3. Holds only while the ledger calls are guaranteed-response (call_unbounded_wait); see Constraints. - R7:
cancel_limit_orderwith a malformedorder_idreturnsRequestError(InvalidOrderId), distinct fromRequestError(OrderNotFound). - R8:
get_my_ordersnever traps. Its signature changes from-> (vec UserOrder)to a result-> (variant { Ok : vec UserOrder; Err : GetMyOrdersError }). A malformedorder_idreturnsErr(RequestError(InvalidOrderId)); a well-formed but unknown id returnsOk([]); otherwiseOk(<orders>). (This is a breaking signature change to a query.) - R9: The hand-written
canister/oisy_trade.didmatches the generated interface (check_candid_interface_compatibilitypasses) and documents the R2 disposition contract and the R3messagerule.
Non-goals
- No fourth disposition arm. No
Indeterminate/Reconcile(see D3) and no split ofRequestErrorinto finer “fix the request / satisfy a precondition / stop” — those distinctions are carried by the inner leaf and don’t change the coarse client action. The three arms are the complete, frozen partition of caller actions. messageis not a wire contract. It exists for humans/operators; the spec forbids clients from matching on it (R3). It is not localized and its exact text may change.- Admin endpoints are out of scope (e.g.
add_trading_pair): controller-only, not part of the multi-language client surface this targets. - Deferred to a follow-up PR: reshaping
get_my_ordersto theget_balancespattern (outer result- per-item inner results, via a
ByIds : vec OrderIdselector). This PR keeps the single-Resultget_my_orders(R8); the batch/per-item form is future work.
- per-item inner results, via a
- No changes to internal/state-layer error types (
canister/src/state,order,lib,ledger— incl. the internalGetMyOrdersError/OrderIdParseError) beyond mapping them to the disposition-tagged public types at the boundary. - No change to which errors are logged. The
main.rsper-error logging arms encode log-worthiness, deliberately not the disposition. Left untouched. - Accepted residual limitations:
- A client hitting a future leaf sees inner
nulland loses the typed reason, but keeps the disposition arm and themessage— the intended trade. - All three arms are declared on every error from the start (R1), so an arm is never added; only a
hypothetical fourth disposition would be breaking. Accepted, because the three arms exhaustively
partition what a caller can do. (Adding leaves to any arm — including a currently-empty one — is
forward-compatible via the inner
opt.)
- A client hitting a future leaf sees inner
Design Decisions
- D1 —
record { kind : variant {…}; message : opt text }. The disposition is a typed, self- documenting variant (kind); the specific reason is an inneropt variant;messageis advisory text. This separates what grows (specific reasons → inneropt) from what’s stable (the small set of caller actions → bare outer arm), and the record gives field-level headroom (a futureretry_afteretc. is a non-breaking field add). - D2 —
InvalidOrderIdis bare; the leaf name carries the meaning. Its internalOrderIdParseErroris not a Candid type and carries no dynamic data, so there is nothing to put on the wire — the leaf name is self-describing. (messageis not a payload substitute; see R3 for its purpose.) - D3 — No
Indeterminate/reconcile arm;CallFailed⇒TemporaryError. Both ledger calls usecall_unbounded_wait(guaranteed response) and ICRCicrc1_transfer/icrc2_transfer_fromcommit atomically with their reply, so a reject implies the transfer did not commit — no side effect on either side — making the operation safe to retry. Nothing to reconcile. - D4 — Naming
RequestError / TemporaryError / InternalError. Symmetric-Errortriple, attribution-clear (your request / transient / the DEX’s internals), IC-native — noServerand noCaller/Calleejargon. - D5 — The
InsufficientFundsasymmetry. DepositInsufficientFunds(caller’s external wallet) ⇒RequestError; withdraw’s ledger-reportedInsufficientFunds(DEX accounting says it has the funds, the ledger disagrees) ⇒InternalError— a genuine invariant violation. - D6 — Malformed
order_id⇒ a bareInvalidOrderIdleaf underRequestError, oncancel_limit_orderandget_my_orders(get_order_statuswas removed in #133).get_my_ordersbecomes non-trapping by returning a result (R8). - D7 — Enforce the shape with a generic
Error<Request, Temporary, Internal>. Every public error is an instantiation of one generic struct ({ kind: ErrorKind<…>, message }), so the three-arm shape is structurally identical across the whole surface and can’t drift. Theimplbounds each leaf onstd::error::Errorand derivesmessagefrom the leaf’sto_string(), so the human text is produced uniformly rather than hand-set per call site.
Implementation
Constraints
- Both ledger calls are guaranteed-response:
icrc2_transfer_fromandicrc1_transferusecall_unbounded_wait(canister/src/ledger/mod.rs). D3/R6 depend on this.ledger/mod.rscarriesTODO(DEFI-2745): Consider switching to bounded_wait— if that lands, best-effort timeouts become genuinely indeterminate andCallFailedmust move out ofTemporaryErrorinto a reconcile-style disposition (reintroducing a fourth arm). dex_types::OrderId = String, parsed tocanister::order::OrderIdviaFromStr(OrderIdParseError, an internal non-Candid unit struct). Parse points:cancel_limit_order,get_my_orders.get_my_orderscurrently returns-> (vec UserOrder)and the entry point (canister/src/main.rs)panic!s on the internalGetMyOrdersError::InvalidOrderId(OrderIdParseError). R8 turns that into a returned, disposition-tagged public error.check_candid_interface_compatibility(canister/src/main.rs) pinsoisy_trade.didto the generated interface viaservice_equal; every interface change updatesoisy_trade.didby hand.- Candid’s forgiving
optdecode rule provides inner-leaf forward-compatibility; only clients generated from the updated.didbenefit.
dex_types (libs/types/src/lib.rs)
A single generic shape enforces the structure for every user-facing error (D7); each endpoint instantiates it with three leaf enums:
#![allow(unused)]
fn main() {
pub struct Error<Request, Temporary, Internal> {
pub kind: ErrorKind<Request, Temporary, Internal>,
pub message: Option<String>, // advisory; clients must not parse (R3)
}
pub enum ErrorKind<Request, Temporary, Internal> {
RequestError(Option<Request>),
TemporaryError(Option<Temporary>),
InternalError(Option<Internal>),
}
// Per-endpoint instantiation (DepositError shown; the rest follow):
pub type DepositError = Error<DepositRequestError, DepositTemporaryError, DepositInternalError>;
pub enum DepositRequestError { AmountExceedsMaximum, UnsupportedToken { token_id: TokenId },
InsufficientFunds { balance: Nat }, InsufficientAllowance { allowance: Nat } }
pub enum DepositTemporaryError { OperationInProgress, LedgerTemporarilyUnavailable,
CallFailed { ledger: Principal, method: String, reason: String } }
pub enum DepositInternalError { LedgerError { reason: String } }
}
- The
implblock boundsRequest: Error,Temporary: Error,Internal: Error(each leaf enum implementsstd::error::Error), and the constructors setmessagefrom the underlying leaf’sto_string()— e.g.Error::request(leaf)⇒{ kind: RequestError(Some(leaf)), message: Some(leaf.to_string()) }. - Arms an endpoint can’t produce are instantiated with an empty (uninhabited) leaf enum (e.g. the
shared
Nevertype, orenum AddLimitOrderInternalError {}), rendering as an always-nullopt variant {}(R1) — valid Candid (optof the empty variant; neverSomeon the wire). - Candid renders each
Error<…>instantiation structurally asrecord { kind : variant { RequestError : opt variant {…}; TemporaryError : opt variant {…}; InternalError : opt variant {…} }; message : opt text }.
The internal→public conversions (canister/src/state, lib, ledger) and construction sites build
these via the constructors; internal flat enums are untouched (Non-goals).
The order-id fix adds a public GetMyOrdersError = Error<GetMyOrdersRequestError, Never, Never> (the
internal GetMyOrdersError in canister/src/lib.rs stays internal): a single RequestError arm with a
bare InvalidOrderId leaf and empty Temporary/Internal arms. The internal
InvalidOrderId(OrderIdParseError) maps to the bare public InvalidOrderId leaf.
Disposition membership
All errors declare all three arms (R1); a cell marked (none) is an opt variant {} — the opt of an
uninhabited variant (the Never leaf) — so it is always null on the wire and reserves the slot for
future leaves without breaking clients. (variant {} is valid Candid; the canister’s
check_candid_interface_compatibility test confirms the generated interface uses it.)
| Error | RequestError | TemporaryError | InternalError |
|---|---|---|---|
| DepositError | AmountExceedsMaximum, UnsupportedToken, InsufficientFunds, InsufficientAllowance | OperationInProgress, LedgerTemporarilyUnavailable, CallFailed | LedgerError, CandidDecodeFailed3 |
| WithdrawError | AmountExceedsMaximum, AmountTooSmall, UnsupportedToken, InsufficientBalance | OperationInProgress, LedgerTemporarilyUnavailable, CallFailed, LedgerFeeChanged4 | LedgerError, LedgerInsufficientFunds1, CandidDecodeFailed3 |
| AddLimitOrderError | AmountExceedsMaximum, UnknownTradingPair, InvalidPrice, InvalidQuantity, InsufficientBalance, InvalidNotional | TradingHalted2 | (none) |
| CancelLimitOrderError | InvalidOrderId, OrderNotFound, NotOrderOwner, OrderAlreadyFilled, OrderAlreadyCanceled | (none) | (none) |
| GetMyOrdersError (new public) | InvalidOrderId | (none) | (none) |
| GetOrderBookTickerError | UnknownTradingPair | (none) | (none) |
| GetOrderBookDepthError | UnknownTradingPair, LimitTooLarge | (none) | (none) |
| GetBalancesError | TokenNotSupported | (none) | (none) |
| GetBalancesRequestError | FilterTooLarge | (none) | (none) |
1 withdraw’s ledger-reported InsufficientFunds (D5). 2 TradingHalted (DEFI-2849) — a global halt
is intentional transient unavailability (“retry when trading resumes”, like a ledger
TemporarilyUnavailable); InvalidNotional (DEFI-2850) is caller-side input → RequestError.
TradingHalted’s placement is a judgment call worth a second look.
3 CandidDecodeFailed — the ledger replied but the response failed to Candid-decode (a DEX-side
type/version mismatch); since the call may have executed, it’s an InternalError (surface, don’t blindly
retry), distinct from CallFailed (the call itself failed with no/rejected response → transient).
4 LedgerFeeChanged — the ledger fee changed between fetch and transfer so nothing was applied;
rare and safe to retry → TemporaryError.
Canister logic (canister/src/lib.rs, main.rs)
cancel_limit_order: mapOrderIdparse failure to a bareRequestError(InvalidOrderId)(wasOrderNotFound).get_my_orders: returnResult<Vec<UserOrder>, GetMyOrdersError>(public); themain.rsentry point returnsErr(RequestError(InvalidOrderId))instead ofpanic!; well-formed unknown id ⇒Ok(vec![]).
Candid (canister/oisy_trade.did)
- Top-of-file comment documenting the R2 disposition contract and the R3
messagerule. - Each error renders as
record { kind : variant { RequestError : opt variant {…}; TemporaryError : opt variant {…}; InternalError : opt variant {…} }; message : opt text }, declaring all three arms — uninhabited ones as an always-nullopt variant {}(R1). get_my_orderssignature becomes a result; newGetMyOrdersError; new bareInvalidOrderIdleaf onCancelLimitOrderError.
Test plan
Unit (libs/types/src/tests.rs):
- For every leaf, assert the internal→public conversion places it under the membership-table arm and
sets a non-empty
message— parameterized. (R2, R3, R4) - Forward-compat decode test: encode an error whose inner arm has an extra leaf, decode into the
shipped type; assert inner
nullwhilekindandmessagedecode intact. (R5)
Unit (canister/src/.../tests.rs):
cancel_limit_ordermalformed id ⇒RequestError(InvalidOrderId), notOrderNotFound. (R7)get_my_ordersmalformed id ⇒Err(RequestError(InvalidOrderId)), no panic; unknown id ⇒Ok([]). (R8)
Integration (dex_int_tests): update existing assertions to the { kind; message } shape, asserting the
arm + inner leaf for at least one case per endpoint; new cancel + get_my_orders malformed-id cases over
the boundary, asserting no trap. (R1, R7, R8)
Interface: check_candid_interface_compatibility passes against the updated oisy_trade.did. (R9)
Commands: cargo test --workspace, cargo fmt --all -- --check, just lint.
Delivery / PR sequence
Stacked, bottom-to-top; each compiles and tests independently. PR2 and PR3 each depend only on PR1.
- PR1 — Disposition-tagged
{ kind; message }errors for the four update-endpoint errors. Shape + leaf enums forAddLimitOrderError,CancelLimitOrderError,DepositError,WithdrawError; map internal→public at the boundary (incl.message);oisy_trade.did+ contract doc block; tests. Accepts: R1 (these four), R2, R3, R4, R5, R9. - PR2 — Extend to query errors. Same shape for
GetOrderBookTickerError,GetOrderBookDepthError,GetBalancesError,GetBalancesRequestError(the latter two cover bothget_balancesandget_fee_balances);oisy_trade.did; tests. Accepts: R1 (remainder), R4 (remainder), R9. - PR3 — Stop conflating / trapping on malformed order IDs. Bare
InvalidOrderIdonCancelLimitOrderError; new publicGetMyOrdersError;get_my_ordersreturns a result (nopanic!);oisy_trade.did; tests. Accepts: R7, R8, R9.
Discussed Alternatives
- Keep bare flat enums, document retryability (Binance/Coinbase norm). Rejected: no machine-readable signal; every multi-language client re-derives the map.
- Rust
is_retryable()/category()helper, no wire change. Rejected: serves only Rust callers; ours are multi-language and uncoordinated. - Numeric
record { code : nat16; detail : opt E }envelope (HTTP-status-class codes). Seriously considered; rejected for typed disposition tags — clearer for multi-language clients (no code-range → meaning doc dependency), avoids the “409 is a 4xx but retryable” tension. Note the chosen shape isrecord { kind : variant {…}; message }, i.e. a typed disposition, not a numeric code. - Closed flat
variant { Transient; Permanent }category, or a closed{ category; error }wrapper. Rejected: can’t gain cases compatibly and the detail loses forward-compatibility; the inneropt variantsolves the latter. - A fourth
Indeterminate/Reconcilearm forCallFailed. Rejected once both ledger calls were confirmed guaranteed-response (D3). Revisit if DEFI-2745 switches to bounded-wait. - Finer caller-side arms / verb arms (
Fix/Resolve/Stop,BadRequest/UnprocessableRequest). Rejected: they split on “what’s wrong,” not “what to do”; the action is the same and the distinction belongs in the inner leaf. - Omit the
messagefield (the spec’s original stance). Reversed on review: when an old client hits a future leaf,detailisnullandkindalone is thin for diagnostics — an advisorymessageaids logs/UI and flags “update your client,” at the cost of a record wrapper (which also buys field-level forward-compat). Clients still match only onkind/leaf (R3). - Carry
OrderIdParseErrorinInvalidOrderId. Rejected: it’s an internal, non-Candid unit struct with no dynamic data; the bare leaf name is self-describing, so nothing is lost on the wire.
DEFI-2849 — Circuit Breaker
id: DEFI-2849 title: MVP Circuit-Breaker Controls tags: [circuit-breaker, permissions, security, trading-halt]
MVP Circuit-Breaker Controls
Motivation
OISY TRADE has no way to stop trading when something goes wrong. We want two controller-gated soft halts so an operator can contain an incident without tearing down state and without trapping users’ funds:
- Global trading halt — when the matching engine itself is suspect (a matching or settlement bug), stop all new orders and all matching.
- Per-pair halt — when one pair’s ledger is compromised or behaving suspiciously, stop new orders and matching on that pair only, leaving every other pair trading.
These are soft halts: state is always preserved, and users can always exit (cancel resting orders and withdraw available balance). Both are invoked only by the canister controller.
Requirements
- R1 — Global halt blocks entry. If trading is halted, then a new
add_limit_orderis rejected withTradingHaltedand the matching engine starts no new matching and produces no new fills (in-flight settling still drains — R2). - R2 — Halt preserves the exit. Under global halt,
cancel_limit_orderand withdrawal of available balance still succeed;resume_tradingre-enables orders and matching. - R3 — Per-pair halt is isolated. If pair A is halted, then orders on A are rejected
with
TradingHaltedand A’s resting orders do not fill; orders on every other pair succeed and match; a cancel on A still succeeds. A per-pair halt is requested by passing a pair list tohalt_trading/resume_trading; targeting an unregistered pair traps. - R4 — Controller-only. Every admin endpoint rejects a non-controller caller with
NotController. - R5 — Durable across upgrade and replay. All control state survives a canister upgrade (snapshot) and event-log replay, and old-format snapshots (written before this change) still load, decoding to “no controls active”.
- R6 — Idempotent and auditable. Halting an already-halted target is a no-op success that still emits an event for the audit trail.
- R7 — Reconcile-before-record cannot be skipped (compile-time). A
deposit/withdrawcannot be recorded without first turning itsPreAsyncPermitinto aPostAsyncPermitvia the post-awaitreconcilestep; omitting it fails to compile. This is a compile-time gate only —reconciledoes not re-check permissions post-await.
Non-goals
Delistedpair state. MVP has two per-pair states only — Active and Halted. A delist/teardown state is out of scope.- Per-account freeze. Descoped after leadership review; freezing a principal’s deposits/withdrawals/orders will not be built under DEFI-2849. The async-permit machinery is retained only for its compile-time reconcile-before-record obligation.
- Hard halts. No mechanism tears down or discards state; every control here is reversible and state-preserving.
- Binding a sync permit to its payload. The async path is compile-enforced
(reconcile-before-record), but nothing stops in-module code from minting a
Permit::Syncfor the wrong payload (e.g. recording a deposit as sync). Closing that would need per-event smart constructors — deliberately out of scope; it’s a deliberate-misuse case caught by review, not the forgettable “forgot to reconcile” mistake the types do close.
Design Decisions
Two decisions are foundational; the rest of the design is in service of them.
-
Gate every state change at a single choke point: event recording. Every state mutation is already an append-only audit event, so the one place to enforce “is this allowed?” is at the moment the event is recorded — not scattered across call sites. That gives exactly one site to get right, and nothing can mutate state without having passed a check. Enforcement therefore lives on the live recording path, above the shared apply path: the apply path is also the replay path and must stay unconditional, so replay reproduces state regardless of the permissions in force at replay time.
-
Synchronous and asynchronous admission are structurally different. A synchronous action (e.g. recording a fill) is checked once, at the recording site. An asynchronous action (deposit, withdraw) crosses an
awaitto touch the ledger — the “outside world” — and the external effect commits across thatawait, so its admission cannot be a single synchronous check at the recording site. Instead it is checked pre-await and the pre-await admission must be carried across theawaitand reconciled post-await before the event can be recorded. This obligation is enforced at the type level rather than by convention — see Implementation. (No control in scope denies post-await; the reconcile step is therefore observational, but the obligation to perform it before recording is compile-enforced.)
Supporting decisions:
- Per-pair status is keyed by
OrderBookId(a set of halted books), not a field onTradingPair.TradingPairis aBiBTreeMapkey; mutating a status field on a map key is a bug. The set matches how orders and the matching loop already resolvepair -> book_id.
(Why not a UserOpGuard bolt-on, a process_async function, a single SyncOp/AsyncOp
enum, or a persisted clean: bool — see Discussed Alternatives.)
Implementation
Constraints (architecture that shapes everything)
The canister is event-sourced. state::audit::process_event
(canister/src/state/audit/mod.rs) applies a mutation via apply_state_transition
and appends the event to the stable log via storage::record_event;
state::audit::record_event appends without applying (used by the async withdraw path,
where the debit is applied directly before the await). The invariant is replay
equivalence: replaying the log through apply_state_transition reproduces heap state
exactly. Separately, the heap is snapshotted at pre_upgrade and restored at
post_upgrade (canister/src/state/snapshot/mod.rs).
Two consequences the whole design relies on:
replay_eventscallsapply_state_transitiondirectly, bypassingprocess_event/record_event. So anything added toprocess_event/record_event(including thePermitparameter) is live-path only and never constrains replay.- New persisted state must be (a) added to
State, (b) written by anapply_state_transitionarm so replay reproduces it, and (c) added toStateSnapshotso upgrades preserve it.Statemutators stay unconditional — admission is checked before the event is emitted, never re-checked on replay.
Permissions layer
New module canister/src/state/permissions/ (mod.rs + tests.rs). A Permissions
struct owns both controls and all gating logic:
#![allow(unused)]
fn main() {
pub struct Permissions {
trading_halted: bool,
halted_pairs: BTreeSet<OrderBookId>, // Active = absent, Halted = present
}
}
struct State gains a permissions: Permissions field, default-empty in State::new;
from_state destructures State exhaustively, which forces the snapshot wiring.
Permit tokens — produced only by Permissions (SyncPermit’s private field makes it
non-constructible outside this module, so holding any permit is proof a check ran):
#![allow(unused)]
fn main() {
pub struct SyncPermit(()); // sync admission proof (non-forgeable)
#[must_use] pub struct PreAsyncPermit(());
pub struct PostAsyncPermit(()); // only via PreAsyncPermit::reconcile
pub enum Permit { Sync(SyncPermit), Async(PostAsyncPermit) }
// From<SyncPermit> / From<PostAsyncPermit> for Permit, so call sites read `permit.into()`.
pub enum UnauthorizedError { TradingHalted, NotController }
}
PreAsyncPermit::reconcile(self) -> PostAsyncPermit consumes the pre-permit and yields
the post-await proof. It is observational only — the ledger effect already committed,
so it never denies; its sole purpose is to carry the compile-time reconcile-before-record
obligation across the await.
One permit_* per EventType, so the policy for each event is exhaustive, named, and
greppable:
- Gated:
permit_trading(caller, book)(global-or-pair halt →TradingHalted),permit_matching(book)(global halt or that book’s pair halt →TradingHalted— per-book, so the matching loop gates each book through this one method rather than a separateis_pair_haltedfilter),permit_deposit(caller)/permit_withdraw(caller)(returnPreAsyncPermit). A globally- or per-pair-halted pair both surface the singleTradingHalted— there is no distinctPairHalted. - Infallible — ungated in the permission layer, but not all truly unguarded:
permit_cancelandpermit_settlingare genuinely ungated;permit_adminis the permit for the halt/upgrade events and is controller/lifecycle-gated at the endpoint;permit_add_trading_pairis controller-gated at the endpoint. These permits return their permit value directly — documenting “not gated here” at a named, greppable site (it does not mean “unguarded”).permit_settlingis intentionally book-less and never gated — settling must always drain (even under halt) so already-matched fills don’t strand (R2); a per-book settling gate would reintroduce that stranding. - Predicate: per-pair halt is enforced via
permit_matching(book), not a standalone matching-loop filter.
NotController is not produced by permit_* (that axis needs runtime.is_controller,
which pure state can’t see) — it’s returned by the endpoint guard, but lives in the same
enum because both axes mean “you may not do this”.
audit::process_event and audit::record_event gain a permit: Permit parameter
(live-only, never touches replay). To persist a deposit/withdraw you need
Permit::Async(PostAsyncPermit), and a PostAsyncPermit exists only via
reconcile — so skipping the post-await reconcile step won’t compile (R7).
Bound on R7: the types force reconcile-before-record for the async path, but a
Permit::Sync is still constructible in-module for any payload (e.g. a deposit could
mint a permit_settling() token and record itself as sync). SyncPermit’s private field
only prevents forging a permit outside this module; it does not bind a token to a
specific payload. That residual is accepted — see Non-goals.
Events
enum EventType (canister/src/state/event/mod.rs) — append minicbor indices, never
reuse:
#![allow(unused)]
fn main() {
#[n(9)] SetHalt(#[n(0)] SetHaltEvent), // { book_ids: Option<Vec<OrderBookId>>, halted }
}
SetHaltEvent carries the optional book-id list (the resolved pair filter) and the new
halted flag. Replay reproduces the endpoint semantics exactly: book_ids = None sets the
global flag, and on resume (halted = false) additionally clears the whole per-pair set;
book_ids = Some(ids) adds/removes those books from the set. The
apply_state_transition arm mutates state.permissions (persistence-independent — no
stable-memory writes).
Snapshot
StateSnapshot (canister/src/state/snapshot/mod.rs) gains one backward-compatible
field after fee_pool:
#![allow(unused)]
fn main() {
#[n(10)] pub permissions: Option<PermissionsSnapshot>, // { trading_halted, halted_pairs }
}
A small PermissionsSnapshot entry struct. from_state encodes None when all-default
(per the fee_pool idiom); into_state does unwrap_or_default() and rebuilds the sets.
Absent field decodes to default (R5).
Enforcement points
-
add_limit_order— afterassert_caller_is_allowed, validate the order (unknown pair →UnknownTradingPair; tick/lot/notional → their errors), then the halt gatepermit_trading(caller, book)?. MapUnauthorizedError::TradingHaltedonto the internal + publicAddLimitOrderError(a halted pair, global or per-pair, surfacesTradingHalted). TheSyncPermitflows into the existingprocess_event(AddLimitOrder, …). -
Matching (
canister/src/execute/mod.rs) —run_oncealways drains in-flight settling first (drain_settlingbefore any matching), then matches only the books for whichpermit_matching(book)isOk. A book is gated by that one call: it returnsErr(TradingHalted)under global halt (every book) and for a per-pair-halted book — so there is no separateis_pair_haltedloop filter. Draining-first is required: a halt can land whilepending_settling_eventsare queued (a prior chunk hit the instruction budget), and those events apply the balance effects of already-matched fills — skipping them would strand a counterparty’s proceeds for the halt’s duration, violating “users can always exit” (R2). The “work remaining?” predicate (has_matchable_pending_orders) counts only books with pending orders andpermit_matching(book).is_ok(), so under global or per-pair haltrun_oncereschedules only for leftover settling (MoreWorkiffhas_pending_settling_events(), elseComplete) and never busy-spins on halted books’ pending orders.resume_trading(global or per-pair) re-arms matching from the endpoint. -
deposit/withdraw(both async) — identical shape:#![allow(unused)] fn main() { let pre = state::with_state(|s| s.permissions().permit_<op>(caller))?; // pre-await admission // ... existing async ledger work (withdraw debits directly before its await) ... let post = pre.reconcile(); // post-await reconcile state::with_state_mut(|s| process_event(s, Deposit, post.into(), runtime)); // deposit // or withdraw success branch: record_event(Withdraw, post.into(), runtime); }The error path (
await?fails) drops the un-reconciledPreAsyncPermit: no record, no permit, no false trap. -
cancel_limit_order— no change; cancels stay open under every control. Covered by tests, not code. -
Other recorders (
add_trading_pair, matching/settling,Upgrade) pass the matching infallible permit (permit_add_trading_pair/permit_settling/permit_admin). The low-levelInitappend inlifecycle.rsis unchanged.
Admin endpoints
Two controller-gated endpoints. Each: a business fn in canister/src/lib.rs guarded by
if !runtime.is_controller(&runtime.msg_caller()) { return Err(...NotController) }, which
builds the event and records it with permit_admin(); a thin #[ic_cdk::update] wrapper
in canister/src/main.rs; and a declaration in canister/oisy_trade.did.
| Endpoint | Arg | Event |
|---|---|---|
halt_trading | (Option<Vec<TradingPair>>) | SetHalt { book_ids, halted: true } |
resume_trading | (Option<Vec<TradingPair>>) | SetHalt { book_ids, halted: false } |
halt_trading / resume_trading take an optional pair filter and keep returning
Result<(), UnauthorizedError> (UnauthorizedError { NotController } only):
halt_trading(None)sets the global flag;halt_trading(Some(pairs))adds those pairs to the halted set.resume_trading(None)clears the global flag and empties the entire per-pair set in one call;resume_trading(Some(pairs))removes those pairs from the set.- A pair is halted iff
global_flag || pair ∈ set; this also drivesget_trading_pairs’TradingStatus::Halted. Some(pairs)is validated up front: any unregistered pair traps (ic_cdk::trap) before anything is recorded — no new error variant.Some(pairs)carrying more thanMAX_HALT_BOOKS(100) entries traps (ic_cdk::trap) before anything is recorded, bounding the size of theSetHaltaudit event.None(global) is unaffected.
Idempotent calls are no-op successes that still emit the event (R6). oisy_trade.did
updates the two endpoints’ signatures, the unified SetHaltEvent, and the
AddLimitOrderError::TradingHalted variant. The repo’s candid equality check must pass.
Test plan
Integration (integration_tests/tests/tests.rs, PocketIC):
- Global halt (R1, R2): under halt,
add_limit_order→TradingHalted; a resting order placed pre-halt still cancels; a withdrawal of available balance succeeds;resume_tradingre-enables orders. - Per-pair halt (R3): with two pairs,
halt_trading(Some([A]))→ orders on A →TradingHalted, orders on B succeed and match, a cancel on A succeeds, andget_trading_pairsreports AHalted/ BTrading. A controller targeting an unregistered pair traps;resume_trading(None)clears the per-pair halt too. - Per-pair halt stops matching (R3): resting crossable orders on a halted pair don’t
fill after the timer ticks;
resume_trading(Some([A]))lets them fill; the other pair is never affected. - Authorization (R4): every admin endpoint rejects a non-controller with
NotController.
Unit:
state/permissions/tests.rs:permit_trading/permit_matchingreturn the rightOk/UnauthorizedError; the infallible permits return their permit unconditionally.state/audit/tests.rs: each newEventTypearm applies the expected mutation (R5 replay).state/snapshot/tests.rs:from_state -> into_stateround-trips both controls; an old-format snapshot (field absent) decodes to defaults (R5 upgrade).execute/tests.rs:run_onceis a no-op under global halt; halted books are skipped while others match and the executor settles rather than busy-spinning.- Worst-case CBOR roundtrip proptest (
test_fixtures) fuzzes the new events.
Verification:
cargo fmt --all
just lint
cargo test -p oisy_trade_canister
cargo test -p oisy_trade_int_tests
# + the repo's candid equality check (see justfile / CI)
Delivery / PR sequence
Stacked, ordered by increasing complexity; each PR is independently mergeable,
compilable, and testable, and rebases on its parent. The async-permit types land in
PR 1 as part of the permit vocabulary (so the sync/async distinction is visible from the
scaffolding). Each mechanism PR carries its own
section in the runbook (docs/runbook-circuit-breakers.md) so docs stay in lockstep;
each section states who may invoke the control (the canister controller) and when
to use it (matching-engine bug → global halt; compromised/suspect ledger → per-pair
halt).
- PR 1 — Permission scaffolding (behavior-neutral). Empty
Permissions+Statefield, snapshotted (backward-compatible); the full permit vocabulary —UnauthorizedError,SyncPermit, the async types (PreAsyncPermit/PostAsyncPermit/reconcile), andPermit { Sync, Async }; one infalliblepermit_*perEventType, withpermit_matching(book)taking the book andpermit_deposit/permit_withdrawreturningPreAsyncPermit(reconciled toPermit::Asyncat the deposit/withdraw recorder sites); thread thePermitparameter throughprocess_event/record_eventand every call site. Acceptance: no behavioral change (all existing tests pass);oisy_trade.didunchanged; snapshot round-trips empty + old-format decodes to default; every recorder call site supplies a permit; deposit/withdraw record viaPermit::Async(the post-await reconcile is structurally present even though it never denies). - PR 2 — Global trading halt.
trading_halted; the unifiedSetHaltevent + arm + snapshot;permit_trading/permit_matching(book)gate the global halt;run_oncedrains settling then matches onlypermit_matching(book).is_ok()books;halt_trading(None)/resume_trading(None)+ candid;AddLimitOrderError::TradingHalted. Acceptance: R1, R2 (incl. settling still drains under halt), R4 (these two endpoints), R5 for the halt flag. - PR 3 — Per-pair halt.
halted_pairs; extend the unifiedSetHaltevent with the optionalbook_idsfilter + arm + snapshot;permit_matching(book)andpermit_tradingextended with the per-book pair check (no separate matching-loop filter); the existinghalt_trading/resume_tradingendpoints gain theOption<Vec<TradingPair>>filter (per-pair halt reusesTradingHalted; an unregistered pair traps;resume_trading(None)also clears the whole per-pair set). Acceptance: R3, R4 (the halt endpoints, incl. trap-on-unknown-pair), R5 for per-pair state, and the executor does not busy-spin on a halted-but-non-empty book.
Discussed Alternatives
- An
Authorityguard parameter on theStatemutators. Rejected: the mutators are the replay path, so replay would re-acquire the guard — which diverges for async ops (a permission change landing during anawaitis logged before the deposit/withdraw event, so a re-check at the event’s log position would deny an op that legitimately committed). Admission must live above the shared apply path. - A single recording function with an
Unguarded/Systemauthority variant. Superseded bypermit_*-per-event: the infallible permits document “intentionally ungated” at a named site and remove the need for a freely-constructible catch-all. - A dedicated
process_asyncconsumingPostAsyncPermit. Superseded: putting thePermitparameter on the existingprocess_event/record_eventsubsumes it and keeps a single recording API. - A single
SyncOp/AsyncOpenum guard. Rejected: one enum shares a method surface and cannot express “aPreAsyncPermitmust become aPostAsyncPermit”. Distinct types are what make the post-await reconcile compile-enforced.
DEFI-2850 — Min/Max Notional
id: DEFI-2850 title: Min/max notional filter per trading pair tags: [order-book, trading-pair, validation]
Min/max notional filter per trading pair
Motivation
A trading pair today constrains only the granularity of price (tick_size) and quantity
(lot_size). It places no constraint on an order’s value — its notional, i.e. the quote
amount that settles, price × quantity / 10^base_decimals.
Tick and lot are orthogonal to notional: they bound increments, not the product’s worth. Under
realistic Binance-equivalent parameters for ckETH/ckUSDC (tick_size = 10_000,
lot_size = 10^14, base decimals = 18) the smallest order that passes tick/lot is
1 tick × 1 lot, which settles to 10_000 × 10^14 / 10^18 = 1 quote unit = 0.000001 ckUSDC
≈ $10⁻⁶ — pure dust, worth far less than the ICRC fee required to settle it. A canister that accepts such
orders bleeds cycles processing trades that can never cover their own settlement cost. There is
also no upper guard against fat-finger orders.
This adds two filters, modeled on Binance’s NOTIONAL filter:
min_notional(required): rejects dust, and serves as the natural place to keep an order worth at least the cost of settling it (the ICRC transfer-fee floor — set manually for now).max_notional(optional): rejects fat-finger orders and caps single-order impact.
These apply at limit-order placement. The canister has no market orders, so the
average-price / applyMinToMarket machinery from Binance does not apply.
Requirements
- R1: An order whose notional
< min_notionalis rejected withInvalidNotional. - R2: An order whose notional
> max_notional(whenmax_notionalis set) is rejected withInvalidNotional. - R3: An order whose notional
== min_notionalexactly is accepted (boundary is inclusive). - R4: Pair creation rejects
min_notional == 0. - R5: Pair creation rejects
max_notional < min_notional(whenmax_notionalis set). - R6: tick, lot,
min_notional, andmax_notionalare enforced independently — an order may fail any one of them, and none is implied by another. No relationship is enforced betweenmin_notionalandtick_size × lot_size(amin_notionallarger than one tick·lot is the normal, intended case). - R7:
min_notionalandmax_notionalround-trip through the state snapshot.
Non-goals
- Per-token transfer-fee-aware auto-floor (enforcing
min_notional ≥ icrc_transfer_feeat pair creation). Deferred until the ledger fee is queryable; for now an operator setsmin_notionalmanually with the transfer fee in mind. - Market orders. None exist in the canister; the Binance
avgPriceMins/applyMinToMarket/applyMaxToMarketbehavior is therefore irrelevant here. - Dynamic min-notional based on volatility or an oracle price.
Design Decisions
Notional is the scaled quote amount, not the raw product. Notional is defined as
price × quantity / 10^base_decimals — exactly the quote_amount that settlement already
computes via Price::checked_mul_quantity_scaled. This is the only definition under which a
bound like “min_notional = 5 USDC” is meaningful; the raw price × quantity is off by a factor
of 10^base_decimals and has no quote-token interpretation. The bound type is therefore
Quantity (the 256-bit (high, low) type), the same type quote_amount returns.
The bounds are expressed and stored in quote-token smallest units — the same unit as
quote_amount, not whole tokens. “min_notional = 5 USDC” for 6-decimal ckUSDC therefore means
the value 5 × 10^6 = 5_000_000. Operators and the public API (Nat) use this unit directly.
The check lives in State::validate_limit_order, not OrderBook::validate_order. Computing
the scaled notional needs base_scale = 10^base_decimals, which is derived from token metadata
held by State and is not available inside OrderBook. This is already why the existing
AmountExceedsMaximum overflow guard sits in State::validate_limit_order rather than in the
order book. The notional bounds reuse the amount that guard already computes.
OrderBook::validate_order stays tick/lot-only; the bound values are still stored on
OrderBook alongside tick_size/lot_size, since they are immutable per-pair configuration.
max_notional is optional. Not every pair needs a cap; None means no upper bound.
Worked example: ckETH/ckUSDC
Concrete end-to-end walkthrough with no fees (maker_fee_bps = taker_fee_bps = 0), to
isolate the notional arithmetic. Token decimals: ckETH (base) = 18, ckUSDC (quote) = 6,
so base_scale = 10^18. Throughout, notional = price × quantity / base_scale, in ckUSDC base
units.
1. Create the pair
AddTradingPairRequest:
| Field | Value | Meaning |
|---|---|---|
tick_size | 10_000 | $0.01 / ETH (= 0.01 × 10^6 ckUSDC base units) |
lot_size | 100_000_000_000_000 | 0.0001 ETH (= 0.0001 × 10^18 ckETH base units) |
min_notional | 5_000_000 | 5 ckUSDC (= 5 × 10^6) |
max_notional | Some(9_000_000_000_000) | 9,000,000 ckUSDC (= 9_000_000 × 10^6) |
Pair-creation checks pass:
min_notional > 0✓ (R4)max_notional ≥ min_notional✓ —9_000_000_000_000 ≥ 5_000_000(R5)- tick·lot exactness (pre-existing invariant):
tick_size × lot_size = 10^4 × 10^14 = 10^18, which is exactlybase_scale, so the remainder is 0 ✓
2. Place an accepted order — buy 0.1 ETH at $2,500/ETH
| Quantity | Value | Check |
|---|---|---|
price | 2_500_000_000 (2_500 × 10^6) | 2_500_000_000 / 10_000 = 250_000 → on tick ✓ |
quantity | 100_000_000_000_000_000 (0.1 × 10^18) | 10^17 / 10^14 = 1_000 → on lot ✓ |
notional | 2_500_000_000 × 10^17 / 10^18 = 250_000_000 | = 250 ckUSDC |
Notional bounds:
250_000_000 ≥ min_notional (5_000_000)✓ (R1 not triggered, R3 boundary not hit)250_000_000 ≤ max_notional (9_000_000_000_000)✓ (R2 not triggered)
The order is accepted. With no fees, a buy reserves exactly the notional: 250 ckUSDC
(250_000_000 base units).
3. Rejected: dust order — 1 tick × 1 lot
price = 10_000, quantity = 10^14. Passes tick and lot trivially, but
notional = 10_000 × 10^14 / 10^18 = 1 base unit = 0.000001 ckUSDC — far below
min_notional. Rejected with InvalidNotional (R1). This is the dust the filter exists to
stop: a settlement worth a millionth of a cent.
4. Rejected: fat-finger — buy 5,000 ETH at $2,500/ETH
price = 2_500_000_000, quantity = 5_000 × 10^18.
notional = 2_500_000_000 × (5_000 × 10^18) / 10^18 = 12_500_000_000_000 = 12,500,000 ckUSDC,
above max_notional (9,000,000 ckUSDC). Rejected with InvalidNotional (R2).
Implementation
Bound types: min_notional: Quantity, max_notional: Option<Quantity>. Public API surfaces
them as Nat / Option<Nat>.
Constraints
base_scale(=10^base_decimals) is only available at theStatelayer.- Trading-pair configuration is event-sourced:
add_trading_pairbuilds anAddTradingPairEvent, the audit handler applies it viarecord_trading_pair, and snapshots persist the resultingOrderBook. New configuration must flow through every link in that chain.
Public types — libs/types/src/lib.rs
AddTradingPairRequest: addmin_notional: Natandmax_notional: Option<Nat>.AddTradingPairError: add a singleInvalidNotional { min_notional: Nat, max_notional: Option<Nat> }variant covering both pair-creation rejections (R4, R5) — it echoes the offending bounds back to the caller.AddLimitOrderError: add a singleInvalidNotional { notional: Nat, min: Nat, max: Option<Nat> }variant covering both R1 and R2. The caller sees the order’s notional next to the configured bounds and can tell which side it violated; this avoids two near-identical variants. Candid:InvalidNotional : record { notional : nat; min : nat; max : opt nat }.TradingPairInfo: surfacemin_notionalandmax_notionalin the query response.
Pair creation — canister/src/lib.rs::add_trading_pair
Parse min_notional / max_notional from Nat into Quantity; reject min_notional == 0
(R4) and max_notional < min_notional (R5); carry both into AddTradingPairEvent.
Event plumbing — canister/src/state/event.rs, canister/src/state/audit/mod.rs
Add the two fields to AddTradingPairEvent; destructure and forward them through the
AddTradingPair handler into record_trading_pair.
Order book — canister/src/order/book.rs
OrderBook gains min_notional / max_notional fields, set via OrderBook::new and exposed
through getters. validate_order is unchanged (tick/lot only).
State — canister/src/state/mod.rs
record_trading_pair: accept the two bounds and forward toOrderBook::new.validate_limit_order: after computingamount(the scaled notional), reject with the new internalAddLimitOrderError::InvalidNotionalwhenamount < min_notional(R1) or, withmax_notionalset, whenamount > max_notional(R2);==passes (R3). Extend the internalAddLimitOrderErrorenum and itsFrommapping tooisy_trade_types::AddLimitOrderError.
Persistence — canister/src/state/snapshot/mod.rs
OrderBookSnapshot persists and restores the two bounds (R7).
Interface & docs
canister/oisy_trade.did: updateAddTradingPairRequest,AddTradingPairError,AddLimitOrderError, andTradingPairInfo.docs/src/development/design.md: document the two filters in the pair-parameters section alongside tick/lot.
Test plan
Helpers in canister/src/test_fixtures/mod.rs: add MIN_NOTIONAL / MAX_NOTIONAL constants
and thread them through trading_pair_request() and init_state_with_order_book().
canister/src/state/tests.rs- R1: notional below
min_notional→InvalidNotional. - R2: notional above
max_notional→InvalidNotional. - R3: notional exactly
min_notional→ accepted. - R6: an order that satisfies tick/lot but fails a notional bound, and one that satisfies the
notional bounds but fails tick/lot — confirming independence. Confirm the existing
validate_overflow_invariantprop-test still holds.
- R1: notional below
canister/src/tests.rs(add_trading_pairmodule)- R4:
min_notional == 0rejected. - R5:
max_notional < min_notionalrejected.
- R4:
canister/src/state/snapshot/tests.rs- R7: bounds survive a snapshot round-trip.
Verification commands: cargo fmt --all, just lint, cargo test (workspace).
Delivery / PR sequence
Single PR. The feature is small and cohesive — the data model (request → event → OrderBook →
snapshot → query/did), pair-creation validation, order-time enforcement, and the design-doc
update ship together as one independently compilable and testable draft PR.
- PR 1 (1/1): all requirements R1–R7.
Discussed Alternatives
- Check in
OrderBook::validate_orderagainst the rawprice × quantity(the ticket’s literal pseudocode). Rejected:OrderBookhas nobase_scale, so it cannot compute the scaled quote amount, and the raw product is not a quote-token value — amin_notionalexpressed against it would be off by10^base_decimalsand meaningless. Threadingbase_scaleintoOrderBookwould duplicate state that already lives, by deliberate design, at theStatelayer (where the overflow guard already is). - Storing the bounds outside
OrderBook(e.g. a separate per-pair config map). Rejected: the bounds are immutable per-pair configuration of the same kind astick_size/lot_size, which already live onOrderBook; co-locating them keeps one source of truth.
DEFI-2852 — Order Status / Partial Fill
id: DEFI-2852 title: Expand order records with partial-fill information tags: [orders, order-status, partial-fill, query-api]
Expand order records with partial-fill information
Motivation
Today a caller cannot tell how much of a resting order has been filled. OrderRecord
carries the original quantity and a coarse status (Pending / Open / Filled /
Canceled); the only fill-derived datum anywhere is CanceledOrderInfo.remaining_quantity,
captured at cancel time. A maker order that is partially consumed but stays on the book
reports Open with no indication that any of it traded — you only learn the remaining
amount if you cancel, or see Filled once it completes. Every major spot venue exposes
filled-so-far as a first-class field (Binance executedQty, Kraken vol_exec, Coinbase
filled_size); we want the same.
We also have two overlapping read endpoints: get_order_status(OrderId) (un-scoped, any
principal, returns a bare OrderStatus) and get_my_orders (caller-scoped, paginated,
returns full OrderRecords — which already embed status). The fill information belongs
on OrderRecord, which makes get_order_status redundant once get_my_orders can also
fetch a single order by id.
Requirements
- R1 — Filled amount is reported.
get_my_ordersreturns each order’s cumulative filled amount in base-token units viaOrderRecord.filled_quantity. Remaining is derivable asquantity − filled_quantity. - R2 — Partial fill is visible. A resting order that has been partially filled reports
0 < filled_quantity < quantityandstatus == Open. - R3 — Full fill. A fully filled order reports
filled_quantity == quantityandstatus == Filled. - R4 — Pending. An order not yet matched reports
filled_quantity == 0. - R5 — Cancel retains the fill. A canceled order reports the
filled_quantityaccumulated before cancel (unchanged by the cancel);statusis the unit variantCanceled. Remaining-at-cancel isquantity − filled_quantity. - R6 — Point lookup, owner-scoped.
get_my_orderswith theById(id)filter returns the single matchingUserOrderwhen the order belongs to the caller, and an empty result otherwise (unknown id, or an order owned by another principal). The two filter modes are mutually exclusive by construction (ByIdcarries no cursor;ByPagecarriesafter/length), so no page parameter is interpreted in lookup mode. - R7 —
get_order_statusremoved. Theget_order_statusendpoint no longer exists in the canister interface;OrderStatusno longer has aNotFoundvariant. Absence from aget_my_ordersresult is the sole signal that an order does not exist / is not the caller’s. - R8 — One stable-memory write per order per batch. Recording a matching event writes
each affected order’s record at most once, folding its status transition, its accumulated
fill delta, and its
last_updated_atinto a single read-modify-write. - R9 — Invariant and durability.
filled_quantityis monotonic non-decreasing and never exceedsquantity. This is enforced by an always-on check that traps on violation (aBUG:panic, per the canister’s existing convention) — not adebug_assert!, since the canister ships in release wheredebug_assert!is compiled out — in addition tochecked_addfor overflow.filled_quantityis persisted in the stable-memory order history, survives canister upgrade, and the matching write path staysWrite-gated so event-log replay does not double-count fills. - R10 — Order timestamps.
OrderRecordexposescreated_at(renamed fromtimestamp, set once at placement) andlast_updated_at: Option<Timestamp>.last_updated_atisNoneuntil the order is first modified, then carries the timestamp of the most recent modifying event (a fill, a status transition, or a cancel).
Non-goals
- Richer fill analytics. Average fill price, quote-filled value, total fees, and fill
count (the Coinbase/Kraken-style fields) are out of scope; only base
filled_quantityis added. They can layer on later as furtherOrderRecordfields without disturbing this work. - A
PartiallyFilledstatus variant. Partialness is expressed by thefilled_quantityfield againstquantity, not by splitting the resting state acrossOpen/PartiallyFilled— see Design Decisions. - Cross-account / global order lookup. Removing
get_order_statusmakes lookup-by-id owner-scoped; querying an order you do not own is no longer possible (accepted). - A stored
remaining_quantity. Remaining is always derived (quantity − filled_quantity), never persisted.
Design Decisions
-
Filled amount is a flat field on
OrderRecord, not anOrderStatusvariant.statusstays a pure lifecycle enum; how much has traded is orthogonal data that applies in several states (a resting order, a canceled order). This mirrors Kraken (vol_exec) and Coinbase (filled_size) and keeps the engine’s existingOpen/Filledtransitions unchanged. (Why not aPartiallyFilledvariant — see Discussed Alternatives.) -
Persist
filled_quantity; do not compute it by joining the live book at query time. The system is pre-launch, so the breaking record-format change carries no migration cost, and persisting keeps the read path a pure history scan —get_my_ordersnever has to reach into order-book internals to reconstruct a number. (Why not the query-time join — see Discussed Alternatives.) -
Consolidate point lookup into
get_my_orders; removeget_order_status. Fill information lives onOrderRecord, whichget_my_ordersalready returns, so a single caller-scoped endpoint subsumes the bare status query. The argument becomes an optionalById | ByPagefilter variant rather than a flat field set, so the two modes are mutually exclusive by construction (no ambiguous “id + cursor” combinations). Lookup-by-id becomes owner-scoped as a consequence (accepted; see Non-goals). -
Drop
NotFoundandCanceledOrderInfo. With lookup folded intoget_my_orders, not-found is signalled by absence from the result vector, soOrderStatus::NotFounddisappears. Withfilled_quantitypersisted, remaining-at-cancel is derivable, soCanceledOrderInfodisappears andCanceledbecomes a unit variant. -
Aggregate fill deltas in the heap, then write each order once. A single batch can fill one order across many
Fills (a taker sweeping several makers; a maker hit repeatedly), and an order can both change status and accrue fills in the same batch. Summing per-order deltas in plain memory first, then doing one read-modify-write per touched order, minimizes stable-memory roundtrips (R8).
Implementation
Constraints
The canister is event-sourced. Order records live in OrderHistory, backed by a
stable-memory StableBTreeMap (canister/src/order/history). State::record_matching_event
applies a matching event to the live OrderBook and, only under
StableMemoryOptions::Write, reflects the result into OrderHistory; post-upgrade replay
runs with Skip, since the stable map is preserved across upgrades. All new persistence
must respect that Write gate so replay does not re-apply it.
Types — libs/types and canister/oisy_trade.did
OrderRecordgainsfilled_quantity: Nat(cumulative base-token amount filled), renamestimestamptocreated_at, and gainslast_updated_at: Option<Timestamp>(R10).OrderStatusdropsNotFound;Canceledbecomes a unit variant. Resulting set:Pending,Open,Filled,Canceled.CanceledOrderInfois removed.GetMyOrdersArgscarries a single non-optionalfilterthat is either a point lookup or a page, replacing the flatafter/lengthpair. The endpoint takesopt GetMyOrdersArgs; an absent argument is the default (first page, newest first).get_order_statusis removed from the canister interface.
The candid surface (the repo’s candid equality check must pass):
// The endpoint arg is optional: `get_my_orders : (opt GetMyOrdersArgs) -> ...`.
// An absent argument defaults to the first page, newest first.
type GetMyOrdersArgs = record {
filter : GetMyOrdersFilter;
};
type GetMyOrdersFilter = variant {
ById : OrderId;
ByPage : record { after : opt OrderId; length : nat32 };
};
type OrderRecord = record {
owner : principal;
side : Side;
price : nat;
quantity : nat;
filled_quantity : nat;
status : OrderStatus;
created_at : nat64;
last_updated_at : opt nat64;
};
type OrderStatus = variant { Pending; Open; Filled; Canceled };
length is capped at MAX_ORDERS_PER_RESPONSE as today; an absent argument is equivalent
to ByPage { after = null; length = MAX_ORDERS_PER_RESPONSE }. A malformed OrderId (in
either ById or ByPage.after) is rejected exactly as the current id/cursor parsing does
(trap), so behavior is consistent across the endpoint.
Internal order record — canister/src/order
- The internal
OrderRecord(order/history) gainsfilled_quantity: Quantityandlast_updated_at: Option<Timestamp>as new trailing minicbor fields (append-only indices; never reuse), and renamestimestamptocreated_at. - The internal
OrderStatus(order/mod.rs)Canceledvariant becomes unit; theCanceledOrderInfostruct is removed. OrderHistoryreplaces theset_status-only writer with a single combined writer,apply_update(&OrderId, OrderUpdate, now: Timestamp), whereOrderUpdate { status: Option<OrderStatus>, filled_delta: Quantity }. It does oneget+ oneinsert, applying the status (if present), adding the delta viachecked_add, and settinglast_updated_at = Some(now). It enforcesfilled_quantity <= quantitywith an always-on check that traps on violation (aBUG:panic, matching the codebase’sexpect("BUG: …")convention) — not adebug_assert!, which is compiled out of the release canister and would let a corrupted value persist silently (R9).
Matching write path — canister/src/state (record_matching_event)
Under the existing Write gate, replace the compute_order_status_transitions +
set_status loop with: build a heap BTreeMap<OrderSeq, OrderUpdate> from the batch
output — for each fill in output.fills, add fill.quantity to both
fill.taker_order_seq and fill.maker_order_seq; for each seq in output.resting_orders
set status = Open; for each in output.filled_orders set status = Filled — then call
apply_update(.., now) once per entry, where now is the matching Event’s timestamp.
output.fills already carries every maker/taker pair and per-fill quantity, so no
order-book/MatchingOutput changes are needed. This is the only site that catches a maker
partially filled while it stays open (which produces no status transition today). The event
timestamp is already available to apply_state_transition (state/audit) and is threaded
into record_matching_event (and record_cancel_limit_order) for last_updated_at.
Cancel path — canister/src/state (record_cancel_limit_order)
Writes the unit Canceled status (with last_updated_at set to the cancel event’s
timestamp). The cancel flow still reads remaining_quantity from the book removal
(book.remove_order) — it is required to compute the unreserve/refund amount, unchanged.
What goes away is only its persistence in history: with CanceledOrderInfo removed,
remaining_quantity is no longer stored on the record (remaining is derived from quantity − filled_quantity).
Endpoint — canister/src/lib.rs, canister/src/main.rs
get_my_orders: an absent argument defaults toGetMyOrdersArgs::default(); then matchargs.filter.ById(id)→ resolve the caller’sUserIdand return the single owned record as a one-elementvec(empty if the id is unknown or owned by another principal).ByPage { after, length }→ the existing newest-first cursor scan. The default filter isByPage { after = None, length = MAX_ORDERS_PER_RESPONSE }.- Remove
get_order_status(business fn inlib.rs, the#[ic_cdk::query]wrapper inmain.rs, andstate::get_order_statusif otherwise unused).
Test plan
Unit (*/tests.rs, helpers/fixtures per repo convention):
order/history/tests.rs:apply_updateapplies status-only, delta-only, and status+delta in a single write and setslast_updated_attonow(R8, R10); thefilled_quantity > quantityinvariant traps in release build config (an always-on check, not a compiled-outdebug_assert!) (R9).state/tests.rs: a batch that partially fills a maker advances itsfilled_quantitywithout a status transition (R2); a fully filled order reachesfilled_quantity == quantity+Filled(R3); cancel-after-partial keepsfilled_quantityand writes unitCanceled(R5); a fill spanning multipleFills for one order writes that order once (R8);created_atis unchanged after fills whilelast_updated_atadvances (R10). Replay underSkipleavesfilled_quantityuntouched (R9).
Integration (integration_tests/tests/tests.rs, PocketIC):
- Place a maker, partially fill it with a crossing taker, then
get_my_ordersshows0 < filled_quantity < quantity,Open(R2); complete the fill →filled_quantity == quantity,Filled(R3). get_my_orderswithById(id)returns the single owned order; returns empty for an unknown id and for an id owned by a different principal;ByPage/absent filter pages as before (R6).- Cancel a partially filled order →
Canceled,filled_quantitypreserved (R5). - Existing tests that called
get_order_statusare migrated toget_my_orders(R7).
Verification:
cargo fmt --all
just lint
cargo test -p oisy_trade_canister
cargo test -p oisy_trade_int_tests
# + the repo's candid equality check (see justfile / CI)
Delivery / PR sequence
A single PR. The filled_quantity field and the write path that populates it are
inseparable — shipping the field without the write path would expose an always-zero value,
and the endpoint consolidation depends on the field existing. Acceptance: all of R1–R10.
(If review size warrants, this could split into “types + endpoint consolidation + always-zero field” then “matching/cancel write path”, but the field is vacuous until the second lands, so one PR is preferred.)
Discussed Alternatives
-
A
PartiallyFilledOrderStatus variant (Binance style). Rejected: it splits the single “resting on the book” concept acrossOpenandPartiallyFilled, while the matching engine marks every resterOpen— the distinction would have to be recomputed at the response boundary anyway. A flat field expresses partialness without touching the lifecycle enum and generalizes to the canceled case. -
Compute filled at query time by joining the live order book. The book’s
resting_ordersindex makes a resting order’s remaining quantity reachable by id, so filled could be derived asquantity − live_remainingwithout persisting anything. This was the simpler option when migration was a concern — but the system is pre-launch, so there is no migration to avoid, and the join couplesget_my_ordersto order-book internals and only works while the order is still live. Persisting keeps reads a pure history scan and a single source of truth on the record. -
Keep
get_order_statusfor un-scoped lookup. Rejected: nothing requires looking up an order you do not own, and folding the lookup intoget_my_ordersremoves a redundant endpoint and theNotFoundvariant. Owner-scoped lookup is the accepted consequence.
DEFI-2853 — Fill-or-Kill Orders
id: DEFI-2853 title: Fill-or-Kill (FOK) limit orders tags: [orders, time-in-force, matching-engine, fees]
Fill-or-Kill (FOK) limit orders
Motivation
Today every limit order is implicitly Good-Til-Canceled (GTC): it rests in the book until filled or canceled and may fill partially over time. This is the right primitive for market-making, but the wrong one for swap-style UX. A swap user expects atomic semantics — “give me exactly X for at least Y, all at once, or don’t touch my funds” — which is exactly what the Oisy integration needs.
Fill-or-Kill (FOK) adds that primitive. It is not a new order type; it is a
time_in_force value on the existing limit order:
- GTC (current default): rests until filled or canceled; partial fills allowed.
- FOK (new): when the matching engine executes the order, the entire quantity must fill against resting liquidity at the order’s price or better, otherwise the whole order is killed with zero execution. No resting, no partial fill.
FOK is the matching-engine half of the swap story; the value to the client is a terminal “filled or killed” outcome rather than a resting order it has to manage.
Requirements
- R1 — Optional TIF, backwards compatible.
LimitOrderRequestaccepts an optionaltime_in_force. An absent value defaults toGoodTilCanceled, so every existing client keeps working unchanged. - R2 — GTC unchanged. A
GoodTilCanceledorder (explicit or defaulted) behaves exactly as today: it may rest, may fill partially, and reachesOpen/Filled/Canceledthrough the existing transitions. No GTC observable behavior changes. - R3 — FOK full fill. A FOK order whose full
quantitycan be satisfied against resting liquidity at its price or better fills completely and reachesstatus == Filledwithfilled_quantity == quantity. It never rests in the book. - R4 — FOK kill. A FOK order whose full quantity cannot be satisfied reaches
status == Expiredwithfilled_quantity == 0, leaves no trace in the book (it never rests), and moves no balances (the reservation taken at placement is fully released). - R5 — No partial FOK. The “some liquidity but not enough” case is killed exactly like the
“no liquidity” case:
Expired,filled_quantity == 0. FOK never settles a partial fill. - R6 — FOK pays taker; its counterparty keeps the maker rate. A FOK never rests, so in every
fill it produces it is the crossing (taker) side and pays
FeeRates.taker, regardless of pair. The resting order it crosses is the maker and keepsFeeRates.maker— choosing FOK must not penalize the liquidity provider it fills against. This falls out of the existing resting-side→maker / crossing-side→taker assignment with no FOK-specific fee logic (see Fee logic). - R7 — GTC fees unchanged. GTC fee assignment is untouched: the resting side of a fill pays the maker rate, the incoming (crossing) side pays the taker rate, exactly as today.
- R8 —
Expiredis distinct fromCanceled.OrderStatusgains a new unit variantExpired, surfaced in the public Candid type.Canceledkeeps its current meaning — explicit cancellation initiated by a user or an administrator (cancel_limit_order, admin sweep) — andExpiredis reserved for system-initiated, time-in-force-driven terminations. This ticket produces exactly one such case (a FOK that cannot fully fill); a future IOC would terminate asExpiredtoo. A client can distinguish “I changed my mind” (Canceled) from “the engine couldn’t honor my time-in-force” (Expired). - R9 — TIF is durable and observable. An order’s
time_in_forceis recorded on its order record, surfaced onOrderRecordin the query API, and round-trips through the state snapshot and event-log replay. The matching engine can always determine an order’s TIF when it evaluates it. - R10 — Bounded cost. A FOK is evaluated atomically and cannot be chunked across messages, so
its cost must fit one message. The work is bounded by the number of resting orders it crosses:
the plan pass is a flattened iteration over the crossing
BTreeMap<_, VecDeque<_>>levels, and apply + settlement is one step per fill. The worst case — a single large FOK that sweeps one fully-populated, fragmented side of the book — is covered by a canbench benchmark that must stay within the per-message instruction limit. - R11 — Async outcome, never
Open.add_limit_orderenqueues a FOK and returns anOrderIdexactly as it does for a GTC order — it does not block on the matching result. The FOK is evaluated when the matching engine processes it, transitioning onlyPending → FilledorPending → Expired; it never reachesOpen. The caller observes the terminal outcome viaget_my_orders.
Non-goals
- IOC (Immediate-Or-Cancel). Conceptually the sibling of FOK (same
TimeInForceenum, same always-taker fee, sameExpiredterminal state — see R8) but it does allow partial fill of the available quantity. Deferred to a follow-up; it reuses the same plan/execute matching this ticket introduces — applying whatever crosses, then canceling the unfilled remainder. That is a third remainder disposition alongside GTC’s rest and FOK’s require-full-or-kill, not simplyrequire_full = false(which is the GTC path, and rests the remainder). LIMIT_MAKER/ post-only. The opposite end of the TIF spectrum (reject if it would cross). Separate scope.- Self-Trade Prevention and
EXPIRED_IN_MATCH. STP is not in scope, so there is no distinct STP-driven expiry sub-case to model;Expiredhere means only “FOK could not fully fill”. - Changing GTC semantics or the FIFO matching order of existing orders beyond what the execution-model decision strictly requires.
Design Decisions
Execution model — asynchronous FOK
A FOK order queues as Pending exactly like a GTC order; the existing timer-driven executor
evaluates it when it pulls the order for matching, transitioning it only Pending → Filled or
Pending → Expired — never Open, since a FOK never rests.
Rationale. Time-in-force governs how long an order stays active in the book — and
add_limit_order does not put the order in the book. It lands in a pre-processing (pending)
queue and only reaches the book when the matching engine processes it. So the correct moment to
evaluate “can this fill in full” is when the engine pulls the order for matching, not at the
Candid call. This is also exactly Binance’s wording — a FOK “will expire if the full order
cannot be filled upon execution” — and it keeps FOK on the same execution path, FIFO ordering,
and message-chunking as every other order: no second matching entry point, no per-call
instruction-bound special case.
Matching is asynchronous and timer-driven: add_limit_order validates the order, reserves
balance, enqueues it as Pending, and returns an OrderId; a separately-scheduled zero-delay
timer (drive_matching) then matches it in a later message (docs/src/development/design.md,
Matching Engine). FOK reuses this unchanged; the caller observes the terminal Filled /
Expired outcome via get_my_orders.
Time-to-evaluation is best-effort, not immediate. The executor schedules books by pending
backlog, so under sustained load on busier books a FOK in a quieter book can wait — and while it
waits it holds its placement reservation. This is a pre-existing, non-FOK-specific property of
the cross-book scheduler (it delays GTC too); scheduling fairness is tracked separately in
DEFI-2899 and is out of scope here. What this ticket pins down is the meaning:
time_in_force is a constraint on how long an order may rest in the book, evaluated when the
engine processes it — not measured from submission. (Of Binance/Kraken/Coinbase, only Coinbase is
explicit that it is measured from submission; we adopt the rest-in-book reading.) (Why not
synchronous inline matching — see Discussed Alternatives.)
Split matching into a read-only plan and a mutating execute
OrderBook::match_order today fills greedily and mutates the book as it goes (it reduces
resting quantities and pops fully-consumed makers in fill_against_queue, and rests any
remainder at the tail). So “fill fully or do nothing” cannot be expressed by calling
match_order and reacting to a PartiallyFilled result — by then the book is already mutated
and partial fills already exist.
We therefore restructure matching into two phases. plan_fills walks the crossing prices
read-only and records the fills it would make (maker_seq, fill price, quantity, and whether
the maker is emptied) plus whether the order fully fills — touching no book state.
apply_plan then replays that plan, performing the mutations. A single parameter,
require_full, gates the two: when it is set and the plan does not fully fill, execute returns
a killed outcome before apply_plan runs, so the book is provably untouched. GTC calls
execute(order, require_full = false) and behaves exactly as today; FOK calls
execute(order, require_full = true).
Chosen over the two narrower alternatives (a non-mutating liquidity pre-check that then reuses
the unchanged match_order, and an operation-log rollback that undoes a partial match — both
in Discussed Alternatives) because plan/execute makes the no-mutation-on-kill guarantee
structural rather than test-enforced, keeps a single matching traversal with no duplicated
crossing predicate, and generalizes to other time-in-force values: the time-in-force selects
what happens to the taker’s unfilled remainder after whatever crossed has been applied — GTC
rests it, FOK requires the whole order to fill or kills it (require_full), and a future IOC
would cancel it — all on the same single plan/apply traversal. The cost is a constant-factor
second pass on the GTC hot path (see Performance).
Expired is a unit variant, matching the current Canceled
The ticket drafted Canceled(CanceledOrderInfo) and Expired(ExpiredOrderInfo), but
DEFI-2852 has since landed and made Canceled a plain unit variant (and removed
NotFound): OrderStatus = { Pending, Open, Filled, Canceled }. Per-order fill data now lives
in flat OrderRecord fields (filled_quantity, last_updated_at), not in a status payload.
Expired follows that established shape — a unit variant — and a killed FOK is simply a
record with status == Expired and filled_quantity == 0. No ExpiredOrderInfo struct.
Dependencies are satisfied on current main
The ticket lists DEFI-2848 (price encoding) and DEFI-2850 (min/max notional) as prerequisites;
both, plus DEFI-2852 (order status), are merged. The scaled-settlement math
(Price::checked_mul_quantity_scaled, Fill::quote_amount) and the shared notional gate
(OrderBook::check_notional, called from State::validate_limit_order) are present, so FOK
orders pass through the same tick/lot/notional/balance validation as GTC with no extra work
beyond confirming the path is shared (R-coverage in the test plan).
Implementation
Constraints
- The canister is event-sourced: an
AddLimitOrderEventis recorded viastate::audit::process_eventand re-applied on replay; matching results are applied byState::record_matching_eventonly underStableMemoryOptions::Write(replay runsSkip). Any new persistence (the TIF field, theExpiredtransition) must flow through this chain and respect theWritegate so replay does not double-apply. In particular,AddLimitOrderEventitself must carrytime_in_force(append-only minicbor field, defaulting toGoodTilCanceledfor pre-existing events): a FOK logged asPendingand replayed beforeprocess_pending_ordersruns must reconstruct as a FOK, not default to GTC and rest (R9). - Matching/settlement are synchronous and free of inter-canister calls, but bounded by the per-message instruction limit; the timer-driven model chunks GTC matching to stay within it. A single FOK is atomic and cannot be chunked (R10).
OrderStatusis shared between the internal engine (canister/src/order/mod.rs) and the public Candid type (libs/types/src/lib.rs); both must gainExpired.
Public types & Candid — libs/types/src/lib.rs, canister/oisy_trade.did
- New
TimeInForceenum:GoodTilCanceled,FillOrKill. LimitOrderRequestgainstime_in_force: Option<TimeInForce>(absent ⇒GoodTilCanceled, R1). Candid:time_in_force : opt TimeInForce.OrderStatusgains the unit variantExpired(R8). Candid:variant { Pending; Open; Filled; Canceled; Expired }.OrderRecordgainstime_in_force: TimeInForce(R9).- The candid equality check must pass (
oisy_trade.didregenerated/updated).
Order model — canister/src/order
PendingOrder/Ordercarrytime_in_force;PendingOrder::try_from(LimitOrderRequest)reads the optional field, defaulting toGoodTilCanceled.- Internal
OrderStatus(order/mod.rs) gainsExpired(next minicbor index). - Internal
OrderRecord(order/history) gainstime_in_forceas a new trailing minicbor field (append-only index; never reuse) so it round-trips through history and snapshot (R9).
Plan/execute matching — canister/src/order/book.rs
Refactor match_order into a read-only plan and a mutating apply, joined by a single
execute(order, require_full) entry point. The refactor is behavior-preserving for GTC.
plan_fills(side, price, quantity) -> FillPlan— read-only. Iterates the crossing price levels best-first (asksascending whileprice ≤ order price;bidsdescending whileprice ≥ order price— the same crossing predicate as today’smatch_orderbreak) and, FIFO within each level, records onePlannedFill { maker_seq, maker_price, fill_qty, maker_emptied }per maker it would touch, accumulating until the order is satisfied. ReturnsFillPlan { fills, fully_filled }. No mutation.apply_plan(side, &FillPlan, &mut Order, &mut fills_out, &mut filled_orders)— mutating. Replays the plan: for eachPlannedFill, reduce the maker (held at the front of its level — re-acquire the level cursor only whenmaker_pricechanges, so cost staysO(L log p + f), notO(f log p)), reduce the taker, push theFill, and onmaker_emptiedpop the maker, drop itsresting_ordersindex entry, insert intofilled_orders, and remove the level if its queue empties. An always-on check (expect("BUG: …")/assert_eq!, matching the codebase convention — notdebug_assert!, which the release canister compiles out, per the DEFI-2852 invariant convention) asserts the maker at the level front matchesplanned.maker_seq, trapping on any plan/apply divergence rather than corrupting the book.execute(order, require_full) -> Execution—let plan = plan_fills(..); ifrequire_full && !plan.fully_filledreturnExecution::Killed { seq }before any mutation (R4, R5); elseapply_plan(..)and, per the existing tail, returnFilledwhen the remainder is zero or rest it (insert_order) and returnResting.match_orderbecomesexecute(order, false)(GTC unchanged, R2); FOK isexecute(order, true).
MatchingOutput gains expired_orders: BTreeSet<OrderSeq> alongside fills / resting_orders
/ filled_orders. process_pending_orders runs the per-order loop — pop_front, execute with
require_full derived from the order’s time_in_force, and on Killed insert into
expired_orders (the order, already popped, leaves no book trace) (R3, R4, R5, R10).
Fee logic — canister/src/state (compute_balance_operations)
Fees are assigned per fill from fill.taker_side (resting side → maker rate, crossing side →
taker rate). No FOK-specific fee logic is needed: a FOK never rests, so it is always the
crossing side of any fill it produces — the existing assignment already bills it FeeRates.taker
and leaves the resting counterparty on FeeRates.maker (R6). Billing the resting maker the taker
rate merely because the crosser chose FOK would penalize the liquidity provider and is not
intended; the ticket’s fee_rate_for_fill sketch that returned the taker rate for both legs is
dropped. GTC assignment is unchanged (R7). The only fee work is a test asserting a FOK fill bills
the FOK side the taker rate and its resting counterparty the maker rate.
Execution wiring — canister/src/execute, canister/src/state (record_matching_event)
add_limit_order is unchanged: it validates, reserves balance, and enqueues the FOK as
Pending, returning an OrderId (R11). The plan/execute branch lives entirely in the order book
(above); record_matching_event consumes the resulting MatchingOutput, mapping each
expired_orders entry to status = Expired and releasing the reservation taken at placement. A
FOK never transitions to Open; GTC keeps its Pending → Open / Filled transitions. The
reservation-release on kill reuses the same unreserve/refund computation the cancel path already
performs over the order’s full quantity (it never entered the book, so there is no RemovedOrder
to read) (R4, R5).
Performance
The plan/execute refactor costs GTC a constant-factor second pass: today’s single fused
matching pass becomes a read-only plan_fills walk plus an apply_plan replay, plus one
transient O(f) FillPlan allocation (f = fills). Asymptotics are unchanged at
O(L log p + f) provided apply_plan holds the level cursor across a level’s fills rather
than re-looking-up by price per fill. The non-crossing/rest case is negligibly affected
(plan_fills stops at the first non-crossing level). The overhead is expected to be small
relative to the settlement that follows in record_matching_event (per-fill fee math and
balance operations, plus stable-memory writes), but it falls on the hot path — confirm with the
canbench suite (the bench_scopes! instrumentation already in lib.rs) before relying on the
estimate.
Docs — docs/src/development/design.md
Document the time_in_force field, the asynchronous execution model (FOK is evaluated when the
engine processes it — “upon execution” — and so transitions Pending → Filled or
Pending → Expired and never rests), and the Canceled (user-initiated) vs Expired
(system-initiated FOK kill) distinction. (R8, plus the AC requiring the design doc to record the
model.)
Test plan
Unit (*/tests.rs, fixtures in canister/src/test_fixtures):
order/book.rstests:- GTC regression / plan==apply: a property test over arbitrary books + orders asserts
execute(order, false)produces the identical fills, resting state, and final book as the pre-refactormatch_order— i.e. the refactor is behavior-preserving (R2). The always-on plan/apply-divergence check inapply_planholds throughout. - Kill is mutation-free: when
plan_fills(..).fully_filledisfalse,execute(order, true)returnsKilledand the book is byte-identical to its pre-call state — compared via theOrderBookSnapshotround-trip (noPartialEqonOrderBookneeded) (R4, R5). Covers the some-but-insufficient liquidity case explicitly (R5). - Boundary + fill: available liquidity exactly equal to
quantity⇒Filled(inclusive); a fillable FOK producesFilledwith the expected fills (R3).
- GTC regression / plan==apply: a property test over arbitrary books + orders asserts
canister/src/benchmarks.rs(canbench): a worst-case FOK that sweeps one fully-populated, fragmented side of the book in a single message, asserting it stays within the per-message instruction limit (R10).state/tests.rs: a FOK that fully fills recordsFilled/filled_quantity == quantityand releases nothing extra (R3); a FOK that can’t fill recordsExpired/filled_quantity == 0, no book trace, reservation released (R4, R5); a FOK fill bills the FOK side the taker rate and its resting counterparty the maker rate (R6); GTC fee assignment unchanged — maker when resting, taker when crossing (R7); defaulted TIF isGoodTilCanceled(R1, R2).order/history+state/snapshottests:time_in_forceround-trips through the record and a snapshot; replay underSkipdoes not re-settle a FOK (R9).
Integration (integration_tests/tests/tests.rs, PocketIC) — these encode the acceptance
criteria end-to-end:
- FOK against sufficient resting liquidity ⇒
Filled,filled_quantity == requested(R3). - FOK against no liquidity ⇒
Expired,filled_quantity == 0, no resting trace (R4). - FOK against insufficient liquidity (some, but < quantity) ⇒
Expired,filled_quantity == 0(R5 — the no-partial guarantee). - Fee on a FOK fill: the FOK side is billed the taker rate and its resting counterparty the maker rate, on an asymmetric-decimal pair (R6).
- GTC fees unchanged: maker-if-resting, taker-on-immediate-cross (R7).
Expiredis distinct fromCanceledin the Candid surface; a user cancel still yieldsCanceled(R8).- Absent
time_in_forcebehaves as GTC (R1, R2).
Verification:
cargo fmt --all
just lint
cargo test -p oisy_trade_canister
cargo test -p oisy_trade_int_tests
# + the repo's candid equality check (see justfile / CI)
Delivery / PR sequence
A stack, each PR independently compilable/testable, isolating the behavior-preserving refactors
from the FOK feature itself. The plan/execute PR sits on a separately-extracted,
behavior-preserving OrderQueue storage refactor — its own base PR off main — so each layer
reviews on its own. The FOK delivery proper is the three PRs below.
- PR 1 (1/3) — plan/execute refactor (pure implementation detail). Introduce
plan_fillsandapply_plan, rewritematch_orderas plan-then-apply, and add the always-on plan/apply-divergence check. No new public types, no behavior change, no new requirement — this is purely how matching is structured internally. The acceptance signal is that every existing order-book/matching test passes unmodified — that is the behavior-preservation proof; no parallel reference-matcher test is needed. Therequire_fullgate,Killedoutcome, andexpired_ordersare not introduced here (they have no caller yet and would be dead code) — they arrive with the FOK wiring in PR 3.- Acceptance: existing matching tests green without edits (behavior preserved, R2).
- PR 2 (2/3) — FOK data model.
TimeInForceenum; optionaltime_in_forceonLimitOrderRequestdefaulting to GTC;OrderStatus::Expired(internal + public + Candid);time_in_forceon the order model, record, snapshot,AddLimitOrderEvent, andOrderRecord. No fee code: R6 needs none (a FOK is always the crossing/taker side, so the existing fee assignment already bills it the taker rate — see Fee logic). Types are defined and surfaced but FOK is not yet routed through matching.- Acceptance: R1, R2, R7, R8, R9. (R6 is satisfied structurally; its fill test lands in PR 3 with the routing.)
- PR 3 (3/3) — FOK matching gate + execution wiring. Add the
require_fullgate toexecute(theKilledoutcome) andexpired_orderstoMatchingOutput; driverequire_fullfromtime_in_forceinprocess_pending_orders; mapexpired_orderstoPending → Expiredplus reservation release inrecord_matching_event; updatedesign.md; add unit + end-to-end integration tests.- Acceptance: R3, R4, R5, R6, R10, R11, and the design-doc AC.
Discussed Alternatives
- A separate
add_fok_orderendpoint. Rejected (per the ticket): the only thing that varies is the TIF enum value, so a second endpoint would just duplicate the entire tick/lot/notional/balance validation path and create a second place to keep it in sync. One endpoint, one extra optional field, mirrors Binance/CEX convention. - A payload-carrying
Expired(ExpiredOrderInfo)(the ticket’s draft). Rejected: DEFI-2852 already moved per-order fill data to flatOrderRecordfields and madeCanceleda unit variant. A unitExpiredis consistent with the current model; the “executedQty == 0” property is expressed by the existingfilled_quantityfield, not a status payload. - Synchronous inline FOK (match within the
add_limit_ordercall). This would match a FOK against the live book inside the update call and return the terminal outcome in one round-trip (closer to Coinbase’s “filled immediately at submission”). Rejected: time-in-force describes how long an order stays active in the book, and the Candid call does not put the order in the book — it enqueues it for pre-processing. Evaluating “fill or kill” at the call would conflate submission with book-entry. It would also add a second matching entry point, jump the FOK ahead of GTC orders already queued (breaking FIFO), and make the per-message instruction bound a hard gate because a single atomic FOK cannot be chunked. The asynchronous model (Binance’s “upon execution”) avoids all of this; the only cost is that the caller pollsget_my_ordersfor the outcome instead of reading it from the call result. - Non-mutating liquidity pre-check, then reuse the unchanged
match_order. A read-only walk summing crossing liquidity gates a kill; if it passes, run today’smatch_order(guaranteedFilled). More surgical — it leaves the GTC hot path at exactly one pass (zero regression) and pays the second walk only on FOK; a killed FOK is a single cheap read-only walk. Rejected for this ticket in favor of the broader plan/execute refactor: the pre-check’s no-mutation guarantee is by avoidance (it depends on the walk’s crossing predicate staying in lockstep withmatch_order’s, a divergence risk closed only by a property test), and it does not generalize to IOC, which needs to commit a partial — exactly what plan/execute’sapply_planprovides. We accept a constant-factor GTC cost (see Performance) to get the structural guarantee and the IOC-ready shape. - Operation-log rollback. Run the unchanged
match_orderwhile journaling each mutation onto a stack, then pop-and-invert the stack if a FOK did not fully fill. Rejected: it carries the largest correctness surface of the options (every mutating primitive must emit a correct, correctly-ordered inverse, or a kill silently corrupts the book), and its performance profile is backwards for this feature — it makes the kill path the most expensive (full mutate, then full reverse with index/level churn) when a kill (“insufficient liquidity”) is exactly the case that should be cheap and side-effect-free. It also yields no benefit for a future IOC, which keeps its partial and never rolls back. - A dedicated
PartiallyFilled/ richer FOK-specific status set. Out of scope and unnecessary: FOK only ever reachesFilledorExpired, both of which already exist (or are added as the singleExpiredunit variant).
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
0.1.0 - 2026-06-16
Added
- Limit orders: submit and query order status, with validation on submission (#11, #19); cancel orders (#76, #77)
- Order matching: order book with a timer-driven matching engine, plus a configurable execution policy and chunked execution of pending orders (#15, #18, #90, #89)
- Deposit and withdrawal flows (#17, #45)
- Balances: per-user free/reserved balances, reserved on order placement and updated on settlement, with
get_balancesandlist_supported_tokensqueries (#27, #28, #30, #99, #98) - Trading pairs:
add_trading_pairandget_trading_pairs, with token metadata (#22, #21, #32) - Per-pair maker/taker fees: configuration and pair plumbing, per-token fee pools, deduction at fill time, and fee visibility — including the per-pair rates in
get_trading_pairsand the dashboard (#107, #108, #109, #105, #153) - Order history and queries: order-lifecycle history, a per-user order index with submission timestamps, and a
get_my_ordersquery; order-book ticker and depth queries (#41, #110, #111, #115, #74) - Audit and event log for state replay, with deposit, withdrawal, trading-pair, limit-order, and matching/settlement events (#38, #42, #44, #47, #66, #68)
- Trading halts: global and per-pair halt, on a permission layer (#125, #126, #127)
- State persistence: order history and balances persisted in stable memory and restored across upgrades (#62, #63, #64)
- Observability: operation logging, canister metrics, and a dashboard with trading-pair details (#23, #52, #79, #80)
Changed
- Settlement exactness: enforce the tick·lot settlement invariant, settle fills in quote units per whole base token, and widen price and tick size to u128 (#119, #121, #122)
- Add a min/max notional filter per trading pair (#131)
- Expand order records with partial-fill information (#133)
- Rename the project from DEX to OISY TRADE (#138)
Fixed
- Apply order-status transitions atomically with matching, fixing a cancel-order trap on fully-filled orders (#92)
- Guard concurrent deposits and withdrawals per (caller, token) (#78)
- Surface trading-pair fee rates in
get_events(#134)
Release Notes
Per-release notes — including the reproducible-build hash, deployment status, and the changes in each version — are published on the GitHub Releases page.