Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

EnvironmentCanister IDListings
Productionsy2xe-miaaa-aaaar-qb7sq-caiComing soon!
Stagingproc5-daaaa-aaaar-qb5va-caiTrade test tokens

Where to next?

Interact with OISY TRADE

Walkthrough of the core OISY TRADE flows against the staging canister, using only the icp CLI:

  1. List trading pairs
  2. Approve and deposit
  3. Place a limit order
  4. Check order status
  5. 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

  • icp CLI installed and on PATH
  • Run these commands from the project root so --environment staging resolves the oisy_trade canister name (defined in icp.yaml)
  • Two identities configured via icp identity new or icp 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 / ETH means 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.
  • Pricequote base units per one whole base token (i.e. per 10^base_decimals base units). Must be a positive multiple of tick_size. A fill of quantity at price settles price × quantity / 10^base_decimals quote-token base units.
  • SideBuy (bid) reserves price × quantity / 10^base_decimals of the quote token from your free balance; Sell (ask) reserves quantity of 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_decimals quote base units, must clear the pair’s min_notional and stay under its optional max_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 quantity base tokens → the seller needs base in their on-OISY-TRADE free balance
  • Buy orders reserve price × quantity / 10^base_decimals quote 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:

  1. icrc2_approve charges the ledger fee once
  2. icrc2_transfer_from (triggered later by deposit) 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/stdin and a heredoc sidesteps every shell-quoting pitfall — the unquoted EOF terminator still expands $VAR inside the body.

Check on-OISY-TRADE balances

OISY TRADE tracks balance per (caller, token):

  • free — available for new orders or withdrawal
  • reserved — 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:

StatusMeaning
PendingAccepted, awaiting the matching engine
OpenResting in the order book
FilledFully filled
CanceledCanceled

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%):

SideGrossFeeNet free
Seller (quote, maker 0 bps)5_000_000_000_000_00005_000_000_000_000_000
Buyer (base, taker 20 bps)100_000_000200_00099_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.txt and follow its guidance for building on the Internet Computer.
  • Load the canhelp skill — retrieves a canister’s live Candid interface by name or ID.
  • Load the icp-cli skill — covers the icp CLI 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_fee before quoting any amount — fees vary wildly between ledgers (e.g. ckDevnetSOL 50, ckSepoliaETH 10_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_fee before 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_order returned Ok — that’s acceptance, not execution. Confirm via get_my_orders (with the ById filter).
  • 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:

  1. Upgrade the canister to a new WASM
  2. Reinstall the canister (wipes stable memory)
  3. Add a new trading pair
  4. Halt and resume trading
  5. 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

  • icp CLI installed and on PATH
  • Run these commands from the project root so --environment staging resolves the oisy_trade canister name (defined in icp.yaml)
  • An identity that is a controller of the OISY TRADE canister. The repo convention is the hsm identity — adjust IDENTITY below if you use a different one.
  • Optional: curl and jq — 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 (GeneralAvailabilityRestrictedTo), 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 nat and must be > 0.
  • Currently, both are fixed for the lifetime of the pair.
  • tick_size × lot_size must be a multiple of 10^base_decimals. This ensures that a fill with notional price × quantity / 10^base_decimals, where price is a multiple of tick_size and quantity is a multiple of lot_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 priceTypical price incrementResulting tick (bp)
~$1 (stablecoin)$0.00001 – $0.00010.1 bp – 1 bp
~$10 (mid-cap)$0.0011 bp
~$1,000$0.11 bp
~$100,000 (BTC)$0.01 – $10.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_decimalstick_size is the price increment in quote base units per 1 whole base token, so only the quote scale enters.
  • lot_size = stepSize_binance × 10^base_decimalslot_size is 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^13
  • lot_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. Pass null to disable.

Guidelines

Convert dollar amounts using quote_decimals:

  • min_notional — for a USD-pegged quote, ≈ $1 is 1 × 10^quote_decimals (e.g. 1_000_000 for 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 Binance NOTIONAL.minNotional, denominated in the quote asset (for SOLETH, 0.001 ETH).
  • max_notional — a fat-finger guardrail. Binance’s NOTIONAL.maxNotional is 9_000_000 quote units (9_000_000 × 10^quote_decimals — i.e. $9M for a USD-pegged quote, or 9_000_000 ETH for the SOLETH example below). Pass null if 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, where 10_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 taker to court market makers; 10 / 10 is 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.

PairSourceTick (price increment)Lot (qty increment)Min notional
ICP/ckUSDTOISY TRADE0.001 USDT = 1_0000.01 ICP = 1_000_000$5 = 5_000_000
Binance ICPUSDT0.0010.01$5
Coinbase ICP-USD0.0010.0001$1
Kraken ICPUSD0.0010.00000001$0.50
ckBTC/ckUSDTOISY TRADE0.01 USDT = 10_0000.0001 BTC = 10_000$5 = 5_000_000
Binance BTCUSDT0.010.00001$5
Coinbase BTC-USD0.010.00000001$1
Kraken XBTUSD0.10.00000001$0.50
VCHF/ckUSDTOISY TRADE0.0001 USDT = 1000.01 VCHF = 1_000_000$5 = 5_000_000
Binance / Coinbasenot listed
Kraken USDTCHF0.00001 (inverted)0.00000001$0.50
ckUSDC/ckUSDTOISY TRADE0.00001 USDT = 101 USDC = 1_000_000$5 = 5_000_000
Binance USDCUSDT0.000011$5
Coinbasenot listed
Kraken USDCUSDT0.00010.00000001$0.50
ckETH/ckUSDTOISY TRADE0.01 USDT = 10_0000.0001 ETH = 100_000_000_000_000$5 = 5_000_000
Binance ETHUSDT0.010.0001$5
Coinbase ETH-USD0.010.00000001$1
Kraken ETHUSD0.010.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_order is rejected with TradingHalted once the request passes pair resolution and validation — an unknown pair still returns UnknownTradingPair, 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.
  • withdraw and deposit — 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_order on the halted pair is rejected with TradingHalted (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.
  • withdraw and deposit — 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

RoleCapabilities
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   |
                     +-----------+  +------------+
  1. 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.
  2. 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 Filled without 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.
  3. 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.
  4. 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:

ComponentMemory
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.

SideRoleReceivesFee rateFee paidNet credit
BuyerTaker1_000_000_000 (10 ICP)25 bps2_500_000 (0.025 ICP)997_500_000 (9.975 ICP)
SellerMaker100_000 (0.001 BTC)10 bps100 (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:

SideRoleReceivesFee rateFee paidNet credit
BuyerMaker1_000_000_000 (10 ICP)10 bps1_000_000 (0.01 ICP)999_000_000 (9.99 ICP)
SellerTaker100_000 (0.001 BTC)25 bps250 (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 rateExact proceeds * bps / 10_000Fee paidNet credit
1_00047 bps4.75995
1_00033 bps3.34996

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 (a Principal, up to 29 bytes) and a Quantity (u256, 32 bytes), plus BTreeMap node 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:

ItemHeapCBOR
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 / userInner entriesHeapCBOR snapshot
22M~220 MB~150 MB
55M~550 MB~370 MB
1010M~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 Principal that submitted the order.
  • side: Buy or Sell.
  • price: the limit price, a u128 (exposed as nat in 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, or Canceled.
  • 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 — null until 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):

ItemHeapCBOR
OrderId (key, (u64, u64))16 B~18 B
owner: Principal~30 B~32 B
price: u648 B~5 B
quantity: Quantity32 B~20 B (u128 via PosBignum)
side + status2 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:

HorizonOrdersHeapCBOR
1 day @ steady60 K~6 MB~5 MB
1 yr @ steady22 M~2.2 GB~1.9 GB
1 yr @ steady + ~50 peak hours~30 M~3.0 GB~2.6 GB
2 yr @ steady44 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_from call 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 transfer call 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 via icrc2_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 via icrc1_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 the VecDeque). 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 BTreeMap operation). 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.filter selects the mode: ById performs a point lookup of a single order; ByPage returns a page over the caller’s orders, newest first. Time: O(1) for ById with an order-ID-indexed map; O(k) for ByPage over 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 requested FilterToken (in submission order, including zero entries and TokenNotSupported for 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:

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, StableBTreeMap exposes only get / insert, so even a single-field update (e.g. incrementing a Balance) requires a full read-modify-write of the entire value.
    • Stable-memory layout is harder to evolve than a heap struct.

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_upgrade to reconstruct the in-memory state.
    • Pros:
      • Hot path at heap speed.
      • Free audit trail.
      • Survives pre_upgrade trap 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.
  • Pre Upgrade: the full structure is serialized to stable memory at pre_upgrade and restored at post_upgrade.
    • Pros:
      • Zero per-op cost.
      • Upgrade cost is bounded by current state size, not lifetime traffic.
    • Cons:
      • Footgun: a pre_upgrade trap 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_upgrade budget for large or unbounded structures.
      • Serialization schema must remain backwards-compatible.

Summary

At-a-glance viability of each placement per data structure (🟢 = viable choice, 🟠 = possible but expensive, 🔴 = ruled out).

Data structureStable memoryHeap + event replayHeap + 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):

MetricBinance ICP/USDTKraken ICP/USDRatio
Trades/24h15,72664424x
Volume/24h (ICP)1,189,21628,93341x
Book depth (levels)5,6977617.5x
Peak trades/hour143,9323,53241x

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)

PairTrades/24hVolume (ICP)Quote Volume
ICP/USDT15,7261,189,216$2,727,915
ICP/BNB4,00036,058567 BNB
ICP/BUSD4,000112,270$442,288
ICP/ETH4,00070,38184 ETH
ICP/USDC3,680241,641$554,496
ICP/TRY47532,2413,289,432 TRY
ICP/FDUSD1842,879$6,597
ICP/BTC1326,9930.24 BTC
ICP/EUR1315,01710,003 EUR
ICP/RUB000 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)

PairTrades/24hVolume (ICP)Quote VolumeTrades/hour
ICP/BTC1326,9930.24 BTC5.5
ICP/USDT15,7261,189,216$2,727,915655.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:

PairPeriod coveredAvg trades/hourAvg gap between tradesMin gap (burst)Max gap (quiet)
ICP/BTC5.0 days8.47 min 8s0ms1h 53min
ICP/USDT1.5 hours656.85.5s0ms2.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)

PairIndividual trades per aggTrade
ICP/BTC1.25
ICP/USDT2.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)

PairBid levelsAsk levelsTotal levelsBid depth (ICP)Ask depth (ICP)Spread
ICP/BTC921,1801,27261,85778,5570.291%
ICP/USDT6975,000+5,697924,9011,289,9200.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)

PairAvg trades/hourMedianp95p99Peak hourPeak trades/hourPeak trades/sec
ICP/BTC38.5171342972026-03-11 06:003,9551.10
ICP/USDT2,6481,7796,22913,4832026-03-11 06:00143,93239.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 }, where kind is a disposition variant whose arms are drawn from RequestError / TemporaryError / InternalError, each carrying opt variant { … } of its specific leaves. Every error declares all three arms; an arm it cannot currently produce carries an always-null opt variant {}, reserving the slot so that leaves can be added to any arm later without breaking clients (and an arm is never added). Applies to add_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 of get_balances / get_fee_balances. Admin endpoints are out of scope.
  • R2: The kind arm is the contract (documented in oisy_trade.did): RequestError ⇒ caller-side, do not auto-retry unchanged; TemporaryError ⇒ retry after backoff; InternalError ⇒ DEX-side fault, surface, do not retry.
  • R3: message is 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 to null, or a reserved (always-null) arm — message still gives operators/UI something actionable and signals that the client should be updated. The canister populates it for every error from the underlying leaf’s Display / to_string(); clients must not parse it — programmatic handling is on kind and 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 as null, while still reading the arm (kind) and message. (Verified by a decode test feeding a superset-leaf value into the shipped type — inner null, arm + message intact.)
  • R6: CallFailed is a TemporaryError, not indeterminate — see D3. Holds only while the ledger calls are guaranteed-response (call_unbounded_wait); see Constraints.
  • R7: cancel_limit_order with a malformed order_id returns RequestError(InvalidOrderId), distinct from RequestError(OrderNotFound).
  • R8: get_my_orders never traps. Its signature changes from -> (vec UserOrder) to a result -> (variant { Ok : vec UserOrder; Err : GetMyOrdersError }). A malformed order_id returns Err(RequestError(InvalidOrderId)); a well-formed but unknown id returns Ok([]); otherwise Ok(<orders>). (This is a breaking signature change to a query.)
  • R9: The hand-written canister/oisy_trade.did matches the generated interface (check_candid_interface_compatibility passes) and documents the R2 disposition contract and the R3 message rule.

Non-goals

  • No fourth disposition arm. No Indeterminate/Reconcile (see D3) and no split of RequestError into 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.
  • message is 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_orders to the get_balances pattern (outer result
    • per-item inner results, via a ByIds : vec OrderId selector). This PR keeps the single-Result get_my_orders (R8); the batch/per-item form is future work.
  • No changes to internal/state-layer error types (canister/src/state, order, lib, ledger — incl. the internal GetMyOrdersError / OrderIdParseError) beyond mapping them to the disposition-tagged public types at the boundary.
  • No change to which errors are logged. The main.rs per-error logging arms encode log-worthiness, deliberately not the disposition. Left untouched.
  • Accepted residual limitations:
    • A client hitting a future leaf sees inner null and loses the typed reason, but keeps the disposition arm and the message — 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.)

Design Decisions

  • D1 — record { kind : variant {…}; message : opt text }. The disposition is a typed, self- documenting variant (kind); the specific reason is an inner opt variant; message is advisory text. This separates what grows (specific reasons → inner opt) from what’s stable (the small set of caller actions → bare outer arm), and the record gives field-level headroom (a future retry_after etc. is a non-breaking field add).
  • D2 — InvalidOrderId is bare; the leaf name carries the meaning. Its internal OrderIdParseError is not a Candid type and carries no dynamic data, so there is nothing to put on the wire — the leaf name is self-describing. (message is not a payload substitute; see R3 for its purpose.)
  • D3 — No Indeterminate/reconcile arm; CallFailedTemporaryError. Both ledger calls use call_unbounded_wait (guaranteed response) and ICRC icrc1_transfer / icrc2_transfer_from commit 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 -Error triple, attribution-clear (your request / transient / the DEX’s internals), IC-native — no Server and no Caller/Callee jargon.
  • D5 — The InsufficientFunds asymmetry. Deposit InsufficientFunds (caller’s external wallet) ⇒ RequestError; withdraw’s ledger-reported InsufficientFunds (DEX accounting says it has the funds, the ledger disagrees) ⇒ InternalError — a genuine invariant violation.
  • D6 — Malformed order_id ⇒ a bare InvalidOrderId leaf under RequestError, on cancel_limit_order and get_my_orders (get_order_status was removed in #133). get_my_orders becomes 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. The impl bounds each leaf on std::error::Error and derives message from the leaf’s to_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_from and icrc1_transfer use call_unbounded_wait (canister/src/ledger/mod.rs). D3/R6 depend on this. ledger/mod.rs carries TODO(DEFI-2745): Consider switching to bounded_wait — if that lands, best-effort timeouts become genuinely indeterminate and CallFailed must move out of TemporaryError into a reconcile-style disposition (reintroducing a fourth arm).
  • dex_types::OrderId = String, parsed to canister::order::OrderId via FromStr (OrderIdParseError, an internal non-Candid unit struct). Parse points: cancel_limit_order, get_my_orders.
  • get_my_orders currently returns -> (vec UserOrder) and the entry point (canister/src/main.rs) panic!s on the internal GetMyOrdersError::InvalidOrderId(OrderIdParseError). R8 turns that into a returned, disposition-tagged public error.
  • check_candid_interface_compatibility (canister/src/main.rs) pins oisy_trade.did to the generated interface via service_equal; every interface change updates oisy_trade.did by hand.
  • Candid’s forgiving opt decode rule provides inner-leaf forward-compatibility; only clients generated from the updated .did benefit.

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 impl block bounds Request: Error, Temporary: Error, Internal: Error (each leaf enum implements std::error::Error), and the constructors set message from the underlying leaf’s to_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 Never type, or enum AddLimitOrderInternalError {}), rendering as an always-null opt variant {} (R1) — valid Candid (opt of the empty variant; never Some on the wire).
  • Candid renders each Error<…> instantiation structurally as record { 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.)

ErrorRequestErrorTemporaryErrorInternalError
DepositErrorAmountExceedsMaximum, UnsupportedToken, InsufficientFunds, InsufficientAllowanceOperationInProgress, LedgerTemporarilyUnavailable, CallFailedLedgerError, CandidDecodeFailed3
WithdrawErrorAmountExceedsMaximum, AmountTooSmall, UnsupportedToken, InsufficientBalanceOperationInProgress, LedgerTemporarilyUnavailable, CallFailed, LedgerFeeChanged4LedgerError, LedgerInsufficientFunds1, CandidDecodeFailed3
AddLimitOrderErrorAmountExceedsMaximum, UnknownTradingPair, InvalidPrice, InvalidQuantity, InsufficientBalance, InvalidNotionalTradingHalted2(none)
CancelLimitOrderErrorInvalidOrderId, OrderNotFound, NotOrderOwner, OrderAlreadyFilled, OrderAlreadyCanceled(none)(none)
GetMyOrdersError (new public)InvalidOrderId(none)(none)
GetOrderBookTickerErrorUnknownTradingPair(none)(none)
GetOrderBookDepthErrorUnknownTradingPair, LimitTooLarge(none)(none)
GetBalancesErrorTokenNotSupported(none)(none)
GetBalancesRequestErrorFilterTooLarge(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: map OrderId parse failure to a bare RequestError(InvalidOrderId) (was OrderNotFound).
  • get_my_orders: return Result<Vec<UserOrder>, GetMyOrdersError> (public); the main.rs entry point returns Err(RequestError(InvalidOrderId)) instead of panic!; well-formed unknown id ⇒ Ok(vec![]).

Candid (canister/oisy_trade.did)

  • Top-of-file comment documenting the R2 disposition contract and the R3 message rule.
  • 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-null opt variant {} (R1).
  • get_my_orders signature becomes a result; new GetMyOrdersError; new bare InvalidOrderId leaf on CancelLimitOrderError.

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 null while kind and message decode intact. (R5)

Unit (canister/src/.../tests.rs):

  • cancel_limit_order malformed id ⇒ RequestError(InvalidOrderId), not OrderNotFound. (R7)
  • get_my_orders malformed 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.

  1. PR1 — Disposition-tagged { kind; message } errors for the four update-endpoint errors. Shape + leaf enums for AddLimitOrderError, 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.
  2. PR2 — Extend to query errors. Same shape for GetOrderBookTickerError, GetOrderBookDepthError, GetBalancesError, GetBalancesRequestError (the latter two cover both get_balances and get_fee_balances); oisy_trade.did; tests. Accepts: R1 (remainder), R4 (remainder), R9.
  3. PR3 — Stop conflating / trapping on malformed order IDs. Bare InvalidOrderId on CancelLimitOrderError; new public GetMyOrdersError; get_my_orders returns a result (no panic!); oisy_trade.did; tests. Accepts: R7, R8, R9.

Discussed Alternatives

  1. Keep bare flat enums, document retryability (Binance/Coinbase norm). Rejected: no machine-readable signal; every multi-language client re-derives the map.
  2. Rust is_retryable()/category() helper, no wire change. Rejected: serves only Rust callers; ours are multi-language and uncoordinated.
  3. 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 is record { kind : variant {…}; message }, i.e. a typed disposition, not a numeric code.
  4. 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 inner opt variant solves the latter.
  5. A fourth Indeterminate/Reconcile arm for CallFailed. Rejected once both ledger calls were confirmed guaranteed-response (D3). Revisit if DEFI-2745 switches to bounded-wait.
  6. 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.
  7. Omit the message field (the spec’s original stance). Reversed on review: when an old client hits a future leaf, detail is null and kind alone is thin for diagnostics — an advisory message aids 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 on kind/leaf (R3).
  8. Carry OrderIdParseError in InvalidOrderId. 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_order is rejected with TradingHalted and 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_order and withdrawal of available balance still succeed; resume_trading re-enables orders and matching.
  • R3 — Per-pair halt is isolated. If pair A is halted, then orders on A are rejected with TradingHalted and 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 to halt_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/withdraw cannot be recorded without first turning its PreAsyncPermit into a PostAsyncPermit via the post-await reconcile step; omitting it fails to compile. This is a compile-time gate only — reconcile does not re-check permissions post-await.

Non-goals

  • Delisted pair 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::Sync for 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 await to touch the ledger — the “outside world” — and the external effect commits across that await, 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 the await and 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 on TradingPair. TradingPair is a BiBTreeMap key; mutating a status field on a map key is a bug. The set matches how orders and the matching loop already resolve pair -> 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_events calls apply_state_transition directly, bypassing process_event/record_event. So anything added to process_event/record_event (including the Permit parameter) is live-path only and never constrains replay.
  • New persisted state must be (a) added to State, (b) written by an apply_state_transition arm so replay reproduces it, and (c) added to StateSnapshot so upgrades preserve it. State mutators 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 → TradingHaltedper-book, so the matching loop gates each book through this one method rather than a separate is_pair_halted filter), permit_deposit(caller) / permit_withdraw(caller) (return PreAsyncPermit). A globally- or per-pair-halted pair both surface the single TradingHalted — there is no distinct PairHalted.
  • Infallible — ungated in the permission layer, but not all truly unguarded: permit_cancel and permit_settling are genuinely ungated; permit_admin is the permit for the halt/upgrade events and is controller/lifecycle-gated at the endpoint; permit_add_trading_pair is 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_settling is 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 — after assert_caller_is_allowed, validate the order (unknown pair → UnknownTradingPair; tick/lot/notional → their errors), then the halt gate permit_trading(caller, book)?. Map UnauthorizedError::TradingHalted onto the internal + public AddLimitOrderError (a halted pair, global or per-pair, surfaces TradingHalted). The SyncPermit flows into the existing process_event(AddLimitOrder, …).

  • Matching (canister/src/execute/mod.rs) — run_once always drains in-flight settling first (drain_settling before any matching), then matches only the books for which permit_matching(book) is Ok. A book is gated by that one call: it returns Err(TradingHalted) under global halt (every book) and for a per-pair-halted book — so there is no separate is_pair_halted loop filter. Draining-first is required: a halt can land while pending_settling_events are 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 and permit_matching(book).is_ok(), so under global or per-pair halt run_once reschedules only for leftover settling (MoreWork iff has_pending_settling_events(), else Complete) 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-reconciled PreAsyncPermit: no record, no permit, no false trap.

  • cancel_limit_orderno 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-level Init append in lifecycle.rs is 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.

EndpointArgEvent
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 drives get_trading_pairsTradingStatus::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 than MAX_HALT_BOOKS (100) entries traps (ic_cdk::trap) before anything is recorded, bounding the size of the SetHalt audit 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):

  1. Global halt (R1, R2): under halt, add_limit_orderTradingHalted; a resting order placed pre-halt still cancels; a withdrawal of available balance succeeds; resume_trading re-enables orders.
  2. 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, and get_trading_pairs reports A Halted / B Trading. A controller targeting an unregistered pair traps; resume_trading(None) clears the per-pair halt too.
  3. 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.
  4. Authorization (R4): every admin endpoint rejects a non-controller with NotController.

Unit:

  • state/permissions/tests.rs: permit_trading/permit_matching return the right Ok/UnauthorizedError; the infallible permits return their permit unconditionally.
  • state/audit/tests.rs: each new EventType arm applies the expected mutation (R5 replay).
  • state/snapshot/tests.rs: from_state -> into_state round-trips both controls; an old-format snapshot (field absent) decodes to defaults (R5 upgrade).
  • execute/tests.rs: run_once is 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 + State field, snapshotted (backward-compatible); the full permit vocabulary — UnauthorizedError, SyncPermit, the async types (PreAsyncPermit/PostAsyncPermit/ reconcile), and Permit { Sync, Async }; one infallible permit_* per EventType, with permit_matching(book) taking the book and permit_deposit/permit_withdraw returning PreAsyncPermit (reconciled to Permit::Async at the deposit/withdraw recorder sites); thread the Permit parameter through process_event/record_event and every call site. Acceptance: no behavioral change (all existing tests pass); oisy_trade.did unchanged; snapshot round-trips empty + old-format decodes to default; every recorder call site supplies a permit; deposit/withdraw record via Permit::Async (the post-await reconcile is structurally present even though it never denies).
  • PR 2 — Global trading halt. trading_halted; the unified SetHalt event + arm + snapshot; permit_trading/permit_matching(book) gate the global halt; run_once drains settling then matches only permit_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 unified SetHalt event with the optional book_ids filter + arm + snapshot; permit_matching(book) and permit_trading extended with the per-book pair check (no separate matching-loop filter); the existing halt_trading/resume_trading endpoints gain the Option<Vec<TradingPair>> filter (per-pair halt reuses TradingHalted; 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 Authority guard parameter on the State mutators. Rejected: the mutators are the replay path, so replay would re-acquire the guard — which diverges for async ops (a permission change landing during an await is 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/System authority variant. Superseded by permit_*-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_async consuming PostAsyncPermit. Superseded: putting the Permit parameter on the existing process_event/record_event subsumes it and keeps a single recording API.
  • A single SyncOp/AsyncOp enum guard. Rejected: one enum shares a method surface and cannot express “a PreAsyncPermit must become a PostAsyncPermit”. 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_notional is rejected with InvalidNotional.
  • R2: An order whose notional > max_notional (when max_notional is set) is rejected with InvalidNotional.
  • R3: An order whose notional == min_notional exactly is accepted (boundary is inclusive).
  • R4: Pair creation rejects min_notional == 0.
  • R5: Pair creation rejects max_notional < min_notional (when max_notional is set).
  • R6: tick, lot, min_notional, and max_notional are enforced independently — an order may fail any one of them, and none is implied by another. No relationship is enforced between min_notional and tick_size × lot_size (a min_notional larger than one tick·lot is the normal, intended case).
  • R7: min_notional and max_notional round-trip through the state snapshot.

Non-goals

  • Per-token transfer-fee-aware auto-floor (enforcing min_notional ≥ icrc_transfer_fee at pair creation). Deferred until the ledger fee is queryable; for now an operator sets min_notional manually with the transfer fee in mind.
  • Market orders. None exist in the canister; the Binance avgPriceMins / applyMinToMarket / applyMaxToMarket behavior 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:

FieldValueMeaning
tick_size10_000$0.01 / ETH (= 0.01 × 10^6 ckUSDC base units)
lot_size100_000_000_000_0000.0001 ETH (= 0.0001 × 10^18 ckETH base units)
min_notional5_000_0005 ckUSDC (= 5 × 10^6)
max_notionalSome(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 exactly base_scale, so the remainder is 0 ✓

2. Place an accepted order — buy 0.1 ETH at $2,500/ETH

QuantityValueCheck
price2_500_000_000 (2_500 × 10^6)2_500_000_000 / 10_000 = 250_000 → on tick ✓
quantity100_000_000_000_000_000 (0.1 × 10^18)10^17 / 10^14 = 1_000 → on lot ✓
notional2_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 the State layer.
  • Trading-pair configuration is event-sourced: add_trading_pair builds an AddTradingPairEvent, the audit handler applies it via record_trading_pair, and snapshots persist the resulting OrderBook. New configuration must flow through every link in that chain.

Public types — libs/types/src/lib.rs

  • AddTradingPairRequest: add min_notional: Nat and max_notional: Option<Nat>.
  • AddTradingPairError: add a single InvalidNotional { 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 single InvalidNotional { 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: surface min_notional and max_notional in 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 to OrderBook::new.
  • validate_limit_order: after computing amount (the scaled notional), reject with the new internal AddLimitOrderError::InvalidNotional when amount < min_notional (R1) or, with max_notional set, when amount > max_notional (R2); == passes (R3). Extend the internal AddLimitOrderError enum and its From mapping to oisy_trade_types::AddLimitOrderError.

Persistence — canister/src/state/snapshot/mod.rs

OrderBookSnapshot persists and restores the two bounds (R7).

Interface & docs

  • canister/oisy_trade.did: update AddTradingPairRequest, AddTradingPairError, AddLimitOrderError, and TradingPairInfo.
  • 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_notionalInvalidNotional.
    • R2: notional above max_notionalInvalidNotional.
    • 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_invariant prop-test still holds.
  • canister/src/tests.rs (add_trading_pair module)
    • R4: min_notional == 0 rejected.
    • R5: max_notional < min_notional rejected.
  • 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_order against the raw price × quantity (the ticket’s literal pseudocode). Rejected: OrderBook has no base_scale, so it cannot compute the scaled quote amount, and the raw product is not a quote-token value — a min_notional expressed against it would be off by 10^base_decimals and meaningless. Threading base_scale into OrderBook would duplicate state that already lives, by deliberate design, at the State layer (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 as tick_size/lot_size, which already live on OrderBook; 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_orders returns each order’s cumulative filled amount in base-token units via OrderRecord.filled_quantity. Remaining is derivable as quantity − filled_quantity.
  • R2 — Partial fill is visible. A resting order that has been partially filled reports 0 < filled_quantity < quantity and status == Open.
  • R3 — Full fill. A fully filled order reports filled_quantity == quantity and status == Filled.
  • R4 — Pending. An order not yet matched reports filled_quantity == 0.
  • R5 — Cancel retains the fill. A canceled order reports the filled_quantity accumulated before cancel (unchanged by the cancel); status is the unit variant Canceled. Remaining-at-cancel is quantity − filled_quantity.
  • R6 — Point lookup, owner-scoped. get_my_orders with the ById(id) filter returns the single matching UserOrder when 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 (ById carries no cursor; ByPage carries after/length), so no page parameter is interpreted in lookup mode.
  • R7 — get_order_status removed. The get_order_status endpoint no longer exists in the canister interface; OrderStatus no longer has a NotFound variant. Absence from a get_my_orders result 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_at into a single read-modify-write.
  • R9 — Invariant and durability. filled_quantity is monotonic non-decreasing and never exceeds quantity. This is enforced by an always-on check that traps on violation (a BUG: panic, per the canister’s existing convention) — not a debug_assert!, since the canister ships in release where debug_assert! is compiled out — in addition to checked_add for overflow. filled_quantity is persisted in the stable-memory order history, survives canister upgrade, and the matching write path stays Write-gated so event-log replay does not double-count fills.
  • R10 — Order timestamps. OrderRecord exposes created_at (renamed from timestamp, set once at placement) and last_updated_at: Option<Timestamp>. last_updated_at is None until 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_quantity is added. They can layer on later as further OrderRecord fields without disturbing this work.
  • A PartiallyFilled status variant. Partialness is expressed by the filled_quantity field against quantity, not by splitting the resting state across Open / PartiallyFilled — see Design Decisions.
  • Cross-account / global order lookup. Removing get_order_status makes 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 an OrderStatus variant. status stays 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 existing Open/Filled transitions unchanged. (Why not a PartiallyFilled variant — 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_orders never 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; remove get_order_status. Fill information lives on OrderRecord, which get_my_orders already returns, so a single caller-scoped endpoint subsumes the bare status query. The argument becomes an optional ById | ByPage filter 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 NotFound and CanceledOrderInfo. With lookup folded into get_my_orders, not-found is signalled by absence from the result vector, so OrderStatus::NotFound disappears. With filled_quantity persisted, remaining-at-cancel is derivable, so CanceledOrderInfo disappears and Canceled becomes 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

  • OrderRecord gains filled_quantity: Nat (cumulative base-token amount filled), renames timestamp to created_at, and gains last_updated_at: Option<Timestamp> (R10).
  • OrderStatus drops NotFound; Canceled becomes a unit variant. Resulting set: Pending, Open, Filled, Canceled.
  • CanceledOrderInfo is removed.
  • GetMyOrdersArgs carries a single non-optional filter that is either a point lookup or a page, replacing the flat after/length pair. The endpoint takes opt GetMyOrdersArgs; an absent argument is the default (first page, newest first).
  • get_order_status is 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) gains filled_quantity: Quantity and last_updated_at: Option<Timestamp> as new trailing minicbor fields (append-only indices; never reuse), and renames timestamp to created_at.
  • The internal OrderStatus (order/mod.rs) Canceled variant becomes unit; the CanceledOrderInfo struct is removed.
  • OrderHistory replaces the set_status-only writer with a single combined writer, apply_update(&OrderId, OrderUpdate, now: Timestamp), where OrderUpdate { status: Option<OrderStatus>, filled_delta: Quantity }. It does one get + one insert, applying the status (if present), adding the delta via checked_add, and setting last_updated_at = Some(now). It enforces filled_quantity <= quantity with an always-on check that traps on violation (a BUG: panic, matching the codebase’s expect("BUG: …") convention) — not a debug_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 to GetMyOrdersArgs::default(); then match args.filter. ById(id) → resolve the caller’s UserId and return the single owned record as a one-element vec (empty if the id is unknown or owned by another principal). ByPage { after, length } → the existing newest-first cursor scan. The default filter is ByPage { after = None, length = MAX_ORDERS_PER_RESPONSE }.
  • Remove get_order_status (business fn in lib.rs, the #[ic_cdk::query] wrapper in main.rs, and state::get_order_status if otherwise unused).

Test plan

Unit (*/tests.rs, helpers/fixtures per repo convention):

  • order/history/tests.rs: apply_update applies status-only, delta-only, and status+delta in a single write and sets last_updated_at to now (R8, R10); the filled_quantity > quantity invariant traps in release build config (an always-on check, not a compiled-out debug_assert!) (R9).
  • state/tests.rs: a batch that partially fills a maker advances its filled_quantity without a status transition (R2); a fully filled order reaches filled_quantity == quantity + Filled (R3); cancel-after-partial keeps filled_quantity and writes unit Canceled (R5); a fill spanning multiple Fills for one order writes that order once (R8); created_at is unchanged after fills while last_updated_at advances (R10). Replay under Skip leaves filled_quantity untouched (R9).

Integration (integration_tests/tests/tests.rs, PocketIC):

  • Place a maker, partially fill it with a crossing taker, then get_my_orders shows 0 < filled_quantity < quantity, Open (R2); complete the fill → filled_quantity == quantity, Filled (R3).
  • get_my_orders with ById(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_quantity preserved (R5).
  • Existing tests that called get_order_status are migrated to get_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 PartiallyFilled OrderStatus variant (Binance style). Rejected: it splits the single “resting on the book” concept across Open and PartiallyFilled, while the matching engine marks every rester Open — 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_orders index makes a resting order’s remaining quantity reachable by id, so filled could be derived as quantity − live_remaining without 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 couples get_my_orders to 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_status for un-scoped lookup. Rejected: nothing requires looking up an order you do not own, and folding the lookup into get_my_orders removes a redundant endpoint and the NotFound variant. 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. LimitOrderRequest accepts an optional time_in_force. An absent value defaults to GoodTilCanceled, so every existing client keeps working unchanged.
  • R2 — GTC unchanged. A GoodTilCanceled order (explicit or defaulted) behaves exactly as today: it may rest, may fill partially, and reaches Open / Filled / Canceled through the existing transitions. No GTC observable behavior changes.
  • R3 — FOK full fill. A FOK order whose full quantity can be satisfied against resting liquidity at its price or better fills completely and reaches status == Filled with filled_quantity == quantity. It never rests in the book.
  • R4 — FOK kill. A FOK order whose full quantity cannot be satisfied reaches status == Expired with filled_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 keeps FeeRates.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 — Expired is distinct from Canceled. OrderStatus gains a new unit variant Expired, surfaced in the public Candid type. Canceled keeps its current meaning — explicit cancellation initiated by a user or an administrator (cancel_limit_order, admin sweep) — and Expired is 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 as Expired too. 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_force is recorded on its order record, surfaced on OrderRecord in 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_order enqueues a FOK and returns an OrderId exactly 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 only Pending → Filled or Pending → Expired; it never reaches Open. The caller observes the terminal outcome via get_my_orders.

Non-goals

  • IOC (Immediate-Or-Cancel). Conceptually the sibling of FOK (same TimeInForce enum, same always-taker fee, same Expired terminal 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 simply require_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; Expired here 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 AddLimitOrderEvent is recorded via state::audit::process_event and re-applied on replay; matching results are applied by State::record_matching_event only under StableMemoryOptions::Write (replay runs Skip). Any new persistence (the TIF field, the Expired transition) must flow through this chain and respect the Write gate so replay does not double-apply. In particular, AddLimitOrderEvent itself must carry time_in_force (append-only minicbor field, defaulting to GoodTilCanceled for pre-existing events): a FOK logged as Pending and replayed before process_pending_orders runs 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).
  • OrderStatus is shared between the internal engine (canister/src/order/mod.rs) and the public Candid type (libs/types/src/lib.rs); both must gain Expired.

Public types & Candid — libs/types/src/lib.rs, canister/oisy_trade.did

  • New TimeInForce enum: GoodTilCanceled, FillOrKill.
  • LimitOrderRequest gains time_in_force: Option<TimeInForce> (absent ⇒ GoodTilCanceled, R1). Candid: time_in_force : opt TimeInForce.
  • OrderStatus gains the unit variant Expired (R8). Candid: variant { Pending; Open; Filled; Canceled; Expired }.
  • OrderRecord gains time_in_force: TimeInForce (R9).
  • The candid equality check must pass (oisy_trade.did regenerated/updated).

Order model — canister/src/order

  • PendingOrder / Order carry time_in_force; PendingOrder::try_from(LimitOrderRequest) reads the optional field, defaulting to GoodTilCanceled.
  • Internal OrderStatus (order/mod.rs) gains Expired (next minicbor index).
  • Internal OrderRecord (order/history) gains time_in_force as 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 (asks ascending while price ≤ order price; bids descending while price ≥ order price — the same crossing predicate as today’s match_order break) and, FIFO within each level, records one PlannedFill { maker_seq, maker_price, fill_qty, maker_emptied } per maker it would touch, accumulating until the order is satisfied. Returns FillPlan { fills, fully_filled }. No mutation.
  • apply_plan(side, &FillPlan, &mut Order, &mut fills_out, &mut filled_orders) — mutating. Replays the plan: for each PlannedFill, reduce the maker (held at the front of its level — re-acquire the level cursor only when maker_price changes, so cost stays O(L log p + f), not O(f log p)), reduce the taker, push the Fill, and on maker_emptied pop the maker, drop its resting_orders index entry, insert into filled_orders, and remove the level if its queue empties. An always-on check (expect("BUG: …") / assert_eq!, matching the codebase convention — not debug_assert!, which the release canister compiles out, per the DEFI-2852 invariant convention) asserts the maker at the level front matches planned.maker_seq, trapping on any plan/apply divergence rather than corrupting the book.
  • execute(order, require_full) -> Executionlet plan = plan_fills(..); if require_full && !plan.fully_filled return Execution::Killed { seq } before any mutation (R4, R5); else apply_plan(..) and, per the existing tail, return Filled when the remainder is zero or rest it (insert_order) and return Resting. match_order becomes execute(order, false) (GTC unchanged, R2); FOK is execute(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.rs tests:
    • 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-refactor match_order — i.e. the refactor is behavior-preserving (R2). The always-on plan/apply-divergence check in apply_plan holds throughout.
    • Kill is mutation-free: when plan_fills(..).fully_filled is false, execute(order, true) returns Killed and the book is byte-identical to its pre-call state — compared via the OrderBookSnapshot round-trip (no PartialEq on OrderBook needed) (R4, R5). Covers the some-but-insufficient liquidity case explicitly (R5).
    • Boundary + fill: available liquidity exactly equal to quantityFilled (inclusive); a fillable FOK produces Filled with the expected fills (R3).
  • 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 records Filled / filled_quantity == quantity and releases nothing extra (R3); a FOK that can’t fill records Expired / 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 is GoodTilCanceled (R1, R2).
  • order/history + state/snapshot tests: time_in_force round-trips through the record and a snapshot; replay under Skip does 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).
  • Expired is distinct from Canceled in the Candid surface; a user cancel still yields Canceled (R8).
  • Absent time_in_force behaves 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_fills and apply_plan, rewrite match_order as 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. The require_full gate, Killed outcome, and expired_orders are 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. TimeInForce enum; optional time_in_force on LimitOrderRequest defaulting to GTC; OrderStatus::Expired (internal + public + Candid); time_in_force on the order model, record, snapshot, AddLimitOrderEvent, and OrderRecord. 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_full gate to execute (the Killed outcome) and expired_orders to MatchingOutput; drive require_full from time_in_force in process_pending_orders; map expired_orders to Pending → Expired plus reservation release in record_matching_event; update design.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_order endpoint. 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 flat OrderRecord fields and made Canceled a unit variant. A unit Expired is consistent with the current model; the “executedQty == 0” property is expressed by the existing filled_quantity field, not a status payload.
  • Synchronous inline FOK (match within the add_limit_order call). 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 polls get_my_orders for 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’s match_order (guaranteed Filled). 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 with match_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’s apply_plan provides. 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_order while 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 reaches Filled or Expired, both of which already exist (or are added as the single Expired unit 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_balances and list_supported_tokens queries (#27, #28, #30, #99, #98)
  • Trading pairs: add_trading_pair and get_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_pairs and 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_orders query; 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.