From 4ce3c35d7f81c0523af7176dd4165511f2c676c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 23 Jun 2026 10:16:03 +0000 Subject: [PATCH 01/35] feat(order): roll up realized quote and fee onto OrderRecord MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `filled_quote` and `filled_fee` to the order record so `get_my_orders` exposes per-order realized notional and fee summaries (DEFI-2901, PR 1 of 3). `filled_quote` is the cumulative realized quote notional `Σ (maker_price × quantity / base_scale)`; a buy taker's released reservation surplus is excluded, so VWAP is derivable as `filled_quote × base_scale / filled_quantity`. `filled_fee` is the cumulative realized fee in the order's receive token (base for a buy, quote for a sell), the amount actually withheld. `OrderUpdate` gains matching `quote_delta` / `fee_delta`, folded into the same single read-modify-write as `filled_quantity` and `status`, each guarded by an always-on overflow trap. Settlement computes the per-fill notional, fees, and maker/taker roles once and feeds both the balance operations and the per-order deltas from that single source. Co-Authored-By: Claude Opus 4.8 (1M context) --- canister/oisy_trade.did | 7 ++ canister/src/order/history/mod.rs | 45 +++++++- canister/src/order/history/tests.rs | 70 +++++++++++- canister/src/state/mod.rs | 160 +++++++++++++++++++++------- canister/src/state/tests.rs | 145 ++++++++++++++++++++++++- canister/src/test_fixtures/mod.rs | 7 ++ canister/src/tests.rs | 4 + integration_tests/tests/tests.rs | 2 + libs/types/src/lib.rs | 7 ++ 9 files changed, 405 insertions(+), 42 deletions(-) diff --git a/canister/oisy_trade.did b/canister/oisy_trade.did index ed2c9d4b..89f69bfb 100644 --- a/canister/oisy_trade.did +++ b/canister/oisy_trade.did @@ -281,6 +281,13 @@ type OrderRecord = record { last_updated_at : opt nat64; /// Time-in-force policy the order was placed with. time_in_force : TimeInForce; + /// Cumulative realized quote notional transacted across the order's fills, + /// in quote-token smallest units. A buy taker's released reservation + /// surplus is excluded; VWAP is `filled_quote × base_scale / filled_quantity`. + filled_quote : nat; + /// Cumulative realized fee charged across the order's fills, in the order's + /// receive token — base for a buy, quote for a sell. + filled_fee : nat; }; /// Request for `get_my_orders`. diff --git a/canister/src/order/history/mod.rs b/canister/src/order/history/mod.rs index 6ca0e501..5f5193ca 100644 --- a/canister/src/order/history/mod.rs +++ b/canister/src/order/history/mod.rs @@ -45,6 +45,15 @@ pub struct OrderRecord { /// Time-in-force policy the order was placed with. #[n(8)] pub time_in_force: TimeInForce, + /// Cumulative realized quote notional transacted across the order's fills, + /// `Σ (maker_price × fill_quantity / base_scale)`. Always quote-denominated; + /// a buy taker's released reservation surplus is excluded. + #[n(9)] + pub filled_quote: Quantity, + /// Cumulative realized fee charged across the order's fills, denominated in + /// the order's receive token — base for a buy, quote for a sell. + #[n(10)] + pub filled_fee: Quantity, } impl From for oisy_trade_types::OrderRecord { @@ -59,17 +68,22 @@ impl From for oisy_trade_types::OrderRecord { created_at: record.created_at.as_nanos(), last_updated_at: record.last_updated_at.map(|t| t.as_nanos()), time_in_force: record.time_in_force.into(), + filled_quote: record.filled_quote.into(), + filled_fee: record.filled_fee.into(), } } } /// A combined update to an order record, applied in a single read-modify-write -/// by [`OrderHistory::apply_update`]: an optional status transition plus a -/// fill delta to add to `filled_quantity`. +/// by [`OrderHistory::apply_update`]: an optional status transition plus the +/// fill, quote, and fee deltas to add to `filled_quantity`, `filled_quote`, and +/// `filled_fee`. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct OrderUpdate { pub status: Option, pub filled_delta: Quantity, + pub quote_delta: Quantity, + pub fee_delta: Quantity, } impl OrderUpdate { @@ -78,6 +92,8 @@ impl OrderUpdate { Self { status: Some(status), filled_delta: Quantity::ZERO, + quote_delta: Quantity::ZERO, + fee_delta: Quantity::ZERO, } } @@ -86,6 +102,8 @@ impl OrderUpdate { Self { status: None, filled_delta, + quote_delta: Quantity::ZERO, + fee_delta: Quantity::ZERO, } } @@ -94,13 +112,16 @@ impl OrderUpdate { /// # Panics /// /// `filled_quantity` is monotonic non-decreasing and must never exceed - /// `quantity`; this invariant is enforced by an always-on check that traps - /// on violation. + /// `quantity`; `filled_quote` and `filled_fee` are monotonic + /// non-decreasing. These invariants are enforced by always-on checks that + /// trap on violation. pub fn apply(self, order: &mut OrderRecord) -> bool { let mut changed = false; let OrderUpdate { status, filled_delta, + quote_delta, + fee_delta, } = self; if let Some(new_status) = status @@ -125,6 +146,22 @@ impl OrderUpdate { order.created_at, ); } + + if quote_delta != Quantity::ZERO { + changed = true; + order.filled_quote = order + .filled_quote + .checked_add(quote_delta) + .expect("BUG: filled_quote overflow"); + } + + if fee_delta != Quantity::ZERO { + changed = true; + order.filled_fee = order + .filled_fee + .checked_add(fee_delta) + .expect("BUG: filled_fee overflow"); + } changed } } diff --git a/canister/src/order/history/tests.rs b/canister/src/order/history/tests.rs index e5552ac7..bb82c30b 100644 --- a/canister/src/order/history/tests.rs +++ b/canister/src/order/history/tests.rs @@ -29,6 +29,8 @@ fn test_record() -> OrderRecord { created_at: Timestamp::EPOCH, last_updated_at: None, time_in_force: TimeInForce::FillOrKill, + filled_quote: Quantity::ZERO, + filled_fee: Quantity::ZERO, } } @@ -134,6 +136,8 @@ fn apply_update_status_and_delta_in_one_write() { OrderUpdate { status: Some(OrderStatus::Open), filled_delta: Quantity::from(300_000u64), + quote_delta: Quantity::from(3u64), + fee_delta: Quantity::from(1u64), }, Timestamp::new(11), ); @@ -142,16 +146,80 @@ fn apply_update_status_and_delta_in_one_write() { OrderUpdate { status: Some(OrderStatus::Filled), filled_delta: Quantity::from(700_000u64), + quote_delta: Quantity::from(7u64), + fee_delta: Quantity::from(2u64), }, Timestamp::new(13), ); let record = history.get(&id).expect("record present"); - // Deltas accumulate; status reflects the latest update. + // The fill, quote, and fee deltas all accumulate within the same single + // read-modify-write; status reflects the latest update. assert_eq!(record.status, OrderStatus::Filled); assert_eq!(record.filled_quantity, Quantity::from(1_000_000u64)); + assert_eq!(record.filled_quote, Quantity::from(10u64)); + assert_eq!(record.filled_fee, Quantity::from(3u64)); assert_eq!(record.last_updated_at, Some(Timestamp::new(13))); } +#[test] +fn apply_update_accumulates_quote_and_fee_in_one_write() { + let mut history = history(); + let id = order_id(0); + history.insert_once(UserId::new(0), id, test_record()); + + // A fill-only update that carries quote and fee deltas writes all three + // scalars (and `last_updated_at`) in a single read-modify-write, leaving + // status untouched. + history.apply_update( + &id, + OrderUpdate { + status: None, + filled_delta: Quantity::from(200_000u64), + quote_delta: Quantity::from(20_000_000u64), + fee_delta: Quantity::from(10_000u64), + }, + Timestamp::new(5), + ); + let record = history.get(&id).expect("record present"); + assert_eq!(record.status, OrderStatus::Pending); + assert_eq!(record.filled_quantity, Quantity::from(200_000u64)); + assert_eq!(record.filled_quote, Quantity::from(20_000_000u64)); + assert_eq!(record.filled_fee, Quantity::from(10_000u64)); + assert_eq!(record.last_updated_at, Some(Timestamp::new(5))); +} + +#[test] +#[should_panic(expected = "BUG: filled_quote overflow")] +fn apply_update_traps_on_filled_quote_overflow() { + // The monotonic `filled_quote` invariant is enforced by an always-on trap, + // not a `debug_assert!` compiled out of the release canister: starting from + // `Quantity::MAX`, any positive `quote_delta` overflows and must panic even + // when tests run in release config. + let mut record = test_record(); + record.filled_quote = Quantity::MAX; + OrderUpdate { + status: None, + filled_delta: Quantity::ZERO, + quote_delta: Quantity::from(1u64), + fee_delta: Quantity::ZERO, + } + .apply(&mut record); +} + +#[test] +#[should_panic(expected = "BUG: filled_fee overflow")] +fn apply_update_traps_on_filled_fee_overflow() { + let mut record = test_record(); + record.filled_fee = Quantity::MAX; + OrderUpdate { + status: None, + filled_delta: Quantity::ZERO, + quote_delta: Quantity::ZERO, + fee_delta: Quantity::from(1u64), + } + .apply(&mut record); +} + #[test] fn apply_update_is_a_noop_when_update_is_a_noop() { let mut history = history(); diff --git a/canister/src/state/mod.rs b/canister/src/state/mod.rs index fd40677a..d78f79bb 100644 --- a/canister/src/state/mod.rs +++ b/canister/src/state/mod.rs @@ -269,6 +269,8 @@ impl State { created_at: timestamp, last_updated_at: None, time_in_force: order.time_in_force(), + filled_quote: Quantity::ZERO, + filled_fee: Quantity::ZERO, }, ); } @@ -404,6 +406,10 @@ impl State { .expect("BUG: trading pair registered but order book missing"); let fee_rates = book.fee_rates(); let output = book.process_pending_orders(&event.orders); + // Compute the realized notional, fees, and roles once per fill, then + // feed both the balance operations and the per-order scalar deltas from + // that single source so the two can never diverge. + let settlements = fill_settlements(&output, fee_rates, base_scale); if matches!(persistence, StableMemoryOptions::Write) { #[cfg(feature = "canbench-rs")] let _p = canbench_rs::bench_scope("status"); @@ -412,14 +418,21 @@ impl State { // taker sweeping several makers, or a maker hit repeatedly) and an // order can both change status and accrue fills in the same batch. let mut updates: BTreeMap = BTreeMap::new(); - for fill in &output.fills { - for seq in [fill.taker_order_seq, fill.maker_order_seq] { - let entry = updates.entry(seq).or_default(); - entry.filled_delta = entry - .filled_delta - .checked_add(fill.quantity) - .expect("BUG: filled_delta overflow"); - } + for settlement in &settlements { + let taker = updates.entry(settlement.taker_order_seq).or_default(); + accrue_fill( + taker, + settlement.quantity, + settlement.notional, + settlement.taker_fee, + ); + let maker = updates.entry(settlement.maker_order_seq).or_default(); + accrue_fill( + maker, + settlement.quantity, + settlement.notional, + settlement.maker_fee, + ); } for seq in &output.resting_orders { updates.entry(*seq).or_default().status = Some(OrderStatus::Open); @@ -432,7 +445,7 @@ impl State { self.order_history.apply_update(&order_id, update, now); } } - let balance_operations = compute_balance_operations(&output, fee_rates, base_scale); + let balance_operations = compute_balance_operations(&settlements); if !balance_operations.is_empty() { self.pending_settling_events .push_back(event::SettlingEvent { @@ -913,44 +926,100 @@ fn resolve_op_users( .collect() } -fn compute_balance_operations( +/// The realized values of a single fill, computed once in settlement (the only +/// point where both `fee_rates` and `base_scale` are in scope) and reused to +/// build both the [`event::BalanceOperation`]s and the per-order scalar deltas, +/// so the two can never diverge (R11). +struct FillSettlement { + taker_order_seq: OrderSeq, + maker_order_seq: OrderSeq, + taker_side: Side, + /// Base quantity exchanged on this fill. + quantity: Quantity, + /// Quote notional `maker_price × quantity / base_scale` (the executed + /// price; a buy taker's reservation surplus is excluded). + notional: Quantity, + /// Fee charged to the taker order, in its receive token (base if the taker + /// bought, quote if it sold). + taker_fee: Quantity, + /// Fee charged to the maker order, in its receive token. + maker_fee: Quantity, + /// Quote surplus released back to a buy taker that crossed below its limit; + /// `None` for a sell taker or an exact-price fill. + surplus: Option, +} + +/// Compute the realized values of every fill once. +fn fill_settlements( output: &MatchingOutput, fee_rates: FeeRates, base_scale: NonZeroU64, -) -> Vec { - let mut ops = Vec::with_capacity(output.fills.len() * 3); - for fill in &output.fills { - let (buyer_seq, seller_seq) = match fill.taker_side { - Side::Buy => (fill.taker_order_seq, fill.maker_order_seq), - Side::Sell => (fill.maker_order_seq, fill.taker_order_seq), +) -> Vec { + output + .fills + .iter() + .map(|fill| { + // Receive-side convention: buyer pays fee in base (the asset they + // receive), seller in quote. Each side's rate is `taker` if they + // were the taker, else `maker`. + let (buyer_rate, seller_rate) = match fill.taker_side { + Side::Buy => (fee_rates.taker, fee_rates.maker), + Side::Sell => (fee_rates.maker, fee_rates.taker), + }; + let notional = fill.quote_amount(base_scale); + let quote_fee = seller_rate.mul_ceil(notional); + let base_fee = buyer_rate.mul_ceil(fill.quantity); + // The taker pays on the side it traded: base if it bought, quote if + // it sold. The maker pays on the opposite side. + let (taker_fee, maker_fee) = match fill.taker_side { + Side::Buy => (base_fee, quote_fee), + Side::Sell => (quote_fee, base_fee), + }; + let surplus = if fill.taker_side == Side::Buy + && let Some(diff) = fill.taker_price.checked_sub(fill.maker_price) + && !diff.is_zero() + { + Some(diff.checked_mul_quantity_scaled(&fill.quantity, base_scale).expect( + "BUG: price_diff * quantity overflow — validated in validate_limit_order", + )) + } else { + None + }; + FillSettlement { + taker_order_seq: fill.taker_order_seq, + maker_order_seq: fill.maker_order_seq, + taker_side: fill.taker_side, + quantity: fill.quantity, + notional, + taker_fee, + maker_fee, + surplus, + } + }) + .collect() +} + +fn compute_balance_operations(settlements: &[FillSettlement]) -> Vec { + let mut ops = Vec::with_capacity(settlements.len() * 3); + for settlement in settlements { + let (buyer_seq, seller_seq) = match settlement.taker_side { + Side::Buy => (settlement.taker_order_seq, settlement.maker_order_seq), + Side::Sell => (settlement.maker_order_seq, settlement.taker_order_seq), }; - // Receive-side convention: buyer pays fee in base (the asset they - // receive), seller in quote. Each side's rate is `taker` if they - // were the taker, else `maker`. - let (buyer_rate, seller_rate) = match fill.taker_side { - Side::Buy => (fee_rates.taker, fee_rates.maker), - Side::Sell => (fee_rates.maker, fee_rates.taker), + let (quote_fee, base_fee) = match settlement.taker_side { + Side::Buy => (settlement.maker_fee, settlement.taker_fee), + Side::Sell => (settlement.taker_fee, settlement.maker_fee), }; - let notional = fill.quote_amount(base_scale); - let quote_fee = seller_rate.mul_ceil(notional); - let base_fee = buyer_rate.mul_ceil(fill.quantity); - ops.push(event::BalanceOperation::Transfer { from_order: buyer_seq, to_order: seller_seq, token: order::PairToken::Quote, - amount: notional, + amount: settlement.notional, fee: nonzero(quote_fee), }); - if fill.taker_side == Side::Buy - && let Some(diff) = fill.taker_price.checked_sub(fill.maker_price) - && !diff.is_zero() - { - let surplus = diff - .checked_mul_quantity_scaled(&fill.quantity, base_scale) - .expect("BUG: price_diff * quantity overflow — validated in validate_limit_order"); + if let Some(surplus) = settlement.surplus { ops.push(event::BalanceOperation::Unreserve { - order: fill.taker_order_seq, + order: settlement.taker_order_seq, token: order::PairToken::Quote, amount: surplus, }); @@ -959,13 +1028,32 @@ fn compute_balance_operations( from_order: seller_seq, to_order: buyer_seq, token: order::PairToken::Base, - amount: fill.quantity, + amount: settlement.quantity, fee: nonzero(base_fee), }); } ops } +/// Fold one fill's realized values into an [`OrderUpdate`] entry. `filled_delta` +/// accumulates the gross base quantity (DEFI-2852), `quote_delta` the realized +/// notional (R1), and `fee_delta` the realized fee in the order's receive token +/// (R2). All three are accumulated with always-on overflow traps (R9). +fn accrue_fill(update: &mut OrderUpdate, quantity: Quantity, notional: Quantity, fee: Quantity) { + update.filled_delta = update + .filled_delta + .checked_add(quantity) + .expect("BUG: filled_delta overflow"); + update.quote_delta = update + .quote_delta + .checked_add(notional) + .expect("BUG: quote_delta overflow"); + update.fee_delta = update + .fee_delta + .checked_add(fee) + .expect("BUG: fee_delta overflow"); +} + /// Collapse a zero-quantity fee to `None`. Keeps `Some(_)` reserved for /// "fee was actually charged" so callers (audit log, apply path, /// `/metrics`) can distinguish "no fee on this fill" from "fee of zero diff --git a/canister/src/state/tests.rs b/canister/src/state/tests.rs index b3c2a6ef..d0119465 100644 --- a/canister/src/state/tests.rs +++ b/canister/src/state/tests.rs @@ -1741,6 +1741,8 @@ mod settle_fills { created_at: sell.created_at, last_updated_at: sell.last_updated_at, time_in_force: TimeInForce::GoodTilCanceled, + filled_quote: Quantity::from(100 * lot), + filled_fee: Quantity::ZERO, }, ); assert_eq!(status_of(&state, BUYER, buy_id), Some(OrderStatus::Filled)); @@ -1784,6 +1786,8 @@ mod settle_fills { created_at: sell.created_at, last_updated_at: sell.last_updated_at, time_in_force: TimeInForce::GoodTilCanceled, + filled_quote: Quantity::from(100 * lot), + filled_fee: Quantity::ZERO, }, ); // The taker rests `Open` with one of three lots filled. @@ -1800,6 +1804,8 @@ mod settle_fills { created_at: buy.created_at, last_updated_at: buy.last_updated_at, time_in_force: TimeInForce::GoodTilCanceled, + filled_quote: Quantity::from(100 * lot), + filled_fee: Quantity::ZERO, }, ); } @@ -1997,6 +2003,11 @@ mod settle_fills { let buy = record_of(&state, BUYER, buy_id); assert_eq!(buy.status, OrderStatus::Pending); assert_eq!(buy.filled_quantity, Quantity::ZERO); + // The realized-value scalars are written under the same `Write` + // gate as `filled_quantity`, so replay under `Skip` leaves them at + // zero too — no double-counting of quote or fee. + assert_eq!(buy.filled_quote, Quantity::ZERO); + assert_eq!(buy.filled_fee, Quantity::ZERO); assert_eq!(buy.last_updated_at, None); } } @@ -2025,11 +2036,12 @@ mod settle_fills { use crate::order::{self, PairToken}; use crate::state::event::BalanceOperation; - let ops = super::super::compute_balance_operations( + let settlements = super::super::fill_settlements( &output, FeeRates::default(), std::num::NonZeroU64::new(PRICE_SCALE as u64).unwrap(), ); + let ops = super::super::compute_balance_operations(&settlements); let fills_len = output.fills.len(); prop_assert!( @@ -2314,6 +2326,137 @@ mod settle_fills { } } + /// Order-level `filled_quote` / `filled_fee` from the DEFI-2901 worked + /// example. The pair is ICP (base, 8 decimals) / ckUSDT (quote); the test + /// fixture's base scale is `10^8`, so every stored figure below matches the + /// spec's smallest-unit numbers exactly. + mod realized_scalars { + use super::{BUYER, SELLER, TestState}; + use crate::EXECUTOR; + use crate::order::{ + BasisPoint, FeeRates, OrderBookId, OrderId, OrderStatus, Quantity, Side, + }; + use crate::test_fixtures::mocks::mock_runtime_for; + use crate::test_fixtures::{ + self, LOT_SIZE, MAX_NOTIONAL, MIN_NOTIONAL, TICK_SIZE, ckbtc_metadata, + icp_ckbtc_trading_pair, icp_metadata, + }; + use candid::Principal; + + // Maker B and the two distinct maker levels need a third principal. + const MAKER_B: Principal = Principal::from_slice(&[0x03]); + + // Prices and quantities in smallest units (base scale = 10^8). + const PRICE_10: u128 = 10_000_000; + const PRICE_11: u128 = 11_000_000; + const PRICE_12: u128 = 12_000_000; + const QTY_2: u128 = 200_000_000; + const QTY_3: u128 = 300_000_000; + const QTY_5: u128 = 500_000_000; + + fn setup(maker_bps: u16, taker_bps: u16) -> TestState { + let mut state = test_fixtures::state(); + state.record_trading_pair( + OrderBookId::ZERO, + icp_ckbtc_trading_pair(), + icp_metadata(), + ckbtc_metadata(), + TICK_SIZE, + LOT_SIZE, + MIN_NOTIONAL, + Some(MAX_NOTIONAL), + FeeRates { + maker: BasisPoint::new(maker_bps).unwrap(), + taker: BasisPoint::new(taker_bps).unwrap(), + }, + ); + state + } + + fn record(state: &TestState, owner: Principal, id: OrderId) -> crate::order::OrderRecord { + state + .get_user_order(&owner, id) + .map(|(_, _, record)| record) + .expect("order record present") + } + + /// A buy taker sweeps two maker levels (2 ICP @ 10, 3 ICP @ 11) with + /// taker 10 bps / maker 5 bps. The taker's `filled_quote` is the realized + /// notional 53 ckUSDT (the 7-ckUSDT reservation surplus is excluded), and + /// its `filled_fee` is the base-denominated 0.005 ICP. Each maker records + /// its own quote-denominated fee. + #[test] + fn buy_taker_sweeping_two_levels_rolls_up_quote_and_fee() { + let mut state = setup(5, 10); + let pair = icp_ckbtc_trading_pair(); + + let maker_a = + test_fixtures::place_order(&mut state, SELLER, &pair, Side::Sell, PRICE_10, QTY_2); + let maker_b = + test_fixtures::place_order(&mut state, MAKER_B, &pair, Side::Sell, PRICE_11, QTY_3); + let taker = + test_fixtures::place_order(&mut state, BUYER, &pair, Side::Buy, PRICE_12, QTY_5); + EXECUTOR.run_once(&mut state, &mock_runtime_for(Principal::anonymous())); + + // Taker buy: gross 5 ICP filled, realized notional 20 + 33 = 53 + // ckUSDT, fee 0.002 + 0.003 = 0.005 ICP (base-denominated). The + // 7-ckUSDT reservation surplus is released, not part of filled_quote. + let taker = record(&state, BUYER, taker); + assert_eq!(taker.status, OrderStatus::Filled); + assert_eq!(taker.filled_quantity, Quantity::from(QTY_5)); + assert_eq!(taker.filled_quote, Quantity::from(53_000_000u128)); + assert_eq!(taker.filled_fee, Quantity::from(500_000u128)); + // VWAP = filled_quote × base_scale / filled_quantity = 10.6 ckUSDT/ICP. + let base_scale = 100_000_000u128; + let vwap = taker.filled_quote.as_u128().unwrap() * base_scale + / taker.filled_quantity.as_u128().unwrap(); + assert_eq!(vwap, 10_600_000); + + // Maker A (Fill 1): 20 ckUSDT notional, 0.01 ckUSDT fee (quote). + let maker_a = record(&state, SELLER, maker_a); + assert_eq!(maker_a.filled_quantity, Quantity::from(QTY_2)); + assert_eq!(maker_a.filled_quote, Quantity::from(20_000_000u128)); + assert_eq!(maker_a.filled_fee, Quantity::from(10_000u128)); + + // Maker B (Fill 2): 33 ckUSDT notional, 0.0165 ckUSDT fee (quote). + let maker_b = record(&state, MAKER_B, maker_b); + assert_eq!(maker_b.filled_quantity, Quantity::from(QTY_3)); + assert_eq!(maker_b.filled_quote, Quantity::from(33_000_000u128)); + assert_eq!(maker_b.filled_fee, Quantity::from(16_500u128)); + } + + /// A single order that crosses on entry (taker leg) and then rests and is + /// hit (maker leg) within the same batch accrues both fills' realized + /// quote and fee, written exactly once. The taker leg is charged the + /// taker rate, the maker leg the maker rate. + #[test] + fn order_filling_both_ways_in_one_batch_writes_once() { + let mut state = setup(5, 10); + let pair = icp_ckbtc_trading_pair(); + + // A resting ask the middle order will cross as taker. + test_fixtures::place_order(&mut state, SELLER, &pair, Side::Sell, PRICE_10, QTY_2); + // The order under test: a buy for 5 ICP @ 10 — crosses the 2-ICP ask + // (taker), then rests with 3 ICP open. + let pivot = + test_fixtures::place_order(&mut state, BUYER, &pair, Side::Buy, PRICE_10, QTY_5); + // A sell that hits the pivot's resting 3 ICP (pivot is now maker). + test_fixtures::place_order(&mut state, MAKER_B, &pair, Side::Sell, PRICE_10, QTY_3); + EXECUTOR.run_once(&mut state, &mock_runtime_for(Principal::anonymous())); + + // Taker leg: 2 ICP @ 10 → notional 20 ckUSDT. Maker leg: 3 ICP @ 10 + // → notional 30 ckUSDT. filled_quote = 20 + 30 = 50 ckUSDT. + let pivot = record(&state, BUYER, pivot); + assert_eq!(pivot.status, OrderStatus::Filled); + assert_eq!(pivot.filled_quantity, Quantity::from(QTY_5)); + assert_eq!(pivot.filled_quote, Quantity::from(50_000_000u128)); + // Buyer pays base fee on both legs: taker leg 10 bps × 2 ICP = + // 0.002 ICP (200_000); maker leg 5 bps × 3 ICP = 0.0015 ICP + // (150_000). Total 0.0035 ICP (350_000), all base-denominated. + assert_eq!(pivot.filled_fee, Quantity::from(350_000u128)); + } + } + fn balance(free: impl Into, reserved: impl Into) -> Balance { Balance::new(free, reserved) } diff --git a/canister/src/test_fixtures/mod.rs b/canister/src/test_fixtures/mod.rs index c4d34e05..22c78417 100644 --- a/canister/src/test_fixtures/mod.rs +++ b/canister/src/test_fixtures/mod.rs @@ -671,6 +671,13 @@ pub mod arbitrary { created_at, last_updated_at, time_in_force, + filled_quote: Quantity::from( + u128::from(filled_lots) + * u128::from(lot) + * u128::from(price_ticks) + * tick, + ), + filled_fee: Quantity::from(u128::from(filled_lots)), }) }, ) diff --git a/canister/src/tests.rs b/canister/src/tests.rs index 27054983..650a29d0 100644 --- a/canister/src/tests.rs +++ b/canister/src/tests.rs @@ -746,6 +746,8 @@ mod cancel_limit_order { created_at: 111, last_updated_at: Some(222), time_in_force: oisy_trade_types::TimeInForce::GoodTilCanceled, + filled_quote: candid::Nat::from(0u64), + filled_fee: candid::Nat::from(0u64), }) ); @@ -805,6 +807,8 @@ mod cancel_limit_order { created_at: 111, last_updated_at: Some(222), time_in_force: oisy_trade_types::TimeInForce::GoodTilCanceled, + filled_quote: candid::Nat::from(0u64), + filled_fee: candid::Nat::from(0u64), }; assert_eq!(result, Ok(expected.clone())); let orders = crate::get_my_orders( diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index f6bbd9f3..b07166eb 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -615,6 +615,8 @@ mod cancel_limit_order { created_at: canceled.created_at, last_updated_at: canceled.last_updated_at, time_in_force: TimeInForce::GoodTilCanceled, + filled_quote: Nat::from(1_000_000_000u64), + filled_fee: Nat::from(0u64), } ); diff --git a/libs/types/src/lib.rs b/libs/types/src/lib.rs index 02aec799..410d54bd 100644 --- a/libs/types/src/lib.rs +++ b/libs/types/src/lib.rs @@ -217,6 +217,13 @@ pub struct OrderRecord { pub last_updated_at: Option, /// Time-in-force policy the order was placed with. pub time_in_force: TimeInForce, + /// Cumulative realized quote notional transacted across the order's fills. + /// Always quote-denominated; a buy taker's released reservation surplus is + /// excluded. VWAP is `filled_quote × base_scale / filled_quantity`. + pub filled_quote: Nat, + /// Cumulative realized fee charged across the order's fills, denominated in + /// the order's receive token — base for a buy, quote for a sell. + pub filled_fee: Nat, } /// Maximum number of orders returned by a single `get_my_orders` call. From a86e05dc1c4930c46cb21d0ab65b39146bbc1520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 23 Jun 2026 10:34:17 +0000 Subject: [PATCH 02/35] test(order): label worked-example fixture as ckBTC, note spec uses ckUSDT Co-Authored-By: Claude Opus 4.8 (1M context) --- canister/src/state/tests.rs | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/canister/src/state/tests.rs b/canister/src/state/tests.rs index d0119465..c8b71000 100644 --- a/canister/src/state/tests.rs +++ b/canister/src/state/tests.rs @@ -2327,9 +2327,11 @@ mod settle_fills { } /// Order-level `filled_quote` / `filled_fee` from the DEFI-2901 worked - /// example. The pair is ICP (base, 8 decimals) / ckUSDT (quote); the test - /// fixture's base scale is `10^8`, so every stored figure below matches the - /// spec's smallest-unit numbers exactly. + /// example. The spec writes that example in ICP/ckUSDT (8/6 decimals); this + /// test reuses the ICP/ckBTC fixture (both 8 decimals). The stored + /// smallest-unit figures depend only on `base_scale = 10^8` and the + /// smallest-unit prices/quantities — not on the quote token's decimals — so + /// every value below matches the spec's numbers exactly. mod realized_scalars { use super::{BUYER, SELLER, TestState}; use crate::EXECUTOR; @@ -2382,7 +2384,7 @@ mod settle_fills { /// A buy taker sweeps two maker levels (2 ICP @ 10, 3 ICP @ 11) with /// taker 10 bps / maker 5 bps. The taker's `filled_quote` is the realized - /// notional 53 ckUSDT (the 7-ckUSDT reservation surplus is excluded), and + /// notional 53 ckBTC (the 7-ckBTC reservation surplus is excluded), and /// its `filled_fee` is the base-denominated 0.005 ICP. Each maker records /// its own quote-denominated fee. #[test] @@ -2399,26 +2401,26 @@ mod settle_fills { EXECUTOR.run_once(&mut state, &mock_runtime_for(Principal::anonymous())); // Taker buy: gross 5 ICP filled, realized notional 20 + 33 = 53 - // ckUSDT, fee 0.002 + 0.003 = 0.005 ICP (base-denominated). The - // 7-ckUSDT reservation surplus is released, not part of filled_quote. + // ckBTC, fee 0.002 + 0.003 = 0.005 ICP (base-denominated). The + // 7-ckBTC reservation surplus is released, not part of filled_quote. let taker = record(&state, BUYER, taker); assert_eq!(taker.status, OrderStatus::Filled); assert_eq!(taker.filled_quantity, Quantity::from(QTY_5)); assert_eq!(taker.filled_quote, Quantity::from(53_000_000u128)); assert_eq!(taker.filled_fee, Quantity::from(500_000u128)); - // VWAP = filled_quote × base_scale / filled_quantity = 10.6 ckUSDT/ICP. + // VWAP = filled_quote × base_scale / filled_quantity = 10.6 ckBTC/ICP. let base_scale = 100_000_000u128; let vwap = taker.filled_quote.as_u128().unwrap() * base_scale / taker.filled_quantity.as_u128().unwrap(); assert_eq!(vwap, 10_600_000); - // Maker A (Fill 1): 20 ckUSDT notional, 0.01 ckUSDT fee (quote). + // Maker A (Fill 1): 20 ckBTC notional, 0.01 ckBTC fee (quote). let maker_a = record(&state, SELLER, maker_a); assert_eq!(maker_a.filled_quantity, Quantity::from(QTY_2)); assert_eq!(maker_a.filled_quote, Quantity::from(20_000_000u128)); assert_eq!(maker_a.filled_fee, Quantity::from(10_000u128)); - // Maker B (Fill 2): 33 ckUSDT notional, 0.0165 ckUSDT fee (quote). + // Maker B (Fill 2): 33 ckBTC notional, 0.0165 ckBTC fee (quote). let maker_b = record(&state, MAKER_B, maker_b); assert_eq!(maker_b.filled_quantity, Quantity::from(QTY_3)); assert_eq!(maker_b.filled_quote, Quantity::from(33_000_000u128)); @@ -2444,8 +2446,8 @@ mod settle_fills { test_fixtures::place_order(&mut state, MAKER_B, &pair, Side::Sell, PRICE_10, QTY_3); EXECUTOR.run_once(&mut state, &mock_runtime_for(Principal::anonymous())); - // Taker leg: 2 ICP @ 10 → notional 20 ckUSDT. Maker leg: 3 ICP @ 10 - // → notional 30 ckUSDT. filled_quote = 20 + 30 = 50 ckUSDT. + // Taker leg: 2 ICP @ 10 → notional 20 ckBTC. Maker leg: 3 ICP @ 10 + // → notional 30 ckBTC. filled_quote = 20 + 30 = 50 ckBTC. let pivot = record(&state, BUYER, pivot); assert_eq!(pivot.status, OrderStatus::Filled); assert_eq!(pivot.filled_quantity, Quantity::from(QTY_5)); From 17109c567c274a5f05be633083c13b9ed9c95e35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 23 Jun 2026 10:48:28 +0000 Subject: [PATCH 03/35] perf(state): settle fills in a single pass without an intermediate Vec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `record_matching_event` materialized every fill's settlement into a `Vec` and then iterated it twice — once to accrue the per-order scalar deltas and once to build the balance operations. The struct carries several u256-backed `Quantity` fields, so the allocation, store, and reload were paid for on the matching hot path for every fill. Fold the compute and both consumers into one pass over `output.fills`: each fill's notional, fees, and roles are computed exactly once and feed both the balance operations (always) and the per-order deltas (only under `StableMemoryOptions::Write`), preserving the R11 single-source guarantee without the intermediate `Vec`. Co-Authored-By: Claude Opus 4.8 (1M context) --- canister/src/state/mod.rs | 192 +++++++++++++++++------------------- canister/src/state/tests.rs | 13 +-- 2 files changed, 100 insertions(+), 105 deletions(-) diff --git a/canister/src/state/mod.rs b/canister/src/state/mod.rs index d78f79bb..a43f39ca 100644 --- a/canister/src/state/mod.rs +++ b/canister/src/state/mod.rs @@ -18,8 +18,8 @@ use crate::Task; use crate::Timestamp; use crate::balance::{Balance, TokenBalance}; use crate::order::{ - self, FeeRates, LotSize, MatchOrderError, MatchingOutput, NotionalError, Order, OrderBook, - OrderBookId, OrderHistory, OrderId, OrderRecord, OrderSeq, OrderStatus, OrderUpdate, PairToken, + self, FeeRates, Fill, LotSize, MatchOrderError, NotionalError, Order, OrderBook, OrderBookId, + OrderHistory, OrderId, OrderRecord, OrderSeq, OrderStatus, OrderUpdate, PairToken, PendingOrder, Quantity, RemovedOrder, Side, TickSize, TokenId, TokenMetadata, TradingPair, }; use crate::storage::VMem; @@ -406,34 +406,41 @@ impl State { .expect("BUG: trading pair registered but order book missing"); let fee_rates = book.fee_rates(); let output = book.process_pending_orders(&event.orders); - // Compute the realized notional, fees, and roles once per fill, then - // feed both the balance operations and the per-order scalar deltas from - // that single source so the two can never diverge. - let settlements = fill_settlements(&output, fee_rates, base_scale); - if matches!(persistence, StableMemoryOptions::Write) { - #[cfg(feature = "canbench-rs")] - let _p = canbench_rs::bench_scope("status"); - // Fold the batch into one update per touched order, then write each - // once: a single batch can fill one order across many `Fill`s (a - // taker sweeping several makers, or a maker hit repeatedly) and an - // order can both change status and accrue fills in the same batch. - let mut updates: BTreeMap = BTreeMap::new(); - for settlement in &settlements { - let taker = updates.entry(settlement.taker_order_seq).or_default(); + // Single pass over the fills: compute each fill's realized notional, + // fees, and roles once (`FillSettlement`), then feed both the balance + // operations (always) and the per-order scalar deltas (only when + // writing) from that one value, so the two can never diverge (R11). The + // per-fill `Quantity` arithmetic is u256-wide, so it is done exactly + // once and the intermediate is never materialized into a `Vec`. + let write = matches!(persistence, StableMemoryOptions::Write); + let mut balance_operations = Vec::with_capacity(output.fills.len() * 3); + // Folds the batch into one update per touched order, written once each: + // a single batch can fill one order across many `Fill`s (a taker + // sweeping several makers, or a maker hit repeatedly) and an order can + // both change status and accrue fills in the same batch. Empty (and + // unused) outside the write path. + let mut updates: BTreeMap = BTreeMap::new(); + for fill in &output.fills { + let settlement = fill_settlement(fill, fee_rates, base_scale); + push_balance_operations(&mut balance_operations, &settlement); + if write { accrue_fill( - taker, + updates.entry(settlement.taker_order_seq).or_default(), settlement.quantity, settlement.notional, settlement.taker_fee, ); - let maker = updates.entry(settlement.maker_order_seq).or_default(); accrue_fill( - maker, + updates.entry(settlement.maker_order_seq).or_default(), settlement.quantity, settlement.notional, settlement.maker_fee, ); } + } + if write { + #[cfg(feature = "canbench-rs")] + let _p = canbench_rs::bench_scope("status"); for seq in &output.resting_orders { updates.entry(*seq).or_default().status = Some(OrderStatus::Open); } @@ -445,7 +452,6 @@ impl State { self.order_history.apply_update(&order_id, update, now); } } - let balance_operations = compute_balance_operations(&settlements); if !balance_operations.is_empty() { self.pending_settling_events .push_back(event::SettlingEvent { @@ -949,90 +955,78 @@ struct FillSettlement { surplus: Option, } -/// Compute the realized values of every fill once. -fn fill_settlements( - output: &MatchingOutput, - fee_rates: FeeRates, - base_scale: NonZeroU64, -) -> Vec { - output - .fills - .iter() - .map(|fill| { - // Receive-side convention: buyer pays fee in base (the asset they - // receive), seller in quote. Each side's rate is `taker` if they - // were the taker, else `maker`. - let (buyer_rate, seller_rate) = match fill.taker_side { - Side::Buy => (fee_rates.taker, fee_rates.maker), - Side::Sell => (fee_rates.maker, fee_rates.taker), - }; - let notional = fill.quote_amount(base_scale); - let quote_fee = seller_rate.mul_ceil(notional); - let base_fee = buyer_rate.mul_ceil(fill.quantity); - // The taker pays on the side it traded: base if it bought, quote if - // it sold. The maker pays on the opposite side. - let (taker_fee, maker_fee) = match fill.taker_side { - Side::Buy => (base_fee, quote_fee), - Side::Sell => (quote_fee, base_fee), - }; - let surplus = if fill.taker_side == Side::Buy - && let Some(diff) = fill.taker_price.checked_sub(fill.maker_price) - && !diff.is_zero() - { - Some(diff.checked_mul_quantity_scaled(&fill.quantity, base_scale).expect( - "BUG: price_diff * quantity overflow — validated in validate_limit_order", - )) - } else { - None - }; - FillSettlement { - taker_order_seq: fill.taker_order_seq, - maker_order_seq: fill.maker_order_seq, - taker_side: fill.taker_side, - quantity: fill.quantity, - notional, - taker_fee, - maker_fee, - surplus, - } - }) - .collect() +/// Compute the realized values of a single fill once. +fn fill_settlement(fill: &Fill, fee_rates: FeeRates, base_scale: NonZeroU64) -> FillSettlement { + // Receive-side convention: buyer pays fee in base (the asset they + // receive), seller in quote. Each side's rate is `taker` if they + // were the taker, else `maker`. + let (buyer_rate, seller_rate) = match fill.taker_side { + Side::Buy => (fee_rates.taker, fee_rates.maker), + Side::Sell => (fee_rates.maker, fee_rates.taker), + }; + let notional = fill.quote_amount(base_scale); + let quote_fee = seller_rate.mul_ceil(notional); + let base_fee = buyer_rate.mul_ceil(fill.quantity); + // The taker pays on the side it traded: base if it bought, quote if + // it sold. The maker pays on the opposite side. + let (taker_fee, maker_fee) = match fill.taker_side { + Side::Buy => (base_fee, quote_fee), + Side::Sell => (quote_fee, base_fee), + }; + let surplus = if fill.taker_side == Side::Buy + && let Some(diff) = fill.taker_price.checked_sub(fill.maker_price) + && !diff.is_zero() + { + Some( + diff.checked_mul_quantity_scaled(&fill.quantity, base_scale) + .expect("BUG: price_diff * quantity overflow — validated in validate_limit_order"), + ) + } else { + None + }; + FillSettlement { + taker_order_seq: fill.taker_order_seq, + maker_order_seq: fill.maker_order_seq, + taker_side: fill.taker_side, + quantity: fill.quantity, + notional, + taker_fee, + maker_fee, + surplus, + } } -fn compute_balance_operations(settlements: &[FillSettlement]) -> Vec { - let mut ops = Vec::with_capacity(settlements.len() * 3); - for settlement in settlements { - let (buyer_seq, seller_seq) = match settlement.taker_side { - Side::Buy => (settlement.taker_order_seq, settlement.maker_order_seq), - Side::Sell => (settlement.maker_order_seq, settlement.taker_order_seq), - }; - let (quote_fee, base_fee) = match settlement.taker_side { - Side::Buy => (settlement.maker_fee, settlement.taker_fee), - Side::Sell => (settlement.taker_fee, settlement.maker_fee), - }; - ops.push(event::BalanceOperation::Transfer { - from_order: buyer_seq, - to_order: seller_seq, +/// Push the (up to three) balance operations a single fill settles into `ops`. +fn push_balance_operations(ops: &mut Vec, settlement: &FillSettlement) { + let (buyer_seq, seller_seq) = match settlement.taker_side { + Side::Buy => (settlement.taker_order_seq, settlement.maker_order_seq), + Side::Sell => (settlement.maker_order_seq, settlement.taker_order_seq), + }; + let (quote_fee, base_fee) = match settlement.taker_side { + Side::Buy => (settlement.maker_fee, settlement.taker_fee), + Side::Sell => (settlement.taker_fee, settlement.maker_fee), + }; + ops.push(event::BalanceOperation::Transfer { + from_order: buyer_seq, + to_order: seller_seq, + token: order::PairToken::Quote, + amount: settlement.notional, + fee: nonzero(quote_fee), + }); + if let Some(surplus) = settlement.surplus { + ops.push(event::BalanceOperation::Unreserve { + order: settlement.taker_order_seq, token: order::PairToken::Quote, - amount: settlement.notional, - fee: nonzero(quote_fee), - }); - if let Some(surplus) = settlement.surplus { - ops.push(event::BalanceOperation::Unreserve { - order: settlement.taker_order_seq, - token: order::PairToken::Quote, - amount: surplus, - }); - } - ops.push(event::BalanceOperation::Transfer { - from_order: seller_seq, - to_order: buyer_seq, - token: order::PairToken::Base, - amount: settlement.quantity, - fee: nonzero(base_fee), + amount: surplus, }); } - ops + ops.push(event::BalanceOperation::Transfer { + from_order: seller_seq, + to_order: buyer_seq, + token: order::PairToken::Base, + amount: settlement.quantity, + fee: nonzero(base_fee), + }); } /// Fold one fill's realized values into an [`OrderUpdate`] entry. `filled_delta` diff --git a/canister/src/state/tests.rs b/canister/src/state/tests.rs index c8b71000..d5c1367a 100644 --- a/canister/src/state/tests.rs +++ b/canister/src/state/tests.rs @@ -2036,12 +2036,13 @@ mod settle_fills { use crate::order::{self, PairToken}; use crate::state::event::BalanceOperation; - let settlements = super::super::fill_settlements( - &output, - FeeRates::default(), - std::num::NonZeroU64::new(PRICE_SCALE as u64).unwrap(), - ); - let ops = super::super::compute_balance_operations(&settlements); + let base_scale = std::num::NonZeroU64::new(PRICE_SCALE as u64).unwrap(); + let mut ops = Vec::new(); + for fill in &output.fills { + let settlement = + super::super::fill_settlement(fill, FeeRates::default(), base_scale); + super::super::push_balance_operations(&mut ops, &settlement); + } let fills_len = output.fills.len(); prop_assert!( From e71fe50680396079d55e970fcbe5cdef0cb53321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 23 Jun 2026 10:51:41 +0000 Subject: [PATCH 04/35] chore(canister): refresh canbench baseline for order-level scalars Regenerate `canbench_results.yml` to capture the expected cost of the `filled_quote`/`filled_fee` order-level rollup. The remaining increase on `process_pending_orders` (matching ~+10-14%, the per-fill `qty` rollup +28-35%, `order_history::apply_update` +3-6%) is the inherent per-fill u256 notional/fee arithmetic over up to 1000 fills, which the single-pass settlement already keeps to one computation per fill. Co-Authored-By: Claude Opus 4.8 (1M context) --- canister/canbench_results.yml | 228 +++++++++++++++++----------------- 1 file changed, 114 insertions(+), 114 deletions(-) diff --git a/canister/canbench_results.yml b/canister/canbench_results.yml index 63e5aee3..748760b3 100644 --- a/canister/canbench_results.yml +++ b/canister/canbench_results.yml @@ -2,35 +2,35 @@ benches: bench_get_my_orders: total: calls: 1 - instructions: 53042949 + instructions: 53572952 heap_increase: 0 stable_memory_increase: 0 scopes: order_history: calls: 1010 - instructions: 43066019 + instructions: 43091436 heap_increase: 0 stable_memory_increase: 0 order_history::get: calls: 1000 - instructions: 38074486 + instructions: 38132888 heap_increase: 0 stable_memory_increase: 0 order_history::orders_after: calls: 10 - instructions: 3427166 + instructions: 3394353 heap_increase: 0 stable_memory_increase: 0 bench_get_order_book_depth_default: total: calls: 1 - instructions: 723480 + instructions: 723716 heap_increase: 0 stable_memory_increase: 0 scopes: qty: calls: 200 - instructions: 275198 + instructions: 275292 heap_increase: 0 stable_memory_increase: 0 qty::add: @@ -41,13 +41,13 @@ benches: bench_get_order_book_depth_max: total: calls: 1 - instructions: 6000160 + instructions: 6000313 heap_increase: 0 stable_memory_increase: 0 scopes: qty: calls: 1697 - instructions: 2332205 + instructions: 2332299 heap_increase: 0 stable_memory_increase: 0 qty::add: @@ -58,7 +58,7 @@ benches: bench_get_order_book_ticker: total: calls: 1 - instructions: 9838 + instructions: 9913 heap_increase: 0 stable_memory_increase: 0 scopes: @@ -75,93 +75,93 @@ benches: bench_process_pending_orders_1000: total: calls: 1 - instructions: 1022269460 + instructions: 1034342347 heap_increase: 0 stable_memory_increase: 0 scopes: bal: calls: 3481 - instructions: 15971620 + instructions: 17536597 heap_increase: 0 stable_memory_increase: 0 bal::debit_reserved: calls: 1538 - instructions: 4697975 + instructions: 4876700 heap_increase: 0 stable_memory_increase: 0 bal::deposit: calls: 1538 - instructions: 3829731 + instructions: 4828614 heap_increase: 0 stable_memory_increase: 0 bal::unreserve: calls: 405 - instructions: 2169922 + instructions: 2440151 heap_increase: 0 stable_memory_increase: 0 balances: calls: 1943 - instructions: 798561316 + instructions: 796505496 heap_increase: 0 stable_memory_increase: 0 balances::transfer: calls: 1538 - instructions: 700212128 + instructions: 700015930 heap_increase: 0 stable_memory_increase: 0 balances::unreserve: calls: 405 - instructions: 94029315 + instructions: 94038053 heap_increase: 0 stable_memory_increase: 0 book::apply_plan: calls: 1000 - instructions: 5456562 + instructions: 5471869 heap_increase: 0 stable_memory_increase: 0 book::insert_order: calls: 473 - instructions: 1296699 + instructions: 1297338 heap_increase: 0 stable_memory_increase: 0 book::match_order: calls: 1000 - instructions: 14097598 + instructions: 14113802 heap_increase: 0 stable_memory_increase: 0 book::plan_fills: calls: 1000 - instructions: 3236288 + instructions: 3236370 heap_increase: 0 stable_memory_increase: 0 matching: calls: 1 - instructions: 134369171 + instructions: 148447641 heap_increase: 0 stable_memory_increase: 0 order_history: calls: 1785 - instructions: 128529612 + instructions: 130722723 heap_increase: 0 stable_memory_increase: 0 order_history::apply_update: calls: 1010 - instructions: 96034668 + instructions: 99152066 heap_increase: 0 stable_memory_increase: 0 order_history::get: calls: 775 - instructions: 28483353 + instructions: 28963318 heap_increase: 0 stable_memory_increase: 0 qty: - calls: 13324 - instructions: 26563492 + calls: 17175 + instructions: 34823254 heap_increase: 0 stable_memory_increase: 0 qty::add: - calls: 4256 - instructions: 1502368 + calls: 8107 + instructions: 2861771 heap_increase: 0 stable_memory_increase: 0 qty::checked_sub: @@ -176,7 +176,7 @@ benches: stable_memory_increase: 0 qty::mul_u128: calls: 1174 - instructions: 4262306 + instructions: 5131919 heap_increase: 0 stable_memory_increase: 0 qty::mul_u64: @@ -186,18 +186,18 @@ benches: stable_memory_increase: 0 settling: calls: 1 - instructions: 882401641 + instructions: 880392684 heap_increase: 0 stable_memory_increase: 0 status: calls: 1 - instructions: 106210300 + instructions: 103778413 heap_increase: 0 stable_memory_increase: 0 bench_process_pending_orders_1000_no_fills: total: calls: 1 - instructions: 94719211 + instructions: 96666747 heap_increase: 0 stable_memory_increase: 0 scopes: @@ -208,12 +208,12 @@ benches: stable_memory_increase: 0 book::insert_order: calls: 1000 - instructions: 1695926 + instructions: 1697445 heap_increase: 0 stable_memory_increase: 0 book::match_order: calls: 1000 - instructions: 6890733 + instructions: 6889221 heap_increase: 0 stable_memory_increase: 0 book::plan_fills: @@ -223,114 +223,114 @@ benches: stable_memory_increase: 0 matching: calls: 1 - instructions: 94174283 + instructions: 96122145 heap_increase: 0 stable_memory_increase: 0 order_history: calls: 1000 - instructions: 80809962 + instructions: 82334219 heap_increase: 0 stable_memory_increase: 0 order_history::apply_update: calls: 1000 - instructions: 78743564 + instructions: 80282385 heap_increase: 0 stable_memory_increase: 0 status: calls: 1 - instructions: 83885239 + instructions: 85832715 heap_increase: 0 stable_memory_increase: 0 bench_process_pending_orders_1000_with_fees: total: calls: 1 - instructions: 1039339015 + instructions: 1058452026 heap_increase: 0 stable_memory_increase: 0 scopes: bal: calls: 3481 - instructions: 16295345 + instructions: 16884853 heap_increase: 0 stable_memory_increase: 0 bal::debit_reserved: calls: 1538 - instructions: 4369308 + instructions: 4876700 heap_increase: 0 stable_memory_increase: 0 bal::deposit: calls: 1538 - instructions: 4486080 + instructions: 4172296 heap_increase: 0 stable_memory_increase: 0 bal::unreserve: calls: 405 - instructions: 2170023 + instructions: 2440251 heap_increase: 0 stable_memory_increase: 0 balances: calls: 1943 - instructions: 802152178 + instructions: 801837834 heap_increase: 0 stable_memory_increase: 0 balances::transfer: calls: 1538 - instructions: 703991011 + instructions: 705279225 heap_increase: 0 stable_memory_increase: 0 balances::unreserve: calls: 405 - instructions: 93849915 + instructions: 94093705 heap_increase: 0 stable_memory_increase: 0 book::apply_plan: calls: 1000 - instructions: 5456562 + instructions: 5471869 heap_increase: 0 stable_memory_increase: 0 book::insert_order: calls: 473 - instructions: 1296699 + instructions: 1297338 heap_increase: 0 stable_memory_increase: 0 book::match_order: calls: 1000 - instructions: 14097598 + instructions: 14113802 heap_increase: 0 stable_memory_increase: 0 book::plan_fills: calls: 1000 - instructions: 3236288 + instructions: 3236370 heap_increase: 0 stable_memory_increase: 0 matching: calls: 1 - instructions: 147433882 + instructions: 166680052 heap_increase: 0 stable_memory_increase: 0 order_history: calls: 1785 - instructions: 128508669 + instructions: 133760472 heap_increase: 0 stable_memory_increase: 0 order_history::apply_update: calls: 1010 - instructions: 96034668 + instructions: 102106378 heap_increase: 0 stable_memory_increase: 0 order_history::get: calls: 775 - instructions: 28483353 + instructions: 29001241 heap_increase: 0 stable_memory_increase: 0 qty: - calls: 19476 - instructions: 38678240 + calls: 24102 + instructions: 49588955 heap_increase: 0 stable_memory_increase: 0 qty::add: - calls: 7332 - instructions: 2588196 + calls: 11958 + instructions: 4221174 heap_increase: 0 stable_memory_increase: 0 qty::checked_sub: @@ -345,7 +345,7 @@ benches: stable_memory_increase: 0 qty::mul_u128: calls: 1174 - instructions: 4213003 + instructions: 4876163 heap_increase: 0 stable_memory_increase: 0 qty::mul_u64: @@ -355,49 +355,49 @@ benches: stable_memory_increase: 0 settling: calls: 1 - instructions: 885974538 + instructions: 885835358 heap_increase: 0 stable_memory_increase: 0 status: calls: 1 - instructions: 106210300 + instructions: 106741632 heap_increase: 0 stable_memory_increase: 0 bench_process_pending_orders_1_large: total: calls: 1 - instructions: 803355400 + instructions: 815926642 heap_increase: 0 stable_memory_increase: 0 scopes: bal: calls: 2788 - instructions: 12017847 + instructions: 12085401 heap_increase: 0 stable_memory_increase: 0 bal::debit_reserved: calls: 1394 - instructions: 4207980 + instructions: 4465049 heap_increase: 0 stable_memory_increase: 0 bal::deposit: calls: 1394 - instructions: 3650569 + instructions: 3578398 heap_increase: 0 stable_memory_increase: 0 balances: calls: 1394 - instructions: 643190133 + instructions: 642904877 heap_increase: 0 stable_memory_increase: 0 balances::transfer: calls: 1394 - instructions: 640423768 + instructions: 640149135 heap_increase: 0 stable_memory_increase: 0 book::apply_plan: calls: 1 - instructions: 4513205 + instructions: 4512097 heap_increase: 0 stable_memory_increase: 0 book::insert_order: @@ -407,42 +407,42 @@ benches: stable_memory_increase: 0 book::match_order: calls: 1 - instructions: 6374013 + instructions: 6373012 heap_increase: 0 stable_memory_increase: 0 book::plan_fills: calls: 1 - instructions: 1852630 + instructions: 1852743 heap_increase: 0 stable_memory_increase: 0 matching: calls: 1 - instructions: 86617988 + instructions: 98860414 heap_increase: 0 stable_memory_increase: 0 order_history: calls: 1396 - instructions: 92590557 + instructions: 95808231 heap_increase: 0 stable_memory_increase: 0 order_history::apply_update: calls: 698 - instructions: 64709177 + instructions: 68296044 heap_increase: 0 stable_memory_increase: 0 order_history::get: calls: 698 - instructions: 24688538 + instructions: 25398067 heap_increase: 0 stable_memory_increase: 0 qty: - calls: 9759 - instructions: 18628678 + calls: 13245 + instructions: 25107819 heap_increase: 0 stable_memory_increase: 0 qty::add: - calls: 3486 - instructions: 1230558 + calls: 6972 + instructions: 2461116 heap_increase: 0 stable_memory_increase: 0 qty::checked_sub: @@ -457,7 +457,7 @@ benches: stable_memory_increase: 0 qty::mul_u128: calls: 697 - instructions: 2550924 + instructions: 2943216 heap_increase: 0 stable_memory_increase: 0 qty::mul_u64: @@ -467,39 +467,39 @@ benches: stable_memory_increase: 0 settling: calls: 1 - instructions: 713108710 + instructions: 713418624 heap_increase: 0 stable_memory_increase: 0 status: calls: 1 - instructions: 73101267 + instructions: 71277414 heap_increase: 0 stable_memory_increase: 0 bench_read_events: total: calls: 1 - instructions: 16223614 + instructions: 16107037 heap_increase: 12 stable_memory_increase: 0 scopes: AddLimitOrder: calls: 1 - instructions: 10727 + instructions: 10681 heap_increase: 0 stable_memory_increase: 0 AddTradingPair: calls: 1 - instructions: 16169 + instructions: 16172 heap_increase: 0 stable_memory_increase: 0 CancelLimitOrder: calls: 1 - instructions: 4743 + instructions: 4720 heap_increase: 0 stable_memory_increase: 0 Deposit: calls: 1 - instructions: 7368 + instructions: 7345 heap_increase: 0 stable_memory_increase: 0 Init: @@ -509,17 +509,17 @@ benches: stable_memory_increase: 0 Matching: calls: 1 - instructions: 403971 + instructions: 403317 heap_increase: 0 stable_memory_increase: 0 SetHalt: calls: 1 - instructions: 47296 + instructions: 46643 heap_increase: 0 stable_memory_increase: 0 Settling: calls: 1 - instructions: 15675486 + instructions: 15560395 heap_increase: 12 stable_memory_increase: 0 Upgrade: @@ -529,24 +529,24 @@ benches: stable_memory_increase: 0 Withdraw: calls: 1 - instructions: 7637 + instructions: 7540 heap_increase: 0 stable_memory_increase: 0 bench_upgrade_1000_no_fills: total: calls: 1 - instructions: 4657182 + instructions: 4657227 heap_increase: 0 stable_memory_increase: 128 scopes: post_upgrade: calls: 1 - instructions: 2850855 + instructions: 2851336 heap_increase: 0 stable_memory_increase: 0 post_upgrade::into_state: calls: 1 - instructions: 1204573 + instructions: 1204501 heap_increase: 0 stable_memory_increase: 0 post_upgrade::load_snapshot: @@ -561,39 +561,39 @@ benches: stable_memory_increase: 0 pre_upgrade: calls: 1 - instructions: 1687772 + instructions: 1687602 heap_increase: 0 stable_memory_increase: 128 pre_upgrade::from_state: calls: 1 - instructions: 65440 + instructions: 65363 heap_increase: 0 stable_memory_increase: 0 pre_upgrade::save_snapshot: calls: 1 - instructions: 1601334 + instructions: 1600975 heap_increase: 0 stable_memory_increase: 128 bench_upgrade_full_depth: total: calls: 1 - instructions: 60972407 + instructions: 60850042 heap_increase: 0 stable_memory_increase: 128 scopes: post_upgrade: calls: 1 - instructions: 36972069 + instructions: 36816825 heap_increase: 0 stable_memory_increase: 0 post_upgrade::into_state: calls: 1 - instructions: 19544318 + instructions: 19388287 heap_increase: 0 stable_memory_increase: 0 post_upgrade::load_snapshot: calls: 1 - instructions: 17401956 + instructions: 17401626 heap_increase: 0 stable_memory_increase: 0 post_upgrade::load_stable_memory: @@ -603,12 +603,12 @@ benches: stable_memory_increase: 0 pre_upgrade: calls: 1 - instructions: 20544646 + instructions: 20550289 heap_increase: 0 stable_memory_increase: 128 pre_upgrade::from_state: calls: 1 - instructions: 3878727 + instructions: 3881494 heap_increase: 0 stable_memory_increase: 0 pre_upgrade::save_snapshot: @@ -619,58 +619,58 @@ benches: bench_write_events: total: calls: 1 - instructions: 22959433 + instructions: 23067143 heap_increase: 7 stable_memory_increase: 0 scopes: AddLimitOrder: calls: 1 - instructions: 13431 + instructions: 13237 heap_increase: 0 stable_memory_increase: 0 AddTradingPair: calls: 1 - instructions: 17308 + instructions: 16955 heap_increase: 0 stable_memory_increase: 0 CancelLimitOrder: calls: 1 - instructions: 6585 + instructions: 6502 heap_increase: 0 stable_memory_increase: 0 Deposit: calls: 1 - instructions: 10528 + instructions: 10302 heap_increase: 0 stable_memory_increase: 0 Init: calls: 1 - instructions: 23774 + instructions: 23129 heap_increase: 0 stable_memory_increase: 0 Matching: calls: 1 - instructions: 621937 + instructions: 621854 heap_increase: 1 stable_memory_increase: 0 SetHalt: calls: 1 - instructions: 69748 + instructions: 69665 heap_increase: 0 stable_memory_increase: 0 Settling: calls: 1 - instructions: 22150567 + instructions: 22260256 heap_increase: 6 stable_memory_increase: 0 Upgrade: calls: 1 - instructions: 21651 + instructions: 21627 heap_increase: 0 stable_memory_increase: 0 Withdraw: calls: 1 - instructions: 10935 + instructions: 10741 heap_increase: 0 stable_memory_increase: 0 version: 0.4.1 From 0efe8e53777e7200ad21c33b38e9464ff4e16e86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 23 Jun 2026 11:02:58 +0000 Subject: [PATCH 05/35] test(canister): derive arb_order_record notional via base_scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compute the proptest generator's `filled_quote` the way the engine does (`maker_price × filled_quantity / base_scale`, cf. `Fill::quote_amount`), so generated `OrderRecord`s are semantically consistent with realized quote notional instead of inflated by `base_scale`. Co-Authored-By: Claude Opus 4.8 (1M context) --- canister/src/test_fixtures/mod.rs | 64 +++++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 17 deletions(-) diff --git a/canister/src/test_fixtures/mod.rs b/canister/src/test_fixtures/mod.rs index 22c78417..5768dae9 100644 --- a/canister/src/test_fixtures/mod.rs +++ b/canister/src/test_fixtures/mod.rs @@ -57,6 +57,13 @@ pub fn ckbtc_metadata() -> TokenMetadata { } } +pub fn ckusdt_metadata() -> TokenMetadata { + TokenMetadata { + symbol: "ckUSDT".to_string(), + decimals: 6, + } +} + pub fn base_metadata() -> TokenMetadata { TokenMetadata { symbol: "BASE".to_string(), @@ -156,10 +163,24 @@ pub fn icp_ckbtc_trading_pair() -> TradingPair { } } +/// ICP (base, 8 decimals) / ckUSDT (quote, 6 decimals) pair for the DEFI-2901 +/// worked example. Base stays ICP, so `base_scale = 10^8` is unchanged from the +/// ckBTC pair; only the quote token's decimals differ. +pub fn icp_ckusdt_trading_pair() -> TradingPair { + TradingPair { + base: icp_token_id(), + quote: ckusdt_token_id(), + } +} + pub fn ckbtc_token_id() -> TokenId { TokenId::new(Principal::from_text("mxzaz-hqaaa-aaaar-qaada-cai").unwrap()) } +pub fn ckusdt_token_id() -> TokenId { + TokenId::new(Principal::from_text("cngnf-vqaaa-aaaar-qag4q-cai").unwrap()) +} + pub fn icp_token_id() -> TokenId { TokenId::new(Principal::from_text("ryjl3-tyaaa-aaaaa-aaaba-cai").unwrap()) } @@ -661,23 +682,32 @@ pub mod arbitrary { last_updated_at, time_in_force, )| { - (0..=qty_lots).prop_map(move |filled_lots| OrderRecord { - owner, - side, - price: Price::new(price_ticks as u128 * tick), - quantity: Quantity::from(qty_lots * lot), - filled_quantity: Quantity::from(filled_lots * lot), - status, - created_at, - last_updated_at, - time_in_force, - filled_quote: Quantity::from( - u128::from(filled_lots) - * u128::from(lot) - * u128::from(price_ticks) - * tick, - ), - filled_fee: Quantity::from(u128::from(filled_lots)), + (0..=qty_lots).prop_map(move |filled_lots| { + let price = Price::new(price_ticks as u128 * tick); + let filled_quantity = Quantity::from(filled_lots * lot); + // Realized quote notional, derived exactly as the engine + // does: `maker_price × filled_quantity / base_scale` + // (cf. `Fill::quote_amount`), with `base_scale = 10^8` + // for this fixture. + let filled_quote = price + .checked_mul_quantity_scaled( + &filled_quantity, + NonZeroU64::new(100_000_000).unwrap(), + ) + .expect("fixture notional fits in 256 bits"); + OrderRecord { + owner, + side, + price, + quantity: Quantity::from(qty_lots * lot), + filled_quantity, + status, + created_at, + last_updated_at, + time_in_force, + filled_quote, + filled_fee: Quantity::from(u128::from(filled_lots)), + } }) }, ) From 1128a86c6e6194a6e5553453cb4233b66c708f05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 23 Jun 2026 11:03:17 +0000 Subject: [PATCH 06/35] test(canister): make realized_scalars a literal ICP/ckUSDT example Register the new ICP (8-dec) / ckUSDT (6-dec) fixture in the `realized_scalars` worked-example test and restore the ckUSDT labels, so the smallest-unit prices/fees literally mean ckUSDT amounts as in the DEFI-2901 spec. Rewrite the module doc accordingly; base stays ICP so `base_scale = 10^8` and every stored integer is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- canister/src/state/tests.rs | 39 ++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/canister/src/state/tests.rs b/canister/src/state/tests.rs index d5c1367a..67e2d13f 100644 --- a/canister/src/state/tests.rs +++ b/canister/src/state/tests.rs @@ -2327,12 +2327,11 @@ mod settle_fills { } } - /// Order-level `filled_quote` / `filled_fee` from the DEFI-2901 worked - /// example. The spec writes that example in ICP/ckUSDT (8/6 decimals); this - /// test reuses the ICP/ckBTC fixture (both 8 decimals). The stored - /// smallest-unit figures depend only on `base_scale = 10^8` and the - /// smallest-unit prices/quantities — not on the quote token's decimals — so - /// every value below matches the spec's numbers exactly. + /// Order-level `filled_quote` / `filled_fee` from the DEFI-2901 ICP/ckUSDT + /// worked example: base ICP (8 decimals) / quote ckUSDT (6 decimals), + /// `base_scale = 10^8`. The smallest-unit prices and quantities below are + /// the spec's, e.g. `PRICE_10 = 10_000_000` is 10 ckUSDT/ICP and the + /// resulting `notional 20_000_000` is 20 ckUSDT. mod realized_scalars { use super::{BUYER, SELLER, TestState}; use crate::EXECUTOR; @@ -2341,8 +2340,8 @@ mod settle_fills { }; use crate::test_fixtures::mocks::mock_runtime_for; use crate::test_fixtures::{ - self, LOT_SIZE, MAX_NOTIONAL, MIN_NOTIONAL, TICK_SIZE, ckbtc_metadata, - icp_ckbtc_trading_pair, icp_metadata, + self, LOT_SIZE, MAX_NOTIONAL, MIN_NOTIONAL, TICK_SIZE, ckusdt_metadata, + icp_ckusdt_trading_pair, icp_metadata, }; use candid::Principal; @@ -2361,9 +2360,9 @@ mod settle_fills { let mut state = test_fixtures::state(); state.record_trading_pair( OrderBookId::ZERO, - icp_ckbtc_trading_pair(), + icp_ckusdt_trading_pair(), icp_metadata(), - ckbtc_metadata(), + ckusdt_metadata(), TICK_SIZE, LOT_SIZE, MIN_NOTIONAL, @@ -2385,13 +2384,13 @@ mod settle_fills { /// A buy taker sweeps two maker levels (2 ICP @ 10, 3 ICP @ 11) with /// taker 10 bps / maker 5 bps. The taker's `filled_quote` is the realized - /// notional 53 ckBTC (the 7-ckBTC reservation surplus is excluded), and + /// notional 53 ckUSDT (the 7-ckUSDT reservation surplus is excluded), and /// its `filled_fee` is the base-denominated 0.005 ICP. Each maker records /// its own quote-denominated fee. #[test] fn buy_taker_sweeping_two_levels_rolls_up_quote_and_fee() { let mut state = setup(5, 10); - let pair = icp_ckbtc_trading_pair(); + let pair = icp_ckusdt_trading_pair(); let maker_a = test_fixtures::place_order(&mut state, SELLER, &pair, Side::Sell, PRICE_10, QTY_2); @@ -2402,26 +2401,26 @@ mod settle_fills { EXECUTOR.run_once(&mut state, &mock_runtime_for(Principal::anonymous())); // Taker buy: gross 5 ICP filled, realized notional 20 + 33 = 53 - // ckBTC, fee 0.002 + 0.003 = 0.005 ICP (base-denominated). The - // 7-ckBTC reservation surplus is released, not part of filled_quote. + // ckUSDT, fee 0.002 + 0.003 = 0.005 ICP (base-denominated). The + // 7-ckUSDT reservation surplus is released, not part of filled_quote. let taker = record(&state, BUYER, taker); assert_eq!(taker.status, OrderStatus::Filled); assert_eq!(taker.filled_quantity, Quantity::from(QTY_5)); assert_eq!(taker.filled_quote, Quantity::from(53_000_000u128)); assert_eq!(taker.filled_fee, Quantity::from(500_000u128)); - // VWAP = filled_quote × base_scale / filled_quantity = 10.6 ckBTC/ICP. + // VWAP = filled_quote × base_scale / filled_quantity = 10.6 ckUSDT/ICP. let base_scale = 100_000_000u128; let vwap = taker.filled_quote.as_u128().unwrap() * base_scale / taker.filled_quantity.as_u128().unwrap(); assert_eq!(vwap, 10_600_000); - // Maker A (Fill 1): 20 ckBTC notional, 0.01 ckBTC fee (quote). + // Maker A (Fill 1): 20 ckUSDT notional, 0.01 ckUSDT fee (quote). let maker_a = record(&state, SELLER, maker_a); assert_eq!(maker_a.filled_quantity, Quantity::from(QTY_2)); assert_eq!(maker_a.filled_quote, Quantity::from(20_000_000u128)); assert_eq!(maker_a.filled_fee, Quantity::from(10_000u128)); - // Maker B (Fill 2): 33 ckBTC notional, 0.0165 ckBTC fee (quote). + // Maker B (Fill 2): 33 ckUSDT notional, 0.0165 ckUSDT fee (quote). let maker_b = record(&state, MAKER_B, maker_b); assert_eq!(maker_b.filled_quantity, Quantity::from(QTY_3)); assert_eq!(maker_b.filled_quote, Quantity::from(33_000_000u128)); @@ -2435,7 +2434,7 @@ mod settle_fills { #[test] fn order_filling_both_ways_in_one_batch_writes_once() { let mut state = setup(5, 10); - let pair = icp_ckbtc_trading_pair(); + let pair = icp_ckusdt_trading_pair(); // A resting ask the middle order will cross as taker. test_fixtures::place_order(&mut state, SELLER, &pair, Side::Sell, PRICE_10, QTY_2); @@ -2447,8 +2446,8 @@ mod settle_fills { test_fixtures::place_order(&mut state, MAKER_B, &pair, Side::Sell, PRICE_10, QTY_3); EXECUTOR.run_once(&mut state, &mock_runtime_for(Principal::anonymous())); - // Taker leg: 2 ICP @ 10 → notional 20 ckBTC. Maker leg: 3 ICP @ 10 - // → notional 30 ckBTC. filled_quote = 20 + 30 = 50 ckBTC. + // Taker leg: 2 ICP @ 10 → notional 20 ckUSDT. Maker leg: 3 ICP @ 10 + // → notional 30 ckUSDT. filled_quote = 20 + 30 = 50 ckUSDT. let pivot = record(&state, BUYER, pivot); assert_eq!(pivot.status, OrderStatus::Filled); assert_eq!(pivot.filled_quantity, Quantity::from(QTY_5)); From 2198311157d9fb147a5a83f856f1055d2109abb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 23 Jun 2026 15:22:06 +0000 Subject: [PATCH 07/35] docs(candid): simplify filled_quote/filled_fee field docs Address review comment 3460414419: drop the base_scale formula from the filled_quote doc (it is plainly cumulative quote in quote-smallest units), spell out VWAP as volume-weighted average price, and remove the confusing reservation-surplus sentence. Co-Authored-By: Claude Opus 4.8 (1M context) --- canister/oisy_trade.did | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/canister/oisy_trade.did b/canister/oisy_trade.did index 89f69bfb..b9ecc9e9 100644 --- a/canister/oisy_trade.did +++ b/canister/oisy_trade.did @@ -282,8 +282,9 @@ type OrderRecord = record { /// Time-in-force policy the order was placed with. time_in_force : TimeInForce; /// Cumulative realized quote notional transacted across the order's fills, - /// in quote-token smallest units. A buy taker's released reservation - /// surplus is excluded; VWAP is `filled_quote × base_scale / filled_quantity`. + /// in quote-token smallest units. With `filled_quantity` (base smallest + /// units), this yields the volume-weighted average price (VWAP) the order + /// traded at. filled_quote : nat; /// Cumulative realized fee charged across the order's fills, in the order's /// receive token — base for a buy, quote for a sell. From 6b4a9dd06cb830bf958ca1fd927299fecf59a8b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 23 Jun 2026 15:22:15 +0000 Subject: [PATCH 08/35] refactor(state): wrap Fill in FillSettlement, single write gate, rename scope Address review comments 3460598457, 3460594366, and 3460487183 on the settlement path in record_matching_event: - FillSettlement now borrows the Fill it derives from and stores only the extra computed values (notional, taker/maker fee, surplus) instead of duplicating the Fill's seqs, side, and quantity. - Consolidate the per-order apply-update under a single write gate; the cheap delta accumulation runs in the single fills pass and only the stable-memory writes are gated, so replay still does not double-count. - Rename the stale bench scope status to apply_order_updates, reflecting the whole per-order apply-update it wraps (apply_update keeps its own inner scope). - Drop the R# spec-requirement tags from code comments. Co-Authored-By: Claude Opus 4.8 (1M context) --- canister/src/state/mod.rs | 92 ++++++++++++++++++--------------------- 1 file changed, 42 insertions(+), 50 deletions(-) diff --git a/canister/src/state/mod.rs b/canister/src/state/mod.rs index a43f39ca..698908c2 100644 --- a/canister/src/state/mod.rs +++ b/canister/src/state/mod.rs @@ -408,39 +408,37 @@ impl State { let output = book.process_pending_orders(&event.orders); // Single pass over the fills: compute each fill's realized notional, // fees, and roles once (`FillSettlement`), then feed both the balance - // operations (always) and the per-order scalar deltas (only when - // writing) from that one value, so the two can never diverge (R11). The - // per-fill `Quantity` arithmetic is u256-wide, so it is done exactly - // once and the intermediate is never materialized into a `Vec`. - let write = matches!(persistence, StableMemoryOptions::Write); + // operations and the per-order scalar deltas from that one value, so the + // two can never diverge. The per-fill `Quantity` arithmetic is u256-wide, + // so it is done exactly once and never materialized into a `Vec`. + // + // `updates` folds the batch into one update per touched order, applied + // once each: a single batch can fill one order across many `Fill`s (a + // taker sweeping several makers, or a maker hit repeatedly) and an order + // can both change status and accrue fills in the same batch. The cheap + // delta accumulation runs unconditionally; the writes to stable memory + // are gated below, so post-upgrade replay does not double-count. let mut balance_operations = Vec::with_capacity(output.fills.len() * 3); - // Folds the batch into one update per touched order, written once each: - // a single batch can fill one order across many `Fill`s (a taker - // sweeping several makers, or a maker hit repeatedly) and an order can - // both change status and accrue fills in the same batch. Empty (and - // unused) outside the write path. let mut updates: BTreeMap = BTreeMap::new(); for fill in &output.fills { let settlement = fill_settlement(fill, fee_rates, base_scale); push_balance_operations(&mut balance_operations, &settlement); - if write { - accrue_fill( - updates.entry(settlement.taker_order_seq).or_default(), - settlement.quantity, - settlement.notional, - settlement.taker_fee, - ); - accrue_fill( - updates.entry(settlement.maker_order_seq).or_default(), - settlement.quantity, - settlement.notional, - settlement.maker_fee, - ); - } + accrue_fill( + updates.entry(fill.taker_order_seq).or_default(), + fill.quantity, + settlement.notional, + settlement.taker_fee, + ); + accrue_fill( + updates.entry(fill.maker_order_seq).or_default(), + fill.quantity, + settlement.notional, + settlement.maker_fee, + ); } - if write { + if matches!(persistence, StableMemoryOptions::Write) { #[cfg(feature = "canbench-rs")] - let _p = canbench_rs::bench_scope("status"); + let _p = canbench_rs::bench_scope("apply_order_updates"); for seq in &output.resting_orders { updates.entry(*seq).or_default().status = Some(OrderStatus::Open); } @@ -932,16 +930,12 @@ fn resolve_op_users( .collect() } -/// The realized values of a single fill, computed once in settlement (the only -/// point where both `fee_rates` and `base_scale` are in scope) and reused to -/// build both the [`event::BalanceOperation`]s and the per-order scalar deltas, -/// so the two can never diverge (R11). -struct FillSettlement { - taker_order_seq: OrderSeq, - maker_order_seq: OrderSeq, - taker_side: Side, - /// Base quantity exchanged on this fill. - quantity: Quantity, +/// A single [`Fill`] together with the realized values derived from it, computed +/// once in settlement (the only point where both `fee_rates` and `base_scale` +/// are in scope) and reused to build both the [`event::BalanceOperation`]s and +/// the per-order scalar deltas, so the two can never diverge. +struct FillSettlement<'a> { + fill: &'a Fill, /// Quote notional `maker_price × quantity / base_scale` (the executed /// price; a buy taker's reservation surplus is excluded). notional: Quantity, @@ -956,7 +950,7 @@ struct FillSettlement { } /// Compute the realized values of a single fill once. -fn fill_settlement(fill: &Fill, fee_rates: FeeRates, base_scale: NonZeroU64) -> FillSettlement { +fn fill_settlement(fill: &Fill, fee_rates: FeeRates, base_scale: NonZeroU64) -> FillSettlement<'_> { // Receive-side convention: buyer pays fee in base (the asset they // receive), seller in quote. Each side's rate is `taker` if they // were the taker, else `maker`. @@ -985,10 +979,7 @@ fn fill_settlement(fill: &Fill, fee_rates: FeeRates, base_scale: NonZeroU64) -> None }; FillSettlement { - taker_order_seq: fill.taker_order_seq, - maker_order_seq: fill.maker_order_seq, - taker_side: fill.taker_side, - quantity: fill.quantity, + fill, notional, taker_fee, maker_fee, @@ -998,11 +989,12 @@ fn fill_settlement(fill: &Fill, fee_rates: FeeRates, base_scale: NonZeroU64) -> /// Push the (up to three) balance operations a single fill settles into `ops`. fn push_balance_operations(ops: &mut Vec, settlement: &FillSettlement) { - let (buyer_seq, seller_seq) = match settlement.taker_side { - Side::Buy => (settlement.taker_order_seq, settlement.maker_order_seq), - Side::Sell => (settlement.maker_order_seq, settlement.taker_order_seq), + let fill = settlement.fill; + let (buyer_seq, seller_seq) = match fill.taker_side { + Side::Buy => (fill.taker_order_seq, fill.maker_order_seq), + Side::Sell => (fill.maker_order_seq, fill.taker_order_seq), }; - let (quote_fee, base_fee) = match settlement.taker_side { + let (quote_fee, base_fee) = match fill.taker_side { Side::Buy => (settlement.maker_fee, settlement.taker_fee), Side::Sell => (settlement.taker_fee, settlement.maker_fee), }; @@ -1015,7 +1007,7 @@ fn push_balance_operations(ops: &mut Vec, settlement: & }); if let Some(surplus) = settlement.surplus { ops.push(event::BalanceOperation::Unreserve { - order: settlement.taker_order_seq, + order: fill.taker_order_seq, token: order::PairToken::Quote, amount: surplus, }); @@ -1024,15 +1016,15 @@ fn push_balance_operations(ops: &mut Vec, settlement: & from_order: seller_seq, to_order: buyer_seq, token: order::PairToken::Base, - amount: settlement.quantity, + amount: fill.quantity, fee: nonzero(base_fee), }); } /// Fold one fill's realized values into an [`OrderUpdate`] entry. `filled_delta` -/// accumulates the gross base quantity (DEFI-2852), `quote_delta` the realized -/// notional (R1), and `fee_delta` the realized fee in the order's receive token -/// (R2). All three are accumulated with always-on overflow traps (R9). +/// accumulates the gross base quantity, `quote_delta` the realized notional, and +/// `fee_delta` the realized fee in the order's receive token. All three are +/// accumulated with always-on overflow traps. fn accrue_fill(update: &mut OrderUpdate, quantity: Quantity, notional: Quantity, fee: Quantity) { update.filled_delta = update .filled_delta From c730732d72a926e1c8115b00ab3e7981d8e52fc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 23 Jun 2026 15:22:22 +0000 Subject: [PATCH 09/35] test(state): fold realized-scalar coverage into existing fee tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review comment 3460709715: remove the standalone realized_scalars module name and assert filled_quote/filled_fee from existing tests where they extend naturally — fill_deducts_fees_on_both_sides now checks each side's roll-up (catching a notional/quantity confusion and a taker/maker fee swap), and should_write_once_for_taker_spanning_multiple_fills asserts the summed filled_quote across distinct maker prices. The multi-level-sweep worked example (with surplus exclusion and VWAP) and the both-ways single-batch case move into the fees module as the only dedicated tests, since no existing test covers them. Co-Authored-By: Claude Opus 4.8 (1M context) --- canister/src/state/tests.rs | 90 +++++++++++++++++++++---------------- 1 file changed, 51 insertions(+), 39 deletions(-) diff --git a/canister/src/state/tests.rs b/canister/src/state/tests.rs index 67e2d13f..39ef6608 100644 --- a/canister/src/state/tests.rs +++ b/canister/src/state/tests.rs @@ -1899,6 +1899,11 @@ mod settle_fills { let buy = record_of(&state, BUYER, buy_id); assert_eq!(buy.status, OrderStatus::Filled); assert_eq!(buy.filled_quantity, Quantity::from(2 * lot)); + // The two fills executed at distinct maker prices (100 and 101), so + // `filled_quote` is their summed notional — distinct from + // `filled_quantity` and proving each fill's price is rolled up. + assert_eq!(buy.filled_quote, Quantity::from(100 * lot + 101 * lot)); + assert_eq!(buy.filled_fee, Quantity::ZERO); } /// `created_at` is stamped once at placement and never moves, while @@ -2077,7 +2082,7 @@ mod settle_fills { mod fees { use super::*; - use crate::order::BasisPoint; + use crate::order::{BasisPoint, OrderStatus}; /// Fill deducts fees on both sides at the role-specific rates. /// Parameterized over which side crosses (taker): @@ -2115,7 +2120,7 @@ mod settle_fills { Side::Sell => (SELLER, BUYER), Side::Buy => (BUYER, SELLER), }; - test_fixtures::place_order( + let first_id = test_fixtures::place_order( &mut state, first_user, &pair, @@ -2123,7 +2128,7 @@ mod settle_fills { price * PRICE_SCALE, qty, ); - test_fixtures::place_order( + let second_id = test_fixtures::place_order( &mut state, second_user, &pair, @@ -2168,6 +2173,22 @@ mod settle_fills { state.balances.fee_balance(&pair.quote), Some(Quantity::from(quote_fee)), ); + + // The order-level scalars roll up the same realized values. Both + // sides traded the full `qty`, so each records `filled_quote == + // notional` (≠ `filled_quantity`, which would be `qty`). The buyer's + // `filled_fee` is the base fee, the seller's is the quote fee — never + // swapped. + let (buyer_id, seller_id) = match taker_side { + Side::Buy => (second_id, first_id), + Side::Sell => (first_id, second_id), + }; + let buy = record_of(&state, BUYER, buyer_id); + assert_eq!(buy.filled_quote, Quantity::from(notional)); + assert_eq!(buy.filled_fee, Quantity::from(base_fee)); + let sell = record_of(&state, SELLER, seller_id); + assert_eq!(sell.filled_quote, Quantity::from(notional)); + assert_eq!(sell.filled_fee, Quantity::from(quote_fee)); } /// Zero rates is a regression guard: the fill path with @@ -2306,6 +2327,17 @@ mod settle_fills { assert_eq!(state.balances.fee_balance(&pair.quote), None); } + fn record_of( + state: &TestState, + owner: Principal, + order_id: crate::order::OrderId, + ) -> crate::order::OrderRecord { + state + .get_user_order(&owner, order_id) + .map(|(_, _, record)| record) + .expect("order record present") + } + fn setup_with_fees(maker_bps: u16, taker_bps: u16) -> TestState { let mut state = test_fixtures::state(); let pair = icp_ckbtc_trading_pair(); @@ -2325,30 +2357,16 @@ mod settle_fills { ); state } - } - /// Order-level `filled_quote` / `filled_fee` from the DEFI-2901 ICP/ckUSDT - /// worked example: base ICP (8 decimals) / quote ckUSDT (6 decimals), - /// `base_scale = 10^8`. The smallest-unit prices and quantities below are - /// the spec's, e.g. `PRICE_10 = 10_000_000` is 10 ckUSDT/ICP and the - /// resulting `notional 20_000_000` is 20 ckUSDT. - mod realized_scalars { - use super::{BUYER, SELLER, TestState}; - use crate::EXECUTOR; - use crate::order::{ - BasisPoint, FeeRates, OrderBookId, OrderId, OrderStatus, Quantity, Side, - }; - use crate::test_fixtures::mocks::mock_runtime_for; - use crate::test_fixtures::{ - self, LOT_SIZE, MAX_NOTIONAL, MIN_NOTIONAL, TICK_SIZE, ckusdt_metadata, - icp_ckusdt_trading_pair, icp_metadata, - }; - use candid::Principal; + // The two worked-example tests below register ICP/ckUSDT (base ICP + // 8 decimals / quote ckUSDT 6 decimals, `base_scale = 10^8`) so the + // smallest-unit figures match the DEFI-2901 spec's example literally: + // `PRICE_10 = 10_000_000` is 10 ckUSDT/ICP and `notional 20_000_000` + // is 20 ckUSDT. + use crate::test_fixtures::{ckusdt_metadata, icp_ckusdt_trading_pair}; // Maker B and the two distinct maker levels need a third principal. const MAKER_B: Principal = Principal::from_slice(&[0x03]); - - // Prices and quantities in smallest units (base scale = 10^8). const PRICE_10: u128 = 10_000_000; const PRICE_11: u128 = 11_000_000; const PRICE_12: u128 = 12_000_000; @@ -2356,7 +2374,7 @@ mod settle_fills { const QTY_3: u128 = 300_000_000; const QTY_5: u128 = 500_000_000; - fn setup(maker_bps: u16, taker_bps: u16) -> TestState { + fn setup_ckusdt_with_fees(maker_bps: u16, taker_bps: u16) -> TestState { let mut state = test_fixtures::state(); state.record_trading_pair( OrderBookId::ZERO, @@ -2375,21 +2393,15 @@ mod settle_fills { state } - fn record(state: &TestState, owner: Principal, id: OrderId) -> crate::order::OrderRecord { - state - .get_user_order(&owner, id) - .map(|(_, _, record)| record) - .expect("order record present") - } - /// A buy taker sweeps two maker levels (2 ICP @ 10, 3 ICP @ 11) with /// taker 10 bps / maker 5 bps. The taker's `filled_quote` is the realized /// notional 53 ckUSDT (the 7-ckUSDT reservation surplus is excluded), and /// its `filled_fee` is the base-denominated 0.005 ICP. Each maker records - /// its own quote-denominated fee. + /// its own quote-denominated fee. No other test exercises a taker + /// sweeping multiple maker levels with fees and surplus exclusion. #[test] fn buy_taker_sweeping_two_levels_rolls_up_quote_and_fee() { - let mut state = setup(5, 10); + let mut state = setup_ckusdt_with_fees(5, 10); let pair = icp_ckusdt_trading_pair(); let maker_a = @@ -2403,7 +2415,7 @@ mod settle_fills { // Taker buy: gross 5 ICP filled, realized notional 20 + 33 = 53 // ckUSDT, fee 0.002 + 0.003 = 0.005 ICP (base-denominated). The // 7-ckUSDT reservation surplus is released, not part of filled_quote. - let taker = record(&state, BUYER, taker); + let taker = record_of(&state, BUYER, taker); assert_eq!(taker.status, OrderStatus::Filled); assert_eq!(taker.filled_quantity, Quantity::from(QTY_5)); assert_eq!(taker.filled_quote, Quantity::from(53_000_000u128)); @@ -2415,13 +2427,13 @@ mod settle_fills { assert_eq!(vwap, 10_600_000); // Maker A (Fill 1): 20 ckUSDT notional, 0.01 ckUSDT fee (quote). - let maker_a = record(&state, SELLER, maker_a); + let maker_a = record_of(&state, SELLER, maker_a); assert_eq!(maker_a.filled_quantity, Quantity::from(QTY_2)); assert_eq!(maker_a.filled_quote, Quantity::from(20_000_000u128)); assert_eq!(maker_a.filled_fee, Quantity::from(10_000u128)); // Maker B (Fill 2): 33 ckUSDT notional, 0.0165 ckUSDT fee (quote). - let maker_b = record(&state, MAKER_B, maker_b); + let maker_b = record_of(&state, MAKER_B, maker_b); assert_eq!(maker_b.filled_quantity, Quantity::from(QTY_3)); assert_eq!(maker_b.filled_quote, Quantity::from(33_000_000u128)); assert_eq!(maker_b.filled_fee, Quantity::from(16_500u128)); @@ -2432,8 +2444,8 @@ mod settle_fills { /// quote and fee, written exactly once. The taker leg is charged the /// taker rate, the maker leg the maker rate. #[test] - fn order_filling_both_ways_in_one_batch_writes_once() { - let mut state = setup(5, 10); + fn order_filling_both_ways_in_one_batch_rolls_up_both_legs() { + let mut state = setup_ckusdt_with_fees(5, 10); let pair = icp_ckusdt_trading_pair(); // A resting ask the middle order will cross as taker. @@ -2448,7 +2460,7 @@ mod settle_fills { // Taker leg: 2 ICP @ 10 → notional 20 ckUSDT. Maker leg: 3 ICP @ 10 // → notional 30 ckUSDT. filled_quote = 20 + 30 = 50 ckUSDT. - let pivot = record(&state, BUYER, pivot); + let pivot = record_of(&state, BUYER, pivot); assert_eq!(pivot.status, OrderStatus::Filled); assert_eq!(pivot.filled_quantity, Quantity::from(QTY_5)); assert_eq!(pivot.filled_quote, Quantity::from(50_000_000u128)); From e97d66cb092e53a30c2e22722d76a143750b37cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 23 Jun 2026 15:22:28 +0000 Subject: [PATCH 10/35] test(int): exercise non-zero fees in partial-fill cancel test Address review comment 3460438909: register the trading pair with non-zero maker/taker fee rates so the partially filled buy accrues a non-trivial filled_fee (the maker-rate base fee) alongside a consistent filled_quote, and assert the withheld fee on the buyer's settled base balance. Co-Authored-By: Claude Opus 4.8 (1M context) --- integration_tests/tests/tests.rs | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index b07166eb..889e9f88 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -496,13 +496,27 @@ mod cancel_limit_order { use oisy_trade_int_tests::icrc_ledger::{BASE_LEDGER_FEE, QUOTE_LEDGER_FEE}; use oisy_trade_int_tests::{PRICE_SCALE, Setup}; use oisy_trade_types::{ - Balance, CancelLimitOrderRequestError, ErrorKind, LimitOrderRequest, OrderRecord, - OrderStatus, Side, TimeInForce, + AddTradingPairRequest, Balance, CancelLimitOrderRequestError, ErrorKind, LimitOrderRequest, + OrderRecord, OrderStatus, Side, TimeInForce, }; #[tokio::test] async fn should_cancel_partially_filled_buy_and_refund_residual() { - let setup = Setup::new().await.with_trading_pair().await; + // Non-zero maker/taker fees so the partially filled buy accrues a + // non-trivial `filled_fee` and a `filled_quote` consistent with the + // realized notional. + const MAKER_FEE_BPS: u16 = 10; + const TAKER_FEE_BPS: u16 = 23; + let setup = Setup::new().await; + setup + .oisy_trade_client_with_caller(setup.controller()) + .add_trading_pair(AddTradingPairRequest { + maker_fee_bps: MAKER_FEE_BPS, + taker_fee_bps: TAKER_FEE_BPS, + ..setup.add_trading_pair_request() + }) + .await + .unwrap(); let buyer = Principal::from_slice(&[0x01]); let buyer_client = setup.oisy_trade_client_with_caller(buyer); let seller = Principal::from_slice(&[0x02]); @@ -616,7 +630,9 @@ mod cancel_limit_order { last_updated_at: canceled.last_updated_at, time_in_force: TimeInForce::GoodTilCanceled, filled_quote: Nat::from(1_000_000_000u64), - filled_fee: Nat::from(0u64), + // Buyer rested first, so it is the maker: a base-token fee at the + // maker rate on the 1M filled = ceil(1_000_000 × 10 / 10_000). + filled_fee: Nat::from(1_000u64), } ); @@ -643,7 +659,9 @@ mod cancel_limit_order { .await .unwrap(), Balance { - free: 1_000_000u64.into(), + // 1M base received minus the 1_000 maker fee withheld on the + // base side. + free: 999_000u64.into(), reserved: 0u64.into(), } ); From 61abf99f8c483b7eed0020f9021c73fb969e0023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Tue, 23 Jun 2026 15:22:33 +0000 Subject: [PATCH 11/35] chore(canister): refresh canbench baseline for renamed scope The status bench scope is renamed to apply_order_updates and the FillSettlement rework changes the settlement path, so the persisted baseline keys/values shift accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) --- canister/canbench_results.yml | 112 +++++++++++++++++----------------- 1 file changed, 56 insertions(+), 56 deletions(-) diff --git a/canister/canbench_results.yml b/canister/canbench_results.yml index 748760b3..e1580ce0 100644 --- a/canister/canbench_results.yml +++ b/canister/canbench_results.yml @@ -75,43 +75,48 @@ benches: bench_process_pending_orders_1000: total: calls: 1 - instructions: 1034342347 + instructions: 1035099447 heap_increase: 0 stable_memory_increase: 0 scopes: + apply_order_updates: + calls: 1 + instructions: 103778413 + heap_increase: 0 + stable_memory_increase: 0 bal: calls: 3481 - instructions: 17536597 + instructions: 17815940 heap_increase: 0 stable_memory_increase: 0 bal::debit_reserved: calls: 1538 - instructions: 4876700 + instructions: 4883000 heap_increase: 0 stable_memory_increase: 0 bal::deposit: calls: 1538 - instructions: 4828614 + instructions: 4834914 heap_increase: 0 stable_memory_increase: 0 bal::unreserve: calls: 405 - instructions: 2440151 + instructions: 2440451 heap_increase: 0 stable_memory_increase: 0 balances: calls: 1943 - instructions: 796505496 + instructions: 797023309 heap_increase: 0 stable_memory_increase: 0 balances::transfer: calls: 1538 - instructions: 700015930 + instructions: 700508158 heap_increase: 0 stable_memory_increase: 0 balances::unreserve: calls: 405 - instructions: 94038053 + instructions: 94097919 heap_increase: 0 stable_memory_increase: 0 book::apply_plan: @@ -136,12 +141,12 @@ benches: stable_memory_increase: 0 matching: calls: 1 - instructions: 148447641 + instructions: 148474317 heap_increase: 0 stable_memory_increase: 0 order_history: calls: 1785 - instructions: 130722723 + instructions: 130722703 heap_increase: 0 stable_memory_increase: 0 order_history::apply_update: @@ -156,7 +161,7 @@ benches: stable_memory_increase: 0 qty: calls: 17175 - instructions: 34823254 + instructions: 34832854 heap_increase: 0 stable_memory_increase: 0 qty::add: @@ -186,21 +191,21 @@ benches: stable_memory_increase: 0 settling: calls: 1 - instructions: 880392684 - heap_increase: 0 - stable_memory_increase: 0 - status: - calls: 1 - instructions: 103778413 + instructions: 881123384 heap_increase: 0 stable_memory_increase: 0 bench_process_pending_orders_1000_no_fills: total: calls: 1 - instructions: 96666747 + instructions: 96666777 heap_increase: 0 stable_memory_increase: 0 scopes: + apply_order_updates: + calls: 1 + instructions: 85832715 + heap_increase: 0 + stable_memory_increase: 0 book::apply_plan: calls: 1000 instructions: 363000 @@ -223,7 +228,7 @@ benches: stable_memory_increase: 0 matching: calls: 1 - instructions: 96122145 + instructions: 96122120 heap_increase: 0 stable_memory_increase: 0 order_history: @@ -236,51 +241,51 @@ benches: instructions: 80282385 heap_increase: 0 stable_memory_increase: 0 - status: - calls: 1 - instructions: 85832715 - heap_increase: 0 - stable_memory_increase: 0 bench_process_pending_orders_1000_with_fees: total: calls: 1 - instructions: 1058452026 + instructions: 1059215493 heap_increase: 0 stable_memory_increase: 0 scopes: + apply_order_updates: + calls: 1 + instructions: 106741632 + heap_increase: 0 + stable_memory_increase: 0 bal: calls: 3481 - instructions: 16884853 + instructions: 17164364 heap_increase: 0 stable_memory_increase: 0 bal::debit_reserved: calls: 1538 - instructions: 4876700 + instructions: 4883000 heap_increase: 0 stable_memory_increase: 0 bal::deposit: calls: 1538 - instructions: 4172296 + instructions: 4178596 heap_increase: 0 stable_memory_increase: 0 bal::unreserve: calls: 405 - instructions: 2440251 + instructions: 2440551 heap_increase: 0 stable_memory_increase: 0 balances: calls: 1943 - instructions: 801837834 + instructions: 802362012 heap_increase: 0 stable_memory_increase: 0 balances::transfer: calls: 1538 - instructions: 705279225 + instructions: 705777753 heap_increase: 0 stable_memory_increase: 0 balances::unreserve: calls: 405 - instructions: 94093705 + instructions: 94153739 heap_increase: 0 stable_memory_increase: 0 book::apply_plan: @@ -305,12 +310,12 @@ benches: stable_memory_increase: 0 matching: calls: 1 - instructions: 166680052 + instructions: 166706728 heap_increase: 0 stable_memory_increase: 0 order_history: calls: 1785 - instructions: 133760472 + instructions: 133760452 heap_increase: 0 stable_memory_increase: 0 order_history::apply_update: @@ -325,7 +330,7 @@ benches: stable_memory_increase: 0 qty: calls: 24102 - instructions: 49588955 + instructions: 49601705 heap_increase: 0 stable_memory_increase: 0 qty::add: @@ -355,44 +360,44 @@ benches: stable_memory_increase: 0 settling: calls: 1 - instructions: 885835358 - heap_increase: 0 - stable_memory_increase: 0 - status: - calls: 1 - instructions: 106741632 + instructions: 886572425 heap_increase: 0 stable_memory_increase: 0 bench_process_pending_orders_1_large: total: calls: 1 - instructions: 815926642 + instructions: 817426189 heap_increase: 0 stable_memory_increase: 0 scopes: + apply_order_updates: + calls: 1 + instructions: 71277414 + heap_increase: 0 + stable_memory_increase: 0 bal: calls: 2788 - instructions: 12085401 + instructions: 12929661 heap_increase: 0 stable_memory_increase: 0 bal::debit_reserved: calls: 1394 - instructions: 4465049 + instructions: 4673999 heap_increase: 0 stable_memory_increase: 0 bal::deposit: calls: 1394 - instructions: 3578398 + instructions: 3787348 heap_increase: 0 stable_memory_increase: 0 balances: calls: 1394 - instructions: 642904877 + instructions: 643948795 heap_increase: 0 stable_memory_increase: 0 balances::transfer: calls: 1394 - instructions: 640149135 + instructions: 642332479 heap_increase: 0 stable_memory_increase: 0 book::apply_plan: @@ -417,12 +422,12 @@ benches: stable_memory_increase: 0 matching: calls: 1 - instructions: 98860414 + instructions: 98890337 heap_increase: 0 stable_memory_increase: 0 order_history: calls: 1396 - instructions: 95808231 + instructions: 95808211 heap_increase: 0 stable_memory_increase: 0 order_history::apply_update: @@ -437,7 +442,7 @@ benches: stable_memory_increase: 0 qty: calls: 13245 - instructions: 25107819 + instructions: 25421244 heap_increase: 0 stable_memory_increase: 0 qty::add: @@ -467,12 +472,7 @@ benches: stable_memory_increase: 0 settling: calls: 1 - instructions: 713418624 - heap_increase: 0 - stable_memory_increase: 0 - status: - calls: 1 - instructions: 71277414 + instructions: 714889314 heap_increase: 0 stable_memory_increase: 0 bench_read_events: From f90f243b1e1f943eee7c12566877198647e3deb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 24 Jun 2026 09:28:00 +0000 Subject: [PATCH 12/35] chore(canister): refresh canbench baseline after main merge The merge of origin/main shifted instruction counts within noise (max +0.02%). Persist the refreshed canbench_results.yml so the regression gate stays green. Co-Authored-By: Claude Opus 4.8 (1M context) --- canister/canbench_results.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/canister/canbench_results.yml b/canister/canbench_results.yml index e1580ce0..e7fc5d2c 100644 --- a/canister/canbench_results.yml +++ b/canister/canbench_results.yml @@ -2,13 +2,13 @@ benches: bench_get_my_orders: total: calls: 1 - instructions: 53572952 + instructions: 53581427 heap_increase: 0 stable_memory_increase: 0 scopes: order_history: calls: 1010 - instructions: 43091436 + instructions: 43091417 heap_increase: 0 stable_memory_increase: 0 order_history::get: @@ -18,13 +18,13 @@ benches: stable_memory_increase: 0 order_history::orders_after: calls: 10 - instructions: 3394353 + instructions: 3394334 heap_increase: 0 stable_memory_increase: 0 bench_get_order_book_depth_default: total: calls: 1 - instructions: 723716 + instructions: 723739 heap_increase: 0 stable_memory_increase: 0 scopes: @@ -41,7 +41,7 @@ benches: bench_get_order_book_depth_max: total: calls: 1 - instructions: 6000313 + instructions: 6000336 heap_increase: 0 stable_memory_increase: 0 scopes: @@ -58,7 +58,7 @@ benches: bench_get_order_book_ticker: total: calls: 1 - instructions: 9913 + instructions: 9889 heap_increase: 0 stable_memory_increase: 0 scopes: @@ -535,13 +535,13 @@ benches: bench_upgrade_1000_no_fills: total: calls: 1 - instructions: 4657227 + instructions: 4657255 heap_increase: 0 stable_memory_increase: 128 scopes: post_upgrade: calls: 1 - instructions: 2851336 + instructions: 2851350 heap_increase: 0 stable_memory_increase: 0 post_upgrade::into_state: @@ -561,7 +561,7 @@ benches: stable_memory_increase: 0 pre_upgrade: calls: 1 - instructions: 1687602 + instructions: 1687616 heap_increase: 0 stable_memory_increase: 128 pre_upgrade::from_state: @@ -577,13 +577,13 @@ benches: bench_upgrade_full_depth: total: calls: 1 - instructions: 60850042 + instructions: 60850070 heap_increase: 0 stable_memory_increase: 128 scopes: post_upgrade: calls: 1 - instructions: 36816825 + instructions: 36816839 heap_increase: 0 stable_memory_increase: 0 post_upgrade::into_state: @@ -603,7 +603,7 @@ benches: stable_memory_increase: 0 pre_upgrade: calls: 1 - instructions: 20550289 + instructions: 20550303 heap_increase: 0 stable_memory_increase: 128 pre_upgrade::from_state: From df627aefe0b0294226fa8719974fa61ac8db4f34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 24 Jun 2026 09:35:43 +0000 Subject: [PATCH 13/35] test(state): rename proptest to reflect fill_settlement + push_balance_operations Co-Authored-By: Claude Opus 4.8 (1M context) --- canister/src/state/tests.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/canister/src/state/tests.rs b/canister/src/state/tests.rs index 0b4a0307..b48b4547 100644 --- a/canister/src/state/tests.rs +++ b/canister/src/state/tests.rs @@ -2046,12 +2046,12 @@ mod settle_fills { // been retired — settlement is now a flat `Vec` in // `SettlingEvent`. Commutativity isn't claimed for arbitrary op sequences // (two Transfers from the same debtor can fail depending on order), only - // for op sequences produced by `compute_balance_operations` from a valid - // `MatchingOutput`. + // for op sequences produced by `fill_settlement` + `push_balance_operations` + // from a valid `MatchingOutput`. proptest! { - /// `compute_balance_operations` preserves structural invariants over - /// any `MatchingOutput` the arbitrary strategy can produce: + /// `fill_settlement` + `push_balance_operations` preserve structural invariants + /// over any `MatchingOutput` the arbitrary strategy can produce: /// - never panics /// - emits exactly one Quote Transfer and one Base Transfer per fill /// - total op count is in `[2 * fills, 3 * fills]` (the extra op is @@ -2059,7 +2059,7 @@ mod settle_fills { /// This covers the fuzz shape the retired `settle_fill_ordering` /// proptest exercised, moved one layer up to the pure compute fn. #[test] - fn compute_balance_operations_matches_fill_shape( + fn settlement_balance_ops_match_fill_shape( output in crate::test_fixtures::arbitrary::arb_matching_output() ) { use crate::order::{self, PairToken}; From 4abdc5cf2160ab1d048ac238d79cdbc9e55d9fcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 24 Jun 2026 09:56:40 +0000 Subject: [PATCH 14/35] docs(did): express order VWAP as filled_quote / filled_quantity Addresses review comment 3465971111. Co-Authored-By: Claude Opus 4.8 (1M context) --- canister/oisy_trade.did | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/canister/oisy_trade.did b/canister/oisy_trade.did index a7644ef7..20412f6f 100644 --- a/canister/oisy_trade.did +++ b/canister/oisy_trade.did @@ -298,9 +298,9 @@ type OrderRecord = record { /// Time-in-force policy the order was placed with. time_in_force : TimeInForce; /// Cumulative realized quote notional transacted across the order's fills, - /// in quote-token smallest units. With `filled_quantity` (base smallest - /// units), this yields the volume-weighted average price (VWAP) the order - /// traded at. + /// in quote-token smallest units. The average execution price is + /// `filled_quote / filled_quantity` (VWAP), a ratio of the two tokens' + /// smallest units. filled_quote : nat; /// Cumulative realized fee charged across the order's fills, in the order's /// receive token — base for a buy, quote for a sell. From 15e624ed49c944eb4890a4ac2411e1520fa98bff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 24 Jun 2026 09:56:59 +0000 Subject: [PATCH 15/35] refactor(state): have FillSettlement own its Fill Addresses review comment 3465986823: `FillSettlement` now owns the `Fill` (no lifetime parameter), built by consuming `output.fills` in the single settlement pass, with all downstream access routed through `settlement.fill`. Refreshes the canbench baseline for the resulting sub-0.03% instruction drift. Co-Authored-By: Claude Opus 4.8 (1M context) --- canister/canbench_results.yml | 48 +++++++++++++++++------------------ canister/src/state/mod.rs | 18 ++++++------- canister/src/state/tests.rs | 14 +++++----- 3 files changed, 40 insertions(+), 40 deletions(-) diff --git a/canister/canbench_results.yml b/canister/canbench_results.yml index e7fc5d2c..d5310462 100644 --- a/canister/canbench_results.yml +++ b/canister/canbench_results.yml @@ -75,18 +75,18 @@ benches: bench_process_pending_orders_1000: total: calls: 1 - instructions: 1035099447 + instructions: 1035420679 heap_increase: 0 stable_memory_increase: 0 scopes: apply_order_updates: calls: 1 - instructions: 103778413 + instructions: 103778721 heap_increase: 0 stable_memory_increase: 0 bal: calls: 3481 - instructions: 17815940 + instructions: 17815969 heap_increase: 0 stable_memory_increase: 0 bal::debit_reserved: @@ -101,22 +101,22 @@ benches: stable_memory_increase: 0 bal::unreserve: calls: 405 - instructions: 2440451 + instructions: 2440521 heap_increase: 0 stable_memory_increase: 0 balances: calls: 1943 - instructions: 797023309 + instructions: 797357936 heap_increase: 0 stable_memory_increase: 0 balances::transfer: calls: 1538 - instructions: 700508158 + instructions: 700810102 heap_increase: 0 stable_memory_increase: 0 balances::unreserve: calls: 405 - instructions: 94097919 + instructions: 94119687 heap_increase: 0 stable_memory_increase: 0 book::apply_plan: @@ -141,12 +141,12 @@ benches: stable_memory_increase: 0 matching: calls: 1 - instructions: 148474317 + instructions: 148450351 heap_increase: 0 stable_memory_increase: 0 order_history: calls: 1785 - instructions: 130722703 + instructions: 130733436 heap_increase: 0 stable_memory_increase: 0 order_history::apply_update: @@ -161,7 +161,7 @@ benches: stable_memory_increase: 0 qty: calls: 17175 - instructions: 34832854 + instructions: 34832924 heap_increase: 0 stable_memory_increase: 0 qty::add: @@ -191,13 +191,13 @@ benches: stable_memory_increase: 0 settling: calls: 1 - instructions: 881123384 + instructions: 881468452 heap_increase: 0 stable_memory_increase: 0 bench_process_pending_orders_1000_no_fills: total: calls: 1 - instructions: 96666777 + instructions: 96666781 heap_increase: 0 stable_memory_increase: 0 scopes: @@ -228,7 +228,7 @@ benches: stable_memory_increase: 0 matching: calls: 1 - instructions: 96122120 + instructions: 96122124 heap_increase: 0 stable_memory_increase: 0 order_history: @@ -244,7 +244,7 @@ benches: bench_process_pending_orders_1000_with_fees: total: calls: 1 - instructions: 1059215493 + instructions: 1059191292 heap_increase: 0 stable_memory_increase: 0 scopes: @@ -310,7 +310,7 @@ benches: stable_memory_increase: 0 matching: calls: 1 - instructions: 166706728 + instructions: 166682527 heap_increase: 0 stable_memory_increase: 0 order_history: @@ -366,23 +366,23 @@ benches: bench_process_pending_orders_1_large: total: calls: 1 - instructions: 817426189 + instructions: 817177169 heap_increase: 0 stable_memory_increase: 0 scopes: apply_order_updates: calls: 1 - instructions: 71277414 + instructions: 71277307 heap_increase: 0 stable_memory_increase: 0 bal: calls: 2788 - instructions: 12929661 + instructions: 12929773 heap_increase: 0 stable_memory_increase: 0 bal::debit_reserved: calls: 1394 - instructions: 4673999 + instructions: 4674073 heap_increase: 0 stable_memory_increase: 0 bal::deposit: @@ -392,12 +392,12 @@ benches: stable_memory_increase: 0 balances: calls: 1394 - instructions: 643948795 + instructions: 643714100 heap_increase: 0 stable_memory_increase: 0 balances::transfer: calls: 1394 - instructions: 642332479 + instructions: 642098050 heap_increase: 0 stable_memory_increase: 0 book::apply_plan: @@ -422,7 +422,7 @@ benches: stable_memory_increase: 0 matching: calls: 1 - instructions: 98890337 + instructions: 98864352 heap_increase: 0 stable_memory_increase: 0 order_history: @@ -442,7 +442,7 @@ benches: stable_memory_increase: 0 qty: calls: 13245 - instructions: 25421244 + instructions: 25421318 heap_increase: 0 stable_memory_increase: 0 qty::add: @@ -472,7 +472,7 @@ benches: stable_memory_increase: 0 settling: calls: 1 - instructions: 714889314 + instructions: 714649290 heap_increase: 0 stable_memory_increase: 0 bench_read_events: diff --git a/canister/src/state/mod.rs b/canister/src/state/mod.rs index f41c5393..f0b198f8 100644 --- a/canister/src/state/mod.rs +++ b/canister/src/state/mod.rs @@ -421,18 +421,18 @@ impl State { // are gated below, so post-upgrade replay does not double-count. let mut balance_operations = Vec::with_capacity(output.fills.len() * 3); let mut updates: BTreeMap = BTreeMap::new(); - for fill in &output.fills { + for fill in output.fills { let settlement = fill_settlement(fill, fee_rates, base_scale); push_balance_operations(&mut balance_operations, &settlement); accrue_fill( - updates.entry(fill.taker_order_seq).or_default(), - fill.quantity, + updates.entry(settlement.fill.taker_order_seq).or_default(), + settlement.fill.quantity, settlement.notional, settlement.taker_fee, ); accrue_fill( - updates.entry(fill.maker_order_seq).or_default(), - fill.quantity, + updates.entry(settlement.fill.maker_order_seq).or_default(), + settlement.fill.quantity, settlement.notional, settlement.maker_fee, ); @@ -942,8 +942,8 @@ fn resolve_op_users( /// once in settlement (the only point where both `fee_rates` and `base_scale` /// are in scope) and reused to build both the [`event::BalanceOperation`]s and /// the per-order scalar deltas, so the two can never diverge. -struct FillSettlement<'a> { - fill: &'a Fill, +struct FillSettlement { + fill: Fill, /// Quote notional `maker_price × quantity / base_scale` (the executed /// price; a buy taker's reservation surplus is excluded). notional: Quantity, @@ -958,7 +958,7 @@ struct FillSettlement<'a> { } /// Compute the realized values of a single fill once. -fn fill_settlement(fill: &Fill, fee_rates: FeeRates, base_scale: NonZeroU64) -> FillSettlement<'_> { +fn fill_settlement(fill: Fill, fee_rates: FeeRates, base_scale: NonZeroU64) -> FillSettlement { // Receive-side convention: buyer pays fee in base (the asset they // receive), seller in quote. Each side's rate is `taker` if they // were the taker, else `maker`. @@ -997,7 +997,7 @@ fn fill_settlement(fill: &Fill, fee_rates: FeeRates, base_scale: NonZeroU64) -> /// Push the (up to three) balance operations a single fill settles into `ops`. fn push_balance_operations(ops: &mut Vec, settlement: &FillSettlement) { - let fill = settlement.fill; + let fill = &settlement.fill; let (buyer_seq, seller_seq) = match fill.taker_side { Side::Buy => (fill.taker_order_seq, fill.maker_order_seq), Side::Sell => (fill.maker_order_seq, fill.taker_order_seq), diff --git a/canister/src/state/tests.rs b/canister/src/state/tests.rs index b48b4547..d69aab79 100644 --- a/canister/src/state/tests.rs +++ b/canister/src/state/tests.rs @@ -2066,13 +2066,18 @@ mod settle_fills { use crate::state::event::BalanceOperation; let base_scale = std::num::NonZeroU64::new(PRICE_SCALE as u64).unwrap(); + let fills_len = output.fills.len(); + // Unreserves only fire for buy-taker fills with strictly positive + // price improvement. + let expected_unreserves = output.fills.iter().filter(|f| { + f.taker_side == order::Side::Buy && f.taker_price.get() > f.maker_price.get() + }).count(); let mut ops = Vec::new(); - for fill in &output.fills { + for fill in output.fills { let settlement = super::super::fill_settlement(fill, FeeRates::default(), base_scale); super::super::push_balance_operations(&mut ops, &settlement); } - let fills_len = output.fills.len(); prop_assert!( ops.len() >= 2 * fills_len && ops.len() <= 3 * fills_len, @@ -2091,11 +2096,6 @@ mod settle_fills { prop_assert_eq!(quote_transfers, fills_len); prop_assert_eq!(base_transfers, fills_len); - // Unreserves only fire for buy-taker fills with strictly positive - // price improvement. - let expected_unreserves = output.fills.iter().filter(|f| { - f.taker_side == order::Side::Buy && f.taker_price.get() > f.maker_price.get() - }).count(); let unreserves = ops.iter().filter(|o| matches!( o, BalanceOperation::Unreserve { .. } From 9767bfe9fcfcc69cc87d3a92a217f3f28c544fce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Wed, 24 Jun 2026 10:11:11 +0000 Subject: [PATCH 16/35] docs(types): align filled_quote VWAP docstring with the .did (drop base_scale) Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/types/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/types/src/lib.rs b/libs/types/src/lib.rs index 32d05370..7a184247 100644 --- a/libs/types/src/lib.rs +++ b/libs/types/src/lib.rs @@ -200,7 +200,8 @@ pub struct OrderRecord { pub time_in_force: TimeInForce, /// Cumulative realized quote notional transacted across the order's fills. /// Always quote-denominated; a buy taker's released reservation surplus is - /// excluded. VWAP is `filled_quote × base_scale / filled_quantity`. + /// excluded. VWAP (average execution price) is `filled_quote / filled_quantity`, + /// a ratio in the two tokens' smallest units. pub filled_quote: Nat, /// Cumulative realized fee charged across the order's fills, denominated in /// the order's receive token — base for a buy, quote for a sell. From e632896d1fafc16a1e338077dc773ac631ae3ad5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 25 Jun 2026 08:21:32 +0000 Subject: [PATCH 17/35] refactor(order): promote FillSettlement to a first-class type Move `Fill` and `FillSettlement` into a new `canister/src/order/fill.rs`, rewrite `fill_settlement` as the `FillSettlement::new` constructor, and turn `push_balance_operations` and `accrue_fill` into methods on `FillSettlement`. Condense the single-write-per-order comment at the settlement call site. Co-Authored-By: Claude Opus 4.8 (1M context) --- canister/src/order/book.rs | 42 +------- canister/src/order/fill.rs | 185 ++++++++++++++++++++++++++++++++++++ canister/src/order/mod.rs | 6 +- canister/src/state/mod.rs | 148 +++-------------------------- canister/src/state/tests.rs | 13 ++- 5 files changed, 208 insertions(+), 186 deletions(-) create mode 100644 canister/src/order/fill.rs diff --git a/canister/src/order/book.rs b/canister/src/order/book.rs index a0098276..70d1f304 100644 --- a/canister/src/order/book.rs +++ b/canister/src/order/book.rs @@ -1,12 +1,12 @@ use super::plan::FillPlan; use super::queue::{OrderQueue, OrderQueueIter}; use super::{ - FeeRates, LotSize, Order, OrderBookId, OrderSeq, Price, Quantity, RestingOrder, Side, TickSize, + FeeRates, Fill, LotSize, Order, OrderBookId, OrderSeq, Price, Quantity, RestingOrder, Side, + TickSize, }; use minicbor::{Decode, Encode}; use std::cmp::Reverse; use std::collections::{BTreeMap, BTreeSet, VecDeque}; -use std::num::NonZeroU64; /// Central limit order book for a single trading pair. /// @@ -544,44 +544,6 @@ impl MatchResult { } } -/// A single fill produced when an incoming order matches a resting order. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct Fill { - /// The sequence of the incoming (taker) order. - #[n(0)] - pub taker_order_seq: OrderSeq, - /// The side of the taker order. - #[n(1)] - pub taker_side: Side, - /// The limit price of the taker order. - #[n(2)] - pub taker_price: Price, - /// The sequence of the resting (maker) order that was matched. - #[n(3)] - pub maker_order_seq: OrderSeq, - /// The price at which the fill occurred (always the maker's price). - #[n(4)] - pub maker_price: Price, - /// The quantity filled. - #[n(5)] - pub quantity: Quantity, -} - -impl Fill { - /// The amount of quote tokens exchanged: - /// `maker_price × quantity / base_scale` (`base_scale = 10^base_decimals`). - pub fn quote_amount(&self, base_scale: NonZeroU64) -> Quantity { - self.maker_price - .checked_mul_quantity_scaled(&self.quantity, base_scale) - .expect("BUG: validation of order should prevent overflow") - } - - /// The amount of base tokens exchanged (same as quantity). - pub fn base_amount(&self) -> &Quantity { - &self.quantity - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub enum MatchOrderError { /// Price is not a positive multiple of the tick size. diff --git a/canister/src/order/fill.rs b/canister/src/order/fill.rs new file mode 100644 index 00000000..b1241c32 --- /dev/null +++ b/canister/src/order/fill.rs @@ -0,0 +1,185 @@ +use super::{FeeRates, OrderSeq, OrderUpdate, PairToken, Price, Quantity, Side}; +use crate::state::event; +use minicbor::{Decode, Encode}; +use std::num::NonZeroU64; + +/// A single fill produced when an incoming order matches a resting order. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct Fill { + /// The sequence of the incoming (taker) order. + #[n(0)] + pub taker_order_seq: OrderSeq, + /// The side of the taker order. + #[n(1)] + pub taker_side: Side, + /// The limit price of the taker order. + #[n(2)] + pub taker_price: Price, + /// The sequence of the resting (maker) order that was matched. + #[n(3)] + pub maker_order_seq: OrderSeq, + /// The price at which the fill occurred (always the maker's price). + #[n(4)] + pub maker_price: Price, + /// The quantity filled. + #[n(5)] + pub quantity: Quantity, +} + +impl Fill { + /// The amount of quote tokens exchanged: + /// `maker_price × quantity / base_scale` (`base_scale = 10^base_decimals`). + pub fn quote_amount(&self, base_scale: NonZeroU64) -> Quantity { + self.maker_price + .checked_mul_quantity_scaled(&self.quantity, base_scale) + .expect("BUG: validation of order should prevent overflow") + } + + /// The amount of base tokens exchanged (same as quantity). + pub fn base_amount(&self) -> &Quantity { + &self.quantity + } +} + +/// A single [`Fill`] together with the realized values derived from it, computed +/// once in settlement (the only point where both `fee_rates` and `base_scale` +/// are in scope) and reused to build both the [`event::BalanceOperation`]s and +/// the per-order scalar deltas, so the two can never diverge. +pub struct FillSettlement { + fill: Fill, + /// Quote notional `maker_price × quantity / base_scale` (the executed + /// price; a buy taker's reservation surplus is excluded). + notional: Quantity, + /// Fee charged to the taker order, in its receive token (base if the taker + /// bought, quote if it sold). + taker_fee: Quantity, + /// Fee charged to the maker order, in its receive token. + maker_fee: Quantity, + /// Quote surplus released back to a buy taker that crossed below its limit; + /// `None` for a sell taker or an exact-price fill. + surplus: Option, +} + +impl FillSettlement { + /// Compute the realized values of a single fill once. + pub fn new(fill: Fill, fee_rates: FeeRates, base_scale: NonZeroU64) -> Self { + // Receive-side convention: buyer pays fee in base (the asset they + // receive), seller in quote. Each side's rate is `taker` if they + // were the taker, else `maker`. + let (buyer_rate, seller_rate) = match fill.taker_side { + Side::Buy => (fee_rates.taker, fee_rates.maker), + Side::Sell => (fee_rates.maker, fee_rates.taker), + }; + let notional = fill.quote_amount(base_scale); + let quote_fee = seller_rate.mul_ceil(notional); + let base_fee = buyer_rate.mul_ceil(fill.quantity); + // The taker pays on the side it traded: base if it bought, quote if + // it sold. The maker pays on the opposite side. + let (taker_fee, maker_fee) = match fill.taker_side { + Side::Buy => (base_fee, quote_fee), + Side::Sell => (quote_fee, base_fee), + }; + let surplus = if fill.taker_side == Side::Buy + && let Some(diff) = fill.taker_price.checked_sub(fill.maker_price) + && !diff.is_zero() + { + Some( + diff.checked_mul_quantity_scaled(&fill.quantity, base_scale) + .expect( + "BUG: price_diff * quantity overflow — validated in validate_limit_order", + ), + ) + } else { + None + }; + Self { + fill, + notional, + taker_fee, + maker_fee, + surplus, + } + } + + /// Push the (up to three) balance operations a single fill settles into `ops`. + pub fn push_balance_operations(&self, ops: &mut Vec) { + let fill = &self.fill; + let (buyer_seq, seller_seq) = match fill.taker_side { + Side::Buy => (fill.taker_order_seq, fill.maker_order_seq), + Side::Sell => (fill.maker_order_seq, fill.taker_order_seq), + }; + let (quote_fee, base_fee) = match fill.taker_side { + Side::Buy => (self.maker_fee, self.taker_fee), + Side::Sell => (self.taker_fee, self.maker_fee), + }; + ops.push(event::BalanceOperation::Transfer { + from_order: buyer_seq, + to_order: seller_seq, + token: PairToken::Quote, + amount: self.notional, + fee: nonzero(quote_fee), + }); + if let Some(surplus) = self.surplus { + ops.push(event::BalanceOperation::Unreserve { + order: fill.taker_order_seq, + token: PairToken::Quote, + amount: surplus, + }); + } + ops.push(event::BalanceOperation::Transfer { + from_order: seller_seq, + to_order: buyer_seq, + token: PairToken::Base, + amount: fill.quantity, + fee: nonzero(base_fee), + }); + } + + /// Fee charged to the taker order, in its receive token. + pub fn taker_fee(&self) -> Quantity { + self.taker_fee + } + + /// Fee charged to the maker order, in its receive token. + pub fn maker_fee(&self) -> Quantity { + self.maker_fee + } + + /// Sequence of the taker order this fill belongs to. + pub fn taker_order_seq(&self) -> OrderSeq { + self.fill.taker_order_seq + } + + /// Sequence of the maker order this fill belongs to. + pub fn maker_order_seq(&self) -> OrderSeq { + self.fill.maker_order_seq + } + + /// Fold this fill's realized values into one order's [`OrderUpdate`] entry, + /// charging `fee` in that order's receive token. `filled_delta` accumulates + /// the gross base quantity, `quote_delta` the realized notional, and + /// `fee_delta` the realized fee. All three are accumulated with always-on + /// overflow traps. + pub fn accrue_fill(&self, update: &mut OrderUpdate, fee: Quantity) { + update.filled_delta = update + .filled_delta + .checked_add(self.fill.quantity) + .expect("BUG: filled_delta overflow"); + update.quote_delta = update + .quote_delta + .checked_add(self.notional) + .expect("BUG: quote_delta overflow"); + update.fee_delta = update + .fee_delta + .checked_add(fee) + .expect("BUG: fee_delta overflow"); + } +} + +/// Collapse a zero-quantity fee to `None`. Keeps `Some(_)` reserved for +/// "fee was actually charged" so callers (audit log, apply path, +/// `/metrics`) can distinguish "no fee on this fill" from "fee of zero +/// charged". +fn nonzero(q: Quantity) -> Option { + if q.is_zero() { None } else { Some(q) } +} diff --git a/canister/src/order/mod.rs b/canister/src/order/mod.rs index c09b6d62..fdcd7f5c 100644 --- a/canister/src/order/mod.rs +++ b/canister/src/order/mod.rs @@ -1,5 +1,6 @@ mod book; mod fees; +mod fill; mod history; mod plan; mod queue; @@ -7,10 +8,11 @@ mod queue; mod tests; pub use book::{ - Fill, MatchOrderError, MatchResult, MatchingOutput, NotionalError, OrderBook, - OrderBookSnapshot, PriceLevel, RemovedOrder, + MatchOrderError, MatchResult, MatchingOutput, NotionalError, OrderBook, OrderBookSnapshot, + PriceLevel, RemovedOrder, }; pub use fees::{BasisPoint, FeeRates, InvalidBasisPoint}; +pub use fill::{Fill, FillSettlement}; pub use history::{CursorNotFound, OrderHistory, OrderUpdate}; use candid::{Nat, Principal}; diff --git a/canister/src/state/mod.rs b/canister/src/state/mod.rs index f0b198f8..db83339f 100644 --- a/canister/src/state/mod.rs +++ b/canister/src/state/mod.rs @@ -18,7 +18,7 @@ use crate::Task; use crate::Timestamp; use crate::balance::{Balance, TokenBalance}; use crate::order::{ - self, CursorNotFound, FeeRates, Fill, LotSize, MatchOrderError, NotionalError, Order, + CursorNotFound, FeeRates, FillSettlement, LotSize, MatchOrderError, NotionalError, Order, OrderBook, OrderBookId, OrderHistory, OrderId, OrderRecord, OrderSeq, OrderStatus, OrderUpdate, PairToken, PendingOrder, Quantity, RemovedOrder, Side, TickSize, TokenId, TokenMetadata, TradingPair, @@ -413,28 +413,20 @@ impl State { // two can never diverge. The per-fill `Quantity` arithmetic is u256-wide, // so it is done exactly once and never materialized into a `Vec`. // - // `updates` folds the batch into one update per touched order, applied - // once each: a single batch can fill one order across many `Fill`s (a - // taker sweeping several makers, or a maker hit repeatedly) and an order - // can both change status and accrue fills in the same batch. The cheap - // delta accumulation runs unconditionally; the writes to stable memory - // are gated below, so post-upgrade replay does not double-count. + // `updates` folds the batch into at most one write per `Order` to + // minimize instruction costs in dealing with stable structures. let mut balance_operations = Vec::with_capacity(output.fills.len() * 3); let mut updates: BTreeMap = BTreeMap::new(); for fill in output.fills { - let settlement = fill_settlement(fill, fee_rates, base_scale); - push_balance_operations(&mut balance_operations, &settlement); - accrue_fill( - updates.entry(settlement.fill.taker_order_seq).or_default(), - settlement.fill.quantity, - settlement.notional, - settlement.taker_fee, + let settlement = FillSettlement::new(fill, fee_rates, base_scale); + settlement.push_balance_operations(&mut balance_operations); + settlement.accrue_fill( + updates.entry(settlement.taker_order_seq()).or_default(), + settlement.taker_fee(), ); - accrue_fill( - updates.entry(settlement.fill.maker_order_seq).or_default(), - settlement.fill.quantity, - settlement.notional, - settlement.maker_fee, + settlement.accrue_fill( + updates.entry(settlement.maker_order_seq()).or_default(), + settlement.maker_fee(), ); } if matches!(persistence, StableMemoryOptions::Write) { @@ -938,124 +930,6 @@ fn resolve_op_users( .collect() } -/// A single [`Fill`] together with the realized values derived from it, computed -/// once in settlement (the only point where both `fee_rates` and `base_scale` -/// are in scope) and reused to build both the [`event::BalanceOperation`]s and -/// the per-order scalar deltas, so the two can never diverge. -struct FillSettlement { - fill: Fill, - /// Quote notional `maker_price × quantity / base_scale` (the executed - /// price; a buy taker's reservation surplus is excluded). - notional: Quantity, - /// Fee charged to the taker order, in its receive token (base if the taker - /// bought, quote if it sold). - taker_fee: Quantity, - /// Fee charged to the maker order, in its receive token. - maker_fee: Quantity, - /// Quote surplus released back to a buy taker that crossed below its limit; - /// `None` for a sell taker or an exact-price fill. - surplus: Option, -} - -/// Compute the realized values of a single fill once. -fn fill_settlement(fill: Fill, fee_rates: FeeRates, base_scale: NonZeroU64) -> FillSettlement { - // Receive-side convention: buyer pays fee in base (the asset they - // receive), seller in quote. Each side's rate is `taker` if they - // were the taker, else `maker`. - let (buyer_rate, seller_rate) = match fill.taker_side { - Side::Buy => (fee_rates.taker, fee_rates.maker), - Side::Sell => (fee_rates.maker, fee_rates.taker), - }; - let notional = fill.quote_amount(base_scale); - let quote_fee = seller_rate.mul_ceil(notional); - let base_fee = buyer_rate.mul_ceil(fill.quantity); - // The taker pays on the side it traded: base if it bought, quote if - // it sold. The maker pays on the opposite side. - let (taker_fee, maker_fee) = match fill.taker_side { - Side::Buy => (base_fee, quote_fee), - Side::Sell => (quote_fee, base_fee), - }; - let surplus = if fill.taker_side == Side::Buy - && let Some(diff) = fill.taker_price.checked_sub(fill.maker_price) - && !diff.is_zero() - { - Some( - diff.checked_mul_quantity_scaled(&fill.quantity, base_scale) - .expect("BUG: price_diff * quantity overflow — validated in validate_limit_order"), - ) - } else { - None - }; - FillSettlement { - fill, - notional, - taker_fee, - maker_fee, - surplus, - } -} - -/// Push the (up to three) balance operations a single fill settles into `ops`. -fn push_balance_operations(ops: &mut Vec, settlement: &FillSettlement) { - let fill = &settlement.fill; - let (buyer_seq, seller_seq) = match fill.taker_side { - Side::Buy => (fill.taker_order_seq, fill.maker_order_seq), - Side::Sell => (fill.maker_order_seq, fill.taker_order_seq), - }; - let (quote_fee, base_fee) = match fill.taker_side { - Side::Buy => (settlement.maker_fee, settlement.taker_fee), - Side::Sell => (settlement.taker_fee, settlement.maker_fee), - }; - ops.push(event::BalanceOperation::Transfer { - from_order: buyer_seq, - to_order: seller_seq, - token: order::PairToken::Quote, - amount: settlement.notional, - fee: nonzero(quote_fee), - }); - if let Some(surplus) = settlement.surplus { - ops.push(event::BalanceOperation::Unreserve { - order: fill.taker_order_seq, - token: order::PairToken::Quote, - amount: surplus, - }); - } - ops.push(event::BalanceOperation::Transfer { - from_order: seller_seq, - to_order: buyer_seq, - token: order::PairToken::Base, - amount: fill.quantity, - fee: nonzero(base_fee), - }); -} - -/// Fold one fill's realized values into an [`OrderUpdate`] entry. `filled_delta` -/// accumulates the gross base quantity, `quote_delta` the realized notional, and -/// `fee_delta` the realized fee in the order's receive token. All three are -/// accumulated with always-on overflow traps. -fn accrue_fill(update: &mut OrderUpdate, quantity: Quantity, notional: Quantity, fee: Quantity) { - update.filled_delta = update - .filled_delta - .checked_add(quantity) - .expect("BUG: filled_delta overflow"); - update.quote_delta = update - .quote_delta - .checked_add(notional) - .expect("BUG: quote_delta overflow"); - update.fee_delta = update - .fee_delta - .checked_add(fee) - .expect("BUG: fee_delta overflow"); -} - -/// Collapse a zero-quantity fee to `None`. Keeps `Some(_)` reserved for -/// "fee was actually charged" so callers (audit log, apply path, -/// `/metrics`) can distinguish "no fee on this fill" from "fee of zero -/// charged". -fn nonzero(q: Quantity) -> Option { - if q.is_zero() { None } else { Some(q) } -} - /// `oisy_trade_types::Balance` carrying a fee amount in `free` and zero in /// `reserved`. Fees have no reserved concept; the `Balance` shape is /// reused to keep the `get_fee_balances` response identical in shape to diff --git a/canister/src/state/tests.rs b/canister/src/state/tests.rs index d69aab79..e0bfc2a4 100644 --- a/canister/src/state/tests.rs +++ b/canister/src/state/tests.rs @@ -2046,11 +2046,11 @@ mod settle_fills { // been retired — settlement is now a flat `Vec` in // `SettlingEvent`. Commutativity isn't claimed for arbitrary op sequences // (two Transfers from the same debtor can fail depending on order), only - // for op sequences produced by `fill_settlement` + `push_balance_operations` - // from a valid `MatchingOutput`. + // for op sequences produced by `FillSettlement::new` + + // `FillSettlement::push_balance_operations` from a valid `MatchingOutput`. proptest! { - /// `fill_settlement` + `push_balance_operations` preserve structural invariants + /// `FillSettlement::new` + `push_balance_operations` preserve structural invariants /// over any `MatchingOutput` the arbitrary strategy can produce: /// - never panics /// - emits exactly one Quote Transfer and one Base Transfer per fill @@ -2062,7 +2062,7 @@ mod settle_fills { fn settlement_balance_ops_match_fill_shape( output in crate::test_fixtures::arbitrary::arb_matching_output() ) { - use crate::order::{self, PairToken}; + use crate::order::{self, FillSettlement, PairToken}; use crate::state::event::BalanceOperation; let base_scale = std::num::NonZeroU64::new(PRICE_SCALE as u64).unwrap(); @@ -2074,9 +2074,8 @@ mod settle_fills { }).count(); let mut ops = Vec::new(); for fill in output.fills { - let settlement = - super::super::fill_settlement(fill, FeeRates::default(), base_scale); - super::super::push_balance_operations(&mut ops, &settlement); + let settlement = FillSettlement::new(fill, FeeRates::default(), base_scale); + settlement.push_balance_operations(&mut ops); } prop_assert!( From b1b9d564e14f1b1cbeb71608e1d607c42c7d3e8b Mon Sep 17 00:00:00 2001 From: gregorydemay Date: Thu, 25 Jun 2026 15:55:34 +0200 Subject: [PATCH 18/35] DEFI-2901: refactor accrue_fill --- canister/src/order/fill.rs | 39 ++++++++++++++++++++------------------ canister/src/state/mod.rs | 9 +-------- 2 files changed, 22 insertions(+), 26 deletions(-) diff --git a/canister/src/order/fill.rs b/canister/src/order/fill.rs index b1241c32..64b4b071 100644 --- a/canister/src/order/fill.rs +++ b/canister/src/order/fill.rs @@ -1,6 +1,7 @@ use super::{FeeRates, OrderSeq, OrderUpdate, PairToken, Price, Quantity, Side}; use crate::state::event; use minicbor::{Decode, Encode}; +use std::collections::BTreeMap; use std::num::NonZeroU64; /// A single fill produced when an incoming order matches a resting order. @@ -155,24 +156,26 @@ impl FillSettlement { self.fill.maker_order_seq } - /// Fold this fill's realized values into one order's [`OrderUpdate`] entry, - /// charging `fee` in that order's receive token. `filled_delta` accumulates - /// the gross base quantity, `quote_delta` the realized notional, and - /// `fee_delta` the realized fee. All three are accumulated with always-on - /// overflow traps. - pub fn accrue_fill(&self, update: &mut OrderUpdate, fee: Quantity) { - update.filled_delta = update - .filled_delta - .checked_add(self.fill.quantity) - .expect("BUG: filled_delta overflow"); - update.quote_delta = update - .quote_delta - .checked_add(self.notional) - .expect("BUG: quote_delta overflow"); - update.fee_delta = update - .fee_delta - .checked_add(fee) - .expect("BUG: fee_delta overflow"); + /// Update maker and taker orders based on this fill. + pub fn accrue_fill(&self, updates: &mut BTreeMap) { + for (order_seq, fee) in [ + (self.maker_order_seq(), self.maker_fee), + (self.taker_order_seq(), self.taker_fee), + ] { + let update = updates.entry(order_seq).or_default(); + update.filled_delta = update + .filled_delta + .checked_add(self.fill.quantity) + .expect("BUG: filled_delta overflow"); + update.quote_delta = update + .quote_delta + .checked_add(self.notional) + .expect("BUG: quote_delta overflow"); + update.fee_delta = update + .fee_delta + .checked_add(fee) + .expect("BUG: fee_delta overflow"); + } } } diff --git a/canister/src/state/mod.rs b/canister/src/state/mod.rs index ca9a4c05..45f7f0c7 100644 --- a/canister/src/state/mod.rs +++ b/canister/src/state/mod.rs @@ -413,14 +413,7 @@ impl State { for fill in &output.fills { let settlement = FillSettlement::new(fill.clone(), fee_rates, base_scale); settlement.push_balance_operations(&mut balance_operations); - settlement.accrue_fill( - updates.entry(settlement.taker_order_seq()).or_default(), - settlement.taker_fee(), - ); - settlement.accrue_fill( - updates.entry(settlement.maker_order_seq()).or_default(), - settlement.maker_fee(), - ); + settlement.accrue_fill(&mut updates); } // A killed fill-or-kill order never touched the book, so its full // placement reservation must be released. From 20e5b7e0d3c2d79791e576f214fa504aba06d618 Mon Sep 17 00:00:00 2001 From: gregorydemay Date: Thu, 25 Jun 2026 16:18:28 +0200 Subject: [PATCH 19/35] DEFI-2901: refactor FillSettlement --- canister/src/order/fill.rs | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/canister/src/order/fill.rs b/canister/src/order/fill.rs index 64b4b071..a9e43a36 100644 --- a/canister/src/order/fill.rs +++ b/canister/src/order/fill.rs @@ -57,8 +57,8 @@ pub struct FillSettlement { /// Fee charged to the maker order, in its receive token. maker_fee: Quantity, /// Quote surplus released back to a buy taker that crossed below its limit; - /// `None` for a sell taker or an exact-price fill. - surplus: Option, + /// Zero for a sell taker or an exact-price fill. + surplus: Quantity, } impl FillSettlement { @@ -84,14 +84,10 @@ impl FillSettlement { && let Some(diff) = fill.taker_price.checked_sub(fill.maker_price) && !diff.is_zero() { - Some( - diff.checked_mul_quantity_scaled(&fill.quantity, base_scale) - .expect( - "BUG: price_diff * quantity overflow — validated in validate_limit_order", - ), - ) + diff.checked_mul_quantity_scaled(&fill.quantity, base_scale) + .expect("BUG: price_diff * quantity overflow — validated in validate_limit_order") } else { - None + Quantity::ZERO }; Self { fill, @@ -120,11 +116,11 @@ impl FillSettlement { amount: self.notional, fee: nonzero(quote_fee), }); - if let Some(surplus) = self.surplus { + if !self.surplus.is_zero() { ops.push(event::BalanceOperation::Unreserve { order: fill.taker_order_seq, token: PairToken::Quote, - amount: surplus, + amount: self.surplus, }); } ops.push(event::BalanceOperation::Transfer { From 67700b917f3ff1ed6ea7f798f462b9d7e189ce91 Mon Sep 17 00:00:00 2001 From: gregorydemay Date: Thu, 25 Jun 2026 16:20:38 +0200 Subject: [PATCH 20/35] DEFI-2901: remove dead code --- canister/src/order/fill.rs | 24 ++---------------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/canister/src/order/fill.rs b/canister/src/order/fill.rs index a9e43a36..6164f19c 100644 --- a/canister/src/order/fill.rs +++ b/canister/src/order/fill.rs @@ -132,31 +132,11 @@ impl FillSettlement { }); } - /// Fee charged to the taker order, in its receive token. - pub fn taker_fee(&self) -> Quantity { - self.taker_fee - } - - /// Fee charged to the maker order, in its receive token. - pub fn maker_fee(&self) -> Quantity { - self.maker_fee - } - - /// Sequence of the taker order this fill belongs to. - pub fn taker_order_seq(&self) -> OrderSeq { - self.fill.taker_order_seq - } - - /// Sequence of the maker order this fill belongs to. - pub fn maker_order_seq(&self) -> OrderSeq { - self.fill.maker_order_seq - } - /// Update maker and taker orders based on this fill. pub fn accrue_fill(&self, updates: &mut BTreeMap) { for (order_seq, fee) in [ - (self.maker_order_seq(), self.maker_fee), - (self.taker_order_seq(), self.taker_fee), + (self.fill.maker_order_seq, self.maker_fee), + (self.fill.taker_order_seq, self.taker_fee), ] { let update = updates.entry(order_seq).or_default(); update.filled_delta = update From a23bf6e018aed56825507eb51ef4514b393f5a4f Mon Sep 17 00:00:00 2001 From: gregorydemay Date: Thu, 25 Jun 2026 16:24:14 +0200 Subject: [PATCH 21/35] DEFI-2901: simplify comments --- canister/src/state/mod.rs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/canister/src/state/mod.rs b/canister/src/state/mod.rs index 45f7f0c7..656a1bd5 100644 --- a/canister/src/state/mod.rs +++ b/canister/src/state/mod.rs @@ -399,14 +399,9 @@ impl State { .expect("BUG: trading pair registered but order book missing"); let fee_rates = book.fee_rates(); let output = book.process_pending_orders(&event.orders); - // Single pass over the fills: compute each fill's realized notional, - // fees, and roles once (`FillSettlement`), then feed both the balance - // operations and the per-order scalar deltas from that one value, so the - // two can never diverge. The per-fill `Quantity` arithmetic is u256-wide, - // so it is done exactly once and never materialized into a `Vec`. - // - // `updates` folds the batch into at most one write per `Order` to - // minimize instruction costs in dealing with stable structures. + + // Single pass over the fills: memoize updates to stable structures + // to write an entry only once to minimize instruction costs. let mut balance_operations = Vec::with_capacity(output.fills.len() * 3 + output.expired_orders.len()); let mut updates: BTreeMap = BTreeMap::new(); From 738a76ef3f054453b97bf0bca0e839a785b0abc1 Mon Sep 17 00:00:00 2001 From: gregorydemay Date: Thu, 25 Jun 2026 16:40:15 +0200 Subject: [PATCH 22/35] DEFI-2901: remove unnecessary clone --- canister/canbench_results.yml | 246 +++++++++++++++++----------------- canister/src/state/mod.rs | 4 +- 2 files changed, 125 insertions(+), 125 deletions(-) diff --git a/canister/canbench_results.yml b/canister/canbench_results.yml index 7a88c0d3..318e395b 100644 --- a/canister/canbench_results.yml +++ b/canister/canbench_results.yml @@ -2,23 +2,23 @@ benches: bench_fok_fill_full_bid_side: total: calls: 1 - instructions: 812876221 + instructions: 812665743 heap_increase: 0 stable_memory_increase: 0 scopes: apply_order_updates: calls: 1 - instructions: 71240464 + instructions: 71229172 heap_increase: 0 stable_memory_increase: 0 bal: calls: 2788 - instructions: 12297100 + instructions: 12297101 heap_increase: 0 stable_memory_increase: 0 bal::debit_reserved: calls: 1394 - instructions: 4464586 + instructions: 4464693 heap_increase: 0 stable_memory_increase: 0 bal::deposit: @@ -28,42 +28,42 @@ benches: stable_memory_increase: 0 balances: calls: 1394 - instructions: 641029037 + instructions: 640848367 heap_increase: 0 stable_memory_increase: 0 balances::transfer: calls: 1394 - instructions: 638170100 + instructions: 637989483 heap_increase: 0 stable_memory_increase: 0 book::apply_plan: calls: 1 - instructions: 4521046 + instructions: 4520980 heap_increase: 0 stable_memory_increase: 0 book::match_order: calls: 1 - instructions: 6416989 + instructions: 6416777 heap_increase: 0 stable_memory_increase: 0 book::plan_fills: calls: 1 - instructions: 1892368 + instructions: 1892220 heap_increase: 0 stable_memory_increase: 0 matching: calls: 1 - instructions: 98167372 + instructions: 98137754 heap_increase: 0 stable_memory_increase: 0 order_history: calls: 1396 - instructions: 95552328 + instructions: 95541036 heap_increase: 0 stable_memory_increase: 0 order_history::apply_update: calls: 698 - instructions: 68179927 + instructions: 68168729 heap_increase: 0 stable_memory_increase: 0 order_history::get: @@ -73,7 +73,7 @@ benches: stable_memory_increase: 0 qty: calls: 13245 - instructions: 25137371 + instructions: 25137219 heap_increase: 0 stable_memory_increase: 0 qty::add: @@ -93,7 +93,7 @@ benches: stable_memory_increase: 0 qty::mul_u128: calls: 697 - instructions: 2839835 + instructions: 2839788 heap_increase: 0 stable_memory_increase: 0 qty::mul_u64: @@ -103,64 +103,64 @@ benches: stable_memory_increase: 0 settling: calls: 1 - instructions: 711044029 + instructions: 710863213 heap_increase: 0 stable_memory_increase: 0 bench_fok_killed_full_bid_side: total: calls: 1 - instructions: 2416071 + instructions: 2415824 heap_increase: 0 stable_memory_increase: 0 scopes: apply_order_updates: calls: 1 - instructions: 123480 + instructions: 123467 heap_increase: 0 stable_memory_increase: 0 bal: calls: 1 - instructions: 8640 + instructions: 8642 heap_increase: 0 stable_memory_increase: 0 bal::unreserve: calls: 1 - instructions: 7077 + instructions: 7078 heap_increase: 0 stable_memory_increase: 0 balances: calls: 1 - instructions: 249484 + instructions: 249421 heap_increase: 0 stable_memory_increase: 0 balances::unreserve: calls: 1 - instructions: 247664 + instructions: 247600 heap_increase: 0 stable_memory_increase: 0 book::match_order: calls: 1 - instructions: 1894301 + instructions: 1894154 heap_increase: 0 stable_memory_increase: 0 book::plan_fills: calls: 1 - instructions: 1892368 + instructions: 1892220 heap_increase: 0 stable_memory_increase: 0 matching: calls: 1 - instructions: 2025671 + instructions: 2025511 heap_increase: 0 stable_memory_increase: 0 order_history: calls: 2 - instructions: 165275 + instructions: 165262 heap_increase: 0 stable_memory_increase: 0 order_history::apply_update: calls: 1 - instructions: 117868 + instructions: 117853 heap_increase: 0 stable_memory_increase: 0 order_history::get: @@ -170,7 +170,7 @@ benches: stable_memory_increase: 0 qty: calls: 699 - instructions: 1138071 + instructions: 1138025 heap_increase: 0 stable_memory_increase: 0 qty::add: @@ -185,19 +185,19 @@ benches: stable_memory_increase: 0 settling: calls: 1 - instructions: 369923 + instructions: 369862 heap_increase: 0 stable_memory_increase: 0 bench_get_my_orders: total: calls: 1 - instructions: 53721150 + instructions: 53713631 heap_increase: 0 stable_memory_increase: 0 scopes: order_history: calls: 1010 - instructions: 43237790 + instructions: 43237318 heap_increase: 0 stable_memory_increase: 0 order_history::get: @@ -207,19 +207,19 @@ benches: stable_memory_increase: 0 order_history::orders_after: calls: 10 - instructions: 3403253 + instructions: 3402839 heap_increase: 0 stable_memory_increase: 0 bench_get_order_book_depth_default: total: calls: 1 - instructions: 723900 + instructions: 723830 heap_increase: 0 stable_memory_increase: 0 scopes: qty: calls: 200 - instructions: 275500 + instructions: 275465 heap_increase: 0 stable_memory_increase: 0 qty::add: @@ -230,13 +230,13 @@ benches: bench_get_order_book_depth_max: total: calls: 1 - instructions: 6000477 + instructions: 6000371 heap_increase: 0 stable_memory_increase: 0 scopes: qty: calls: 1697 - instructions: 2332507 + instructions: 2332454 heap_increase: 0 stable_memory_increase: 0 qty::add: @@ -247,13 +247,13 @@ benches: bench_get_order_book_ticker: total: calls: 1 - instructions: 10097 + instructions: 10099 heap_increase: 0 stable_memory_increase: 0 scopes: qty: calls: 2 - instructions: 2864 + instructions: 2865 heap_increase: 0 stable_memory_increase: 0 qty::add: @@ -264,23 +264,23 @@ benches: bench_gtc_fill_full_bid_side: total: calls: 1 - instructions: 812876207 + instructions: 812665729 heap_increase: 0 stable_memory_increase: 0 scopes: apply_order_updates: calls: 1 - instructions: 71240470 + instructions: 71229178 heap_increase: 0 stable_memory_increase: 0 bal: calls: 2788 - instructions: 12297100 + instructions: 12297101 heap_increase: 0 stable_memory_increase: 0 bal::debit_reserved: calls: 1394 - instructions: 4464586 + instructions: 4464693 heap_increase: 0 stable_memory_increase: 0 bal::deposit: @@ -290,42 +290,42 @@ benches: stable_memory_increase: 0 balances: calls: 1394 - instructions: 641029037 + instructions: 640848367 heap_increase: 0 stable_memory_increase: 0 balances::transfer: calls: 1394 - instructions: 638170100 + instructions: 637989483 heap_increase: 0 stable_memory_increase: 0 book::apply_plan: calls: 1 - instructions: 4521046 + instructions: 4520980 heap_increase: 0 stable_memory_increase: 0 book::match_order: calls: 1 - instructions: 6416967 + instructions: 6416755 heap_increase: 0 stable_memory_increase: 0 book::plan_fills: calls: 1 - instructions: 1892368 + instructions: 1892220 heap_increase: 0 stable_memory_increase: 0 matching: calls: 1 - instructions: 98167356 + instructions: 98137738 heap_increase: 0 stable_memory_increase: 0 order_history: calls: 1396 - instructions: 95552336 + instructions: 95541044 heap_increase: 0 stable_memory_increase: 0 order_history::apply_update: calls: 698 - instructions: 68179933 + instructions: 68168735 heap_increase: 0 stable_memory_increase: 0 order_history::get: @@ -335,7 +335,7 @@ benches: stable_memory_increase: 0 qty: calls: 13245 - instructions: 25137371 + instructions: 25137219 heap_increase: 0 stable_memory_increase: 0 qty::add: @@ -355,7 +355,7 @@ benches: stable_memory_increase: 0 qty::mul_u128: calls: 697 - instructions: 2839835 + instructions: 2839788 heap_increase: 0 stable_memory_increase: 0 qty::mul_u64: @@ -365,24 +365,24 @@ benches: stable_memory_increase: 0 settling: calls: 1 - instructions: 711044031 + instructions: 710863215 heap_increase: 0 stable_memory_increase: 0 bench_process_pending_orders_1000: total: calls: 1 - instructions: 1038010874 + instructions: 1037763412 heap_increase: 0 stable_memory_increase: 0 scopes: apply_order_updates: calls: 1 - instructions: 104423916 + instructions: 104407624 heap_increase: 0 stable_memory_increase: 0 bal: calls: 3481 - instructions: 17860262 + instructions: 17860103 heap_increase: 0 stable_memory_increase: 0 bal::debit_reserved: @@ -392,62 +392,62 @@ benches: stable_memory_increase: 0 bal::deposit: calls: 1538 - instructions: 4834914 + instructions: 4834908 heap_increase: 0 stable_memory_increase: 0 bal::unreserve: calls: 405 - instructions: 2440449 + instructions: 2440443 heap_increase: 0 stable_memory_increase: 0 balances: calls: 1943 - instructions: 797933464 + instructions: 797727717 heap_increase: 0 stable_memory_increase: 0 balances::transfer: calls: 1538 - instructions: 701372163 + instructions: 701190188 heap_increase: 0 stable_memory_increase: 0 balances::unreserve: calls: 405 - instructions: 94158741 + instructions: 94135063 heap_increase: 0 stable_memory_increase: 0 book::apply_plan: calls: 1000 - instructions: 7811184 + instructions: 7807039 heap_increase: 0 stable_memory_increase: 0 book::insert_order: calls: 473 - instructions: 1296385 + instructions: 1295731 heap_increase: 0 stable_memory_increase: 0 book::match_order: calls: 1000 - instructions: 14295838 + instructions: 14290821 heap_increase: 0 stable_memory_increase: 0 book::plan_fills: calls: 1000 - instructions: 3188249 + instructions: 3187471 heap_increase: 0 stable_memory_increase: 0 matching: calls: 1 - instructions: 149336675 + instructions: 149295126 heap_increase: 0 stable_memory_increase: 0 order_history: calls: 1785 - instructions: 131693042 + instructions: 131677007 heap_increase: 0 stable_memory_increase: 0 order_history::apply_update: calls: 1010 - instructions: 99776378 + instructions: 99760306 heap_increase: 0 stable_memory_increase: 0 order_history::get: @@ -457,7 +457,7 @@ benches: stable_memory_increase: 0 qty: calls: 17175 - instructions: 34836768 + instructions: 34836473 heap_increase: 0 stable_memory_increase: 0 qty::add: @@ -477,7 +477,7 @@ benches: stable_memory_increase: 0 qty::mul_u128: calls: 1174 - instructions: 5131905 + instructions: 5131846 heap_increase: 0 stable_memory_increase: 0 qty::mul_u64: @@ -487,34 +487,34 @@ benches: stable_memory_increase: 0 settling: calls: 1 - instructions: 883189424 + instructions: 882983571 heap_increase: 0 stable_memory_increase: 0 bench_process_pending_orders_1000_no_fills: total: calls: 1 - instructions: 96847371 + instructions: 96831972 heap_increase: 0 stable_memory_increase: 0 scopes: apply_order_updates: calls: 1 - instructions: 85793495 + instructions: 85778401 heap_increase: 0 stable_memory_increase: 0 book::apply_plan: calls: 1000 - instructions: 3308223 + instructions: 3308080 heap_increase: 0 stable_memory_increase: 0 book::insert_order: calls: 1000 - instructions: 1699675 + instructions: 1699579 heap_increase: 0 stable_memory_increase: 0 book::match_order: calls: 1000 - instructions: 7097308 + instructions: 7097071 heap_increase: 0 stable_memory_increase: 0 book::plan_fills: @@ -524,34 +524,34 @@ benches: stable_memory_increase: 0 matching: calls: 1 - instructions: 96302691 + instructions: 96287318 heap_increase: 0 stable_memory_increase: 0 order_history: calls: 1000 - instructions: 82291066 + instructions: 82276019 heap_increase: 0 stable_memory_increase: 0 order_history::apply_update: calls: 1000 - instructions: 80220796 + instructions: 80205796 heap_increase: 0 stable_memory_increase: 0 bench_process_pending_orders_1000_with_fees: total: calls: 1 - instructions: 1064726720 + instructions: 1063243986 heap_increase: 0 stable_memory_increase: 0 scopes: apply_order_updates: calls: 1 - instructions: 107312401 + instructions: 106639270 heap_increase: 0 stable_memory_increase: 0 bal: calls: 3481 - instructions: 17209921 + instructions: 17168624 heap_increase: 0 stable_memory_increase: 0 bal::debit_reserved: @@ -566,67 +566,67 @@ benches: stable_memory_increase: 0 bal::unreserve: calls: 405 - instructions: 2440547 + instructions: 2440456 heap_increase: 0 stable_memory_increase: 0 balances: calls: 1943 - instructions: 806140221 + instructions: 805366507 heap_increase: 0 stable_memory_increase: 0 balances::transfer: calls: 1538 - instructions: 709050879 + instructions: 708386798 heap_increase: 0 stable_memory_increase: 0 balances::unreserve: calls: 405 - instructions: 94662310 + instructions: 94552731 heap_increase: 0 stable_memory_increase: 0 book::apply_plan: calls: 1000 - instructions: 7811184 + instructions: 7807039 heap_increase: 0 stable_memory_increase: 0 book::insert_order: calls: 473 - instructions: 1296385 + instructions: 1295731 heap_increase: 0 stable_memory_increase: 0 book::match_order: calls: 1000 - instructions: 14295838 + instructions: 14290821 heap_increase: 0 stable_memory_increase: 0 book::plan_fills: calls: 1000 - instructions: 3188249 + instructions: 3187471 heap_increase: 0 stable_memory_increase: 0 matching: calls: 1 - instructions: 167405119 + instructions: 166706746 heap_increase: 0 stable_memory_increase: 0 order_history: calls: 1785 - instructions: 134590838 + instructions: 133918361 heap_increase: 0 stable_memory_increase: 0 order_history::apply_update: calls: 1010 - instructions: 102646722 + instructions: 101974322 heap_increase: 0 stable_memory_increase: 0 order_history::get: calls: 775 - instructions: 29273470 + instructions: 29273541 heap_increase: 0 stable_memory_increase: 0 qty: calls: 24102 - instructions: 49518573 + instructions: 49518094 heap_increase: 0 stable_memory_increase: 0 qty::add: @@ -646,7 +646,7 @@ benches: stable_memory_increase: 0 qty::mul_u128: calls: 1174 - instructions: 4876088 + instructions: 4876089 heap_increase: 0 stable_memory_increase: 0 qty::mul_u64: @@ -656,13 +656,13 @@ benches: stable_memory_increase: 0 settling: calls: 1 - instructions: 891422357 + instructions: 890638097 heap_increase: 0 stable_memory_increase: 0 bench_read_events: total: calls: 1 - instructions: 16107037 + instructions: 16106855 heap_increase: 12 stable_memory_increase: 0 scopes: @@ -688,27 +688,27 @@ benches: stable_memory_increase: 0 Init: calls: 1 - instructions: 18438 + instructions: 18420 heap_increase: 0 stable_memory_increase: 0 Matching: calls: 1 - instructions: 403317 + instructions: 403263 heap_increase: 0 stable_memory_increase: 0 SetHalt: calls: 1 - instructions: 46643 + instructions: 46607 heap_increase: 0 stable_memory_increase: 0 Settling: calls: 1 - instructions: 15560395 + instructions: 15560329 heap_increase: 12 stable_memory_increase: 0 Upgrade: calls: 1 - instructions: 18975 + instructions: 18957 heap_increase: 0 stable_memory_increase: 0 Withdraw: @@ -719,13 +719,13 @@ benches: bench_upgrade_1000_no_fills: total: calls: 1 - instructions: 4657265 + instructions: 4657109 heap_increase: 0 stable_memory_increase: 128 scopes: post_upgrade: calls: 1 - instructions: 2864699 + instructions: 2864576 heap_increase: 0 stable_memory_increase: 0 post_upgrade::into_state: @@ -735,7 +735,7 @@ benches: stable_memory_increase: 0 post_upgrade::load_snapshot: calls: 1 - instructions: 1620973 + instructions: 1620847 heap_increase: 0 stable_memory_increase: 0 post_upgrade::load_stable_memory: @@ -745,7 +745,7 @@ benches: stable_memory_increase: 0 pre_upgrade: calls: 1 - instructions: 1681655 + instructions: 1681620 heap_increase: 0 stable_memory_increase: 128 pre_upgrade::from_state: @@ -755,19 +755,19 @@ benches: stable_memory_increase: 0 pre_upgrade::save_snapshot: calls: 1 - instructions: 1595734 + instructions: 1595697 heap_increase: 0 stable_memory_increase: 128 bench_upgrade_full_depth: total: calls: 1 - instructions: 60767469 + instructions: 60733107 heap_increase: 0 stable_memory_increase: 128 scopes: post_upgrade: calls: 1 - instructions: 36702814 + instructions: 36668491 heap_increase: 0 stable_memory_increase: 0 post_upgrade::into_state: @@ -777,7 +777,7 @@ benches: stable_memory_increase: 0 post_upgrade::load_snapshot: calls: 1 - instructions: 17401413 + instructions: 17367087 heap_increase: 0 stable_memory_increase: 0 post_upgrade::load_stable_memory: @@ -787,7 +787,7 @@ benches: stable_memory_increase: 0 pre_upgrade: calls: 1 - instructions: 20542549 + instructions: 20542508 heap_increase: 0 stable_memory_increase: 128 pre_upgrade::from_state: @@ -797,64 +797,64 @@ benches: stable_memory_increase: 0 pre_upgrade::save_snapshot: calls: 1 - instructions: 14651949 + instructions: 14651906 heap_increase: 0 stable_memory_increase: 128 bench_write_events: total: calls: 1 - instructions: 23067143 + instructions: 23066935 heap_increase: 7 stable_memory_increase: 0 scopes: AddLimitOrder: calls: 1 - instructions: 13237 + instructions: 13220 heap_increase: 0 stable_memory_increase: 0 AddTradingPair: calls: 1 - instructions: 16955 + instructions: 16936 heap_increase: 0 stable_memory_increase: 0 CancelLimitOrder: calls: 1 - instructions: 6502 + instructions: 6487 heap_increase: 0 stable_memory_increase: 0 Deposit: calls: 1 - instructions: 10302 + instructions: 10285 heap_increase: 0 stable_memory_increase: 0 Init: calls: 1 - instructions: 23129 + instructions: 23108 heap_increase: 0 stable_memory_increase: 0 Matching: calls: 1 - instructions: 621854 + instructions: 621823 heap_increase: 1 stable_memory_increase: 0 SetHalt: calls: 1 - instructions: 69665 + instructions: 69642 heap_increase: 0 stable_memory_increase: 0 Settling: calls: 1 - instructions: 22260256 + instructions: 22260219 heap_increase: 6 stable_memory_increase: 0 Upgrade: calls: 1 - instructions: 21627 + instructions: 21606 heap_increase: 0 stable_memory_increase: 0 Withdraw: calls: 1 - instructions: 10741 + instructions: 10724 heap_increase: 0 stable_memory_increase: 0 version: 0.4.1 diff --git a/canister/src/state/mod.rs b/canister/src/state/mod.rs index 656a1bd5..01ca7f71 100644 --- a/canister/src/state/mod.rs +++ b/canister/src/state/mod.rs @@ -405,8 +405,8 @@ impl State { let mut balance_operations = Vec::with_capacity(output.fills.len() * 3 + output.expired_orders.len()); let mut updates: BTreeMap = BTreeMap::new(); - for fill in &output.fills { - let settlement = FillSettlement::new(fill.clone(), fee_rates, base_scale); + for fill in output.fills { + let settlement = FillSettlement::new(fill, fee_rates, base_scale); settlement.push_balance_operations(&mut balance_operations); settlement.accrue_fill(&mut updates); } From 01ec74c7bee85316aaca3df8e0c820a4c78e91f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 25 Jun 2026 14:58:11 +0000 Subject: [PATCH 23/35] refactor(canister): encapsulate balance-op construction in a settle() builder Add a `RemovedOrderSettlement` sibling to `FillSettlement` and a unified `settle()` builder so `record_matching_event` no longer pushes balance operations or builds the order-update map inline. `settle()` does a single pass producing both the balance operations and the per-order update map, gating the per-fill `accrue_fill` work under the write path so `Skip`/replay no longer does the per-fill accrue work (the update map is discarded under `Skip` anyway). `cancel_limit_order` now also routes its refund through `RemovedOrderSettlement`, letting the free `refund_for` helper be deleted. Co-Authored-By: Claude Opus 4.8 (1M context) --- canister/src/order/fill.rs | 41 ++++++++++++++- canister/src/order/mod.rs | 2 +- canister/src/state/mod.rs | 100 ++++++++++++++++-------------------- canister/src/state/tests.rs | 15 ++---- 4 files changed, 87 insertions(+), 71 deletions(-) diff --git a/canister/src/order/fill.rs b/canister/src/order/fill.rs index 6164f19c..e985e994 100644 --- a/canister/src/order/fill.rs +++ b/canister/src/order/fill.rs @@ -1,4 +1,4 @@ -use super::{FeeRates, OrderSeq, OrderUpdate, PairToken, Price, Quantity, Side}; +use super::{FeeRates, OrderSeq, OrderUpdate, PairToken, Price, Quantity, RemovedOrder, Side}; use crate::state::event; use minicbor::{Decode, Encode}; use std::collections::BTreeMap; @@ -155,6 +155,45 @@ impl FillSettlement { } } +/// The settlement of a removed order (canceled or killed): the placement +/// reservation released back to its owner, computed where `base_scale` is in +/// scope so the matcher stays scale-agnostic. +pub struct RemovedOrderSettlement { + order_seq: OrderSeq, + token: PairToken, + amount: Quantity, +} + +impl RemovedOrderSettlement { + /// Compute the reservation released by removing an order. + pub fn new(order_seq: OrderSeq, removed: &RemovedOrder, base_scale: NonZeroU64) -> Self { + let (token, amount) = match removed.side { + Side::Buy => ( + PairToken::Quote, + removed + .price + .checked_mul_quantity_scaled(&removed.remaining_quantity, base_scale) + .expect("BUG: price * remaining overflow — validated at placement"), + ), + Side::Sell => (PairToken::Base, removed.remaining_quantity), + }; + Self { + order_seq, + token, + amount, + } + } + + /// Push the single unreserve operation that releases the reservation. + pub fn push_balance_operations(&self, ops: &mut Vec) { + ops.push(event::BalanceOperation::Unreserve { + order: self.order_seq, + token: self.token, + amount: self.amount, + }); + } +} + /// Collapse a zero-quantity fee to `None`. Keeps `Some(_)` reserved for /// "fee was actually charged" so callers (audit log, apply path, /// `/metrics`) can distinguish "no fee on this fill" from "fee of zero diff --git a/canister/src/order/mod.rs b/canister/src/order/mod.rs index fdcd7f5c..c623b8af 100644 --- a/canister/src/order/mod.rs +++ b/canister/src/order/mod.rs @@ -12,7 +12,7 @@ pub use book::{ PriceLevel, RemovedOrder, }; pub use fees::{BasisPoint, FeeRates, InvalidBasisPoint}; -pub use fill::{Fill, FillSettlement}; +pub use fill::{Fill, FillSettlement, RemovedOrderSettlement}; pub use history::{CursorNotFound, OrderHistory, OrderUpdate}; use candid::{Nat, Principal}; diff --git a/canister/src/state/mod.rs b/canister/src/state/mod.rs index 01ca7f71..37cfd641 100644 --- a/canister/src/state/mod.rs +++ b/canister/src/state/mod.rs @@ -18,10 +18,10 @@ use crate::Task; use crate::Timestamp; use crate::balance::{Balance, TokenBalance}; use crate::order::{ - CursorNotFound, FeeRates, FillSettlement, LotSize, MatchOrderError, NotionalError, Order, - OrderBook, OrderBookId, OrderHistory, OrderId, OrderRecord, OrderSeq, OrderStatus, OrderUpdate, - PairToken, PendingOrder, Price, Quantity, RemovedOrder, Side, TickSize, TokenId, TokenMetadata, - TradingPair, + CursorNotFound, FeeRates, Fill, FillSettlement, LotSize, MatchOrderError, MatchingOutput, + NotionalError, Order, OrderBook, OrderBookId, OrderHistory, OrderId, OrderRecord, OrderSeq, + OrderStatus, OrderUpdate, PendingOrder, Quantity, RemovedOrder, RemovedOrderSettlement, Side, + TickSize, TokenId, TokenMetadata, TradingPair, }; use crate::storage::VMem; use crate::user::{UserId, UserRegistry}; @@ -352,14 +352,12 @@ impl State { .order_books .get_mut(&book_id) .expect("BUG: order book missing for canceled order"); - let RemovedOrder { - side, - price, - remaining_quantity, - } = book.remove_order(seq).expect( + let removed = book.remove_order(seq).expect( "BUG: canceled order request was validated, but canceled order not found in book", ); - let (refund_token, refund_amount) = refund_for(side, price, remaining_quantity, base_scale); + let mut balance_operations = Vec::with_capacity(1); + RemovedOrderSettlement::new(seq, &removed, base_scale) + .push_balance_operations(&mut balance_operations); if matches!(persistence, StableMemoryOptions::Write) { self.order_history.apply_update( &order_id, @@ -370,11 +368,7 @@ impl State { self.pending_settling_events .push_back(event::SettlingEvent { book_id, - balance_operations: vec![event::BalanceOperation::Unreserve { - order: seq, - token: refund_token, - amount: refund_amount, - }], + balance_operations, }); } @@ -400,41 +394,25 @@ impl State { let fee_rates = book.fee_rates(); let output = book.process_pending_orders(&event.orders); - // Single pass over the fills: memoize updates to stable structures - // to write an entry only once to minimize instruction costs. - let mut balance_operations = - Vec::with_capacity(output.fills.len() * 3 + output.expired_orders.len()); - let mut updates: BTreeMap = BTreeMap::new(); - for fill in output.fills { - let settlement = FillSettlement::new(fill, fee_rates, base_scale); - settlement.push_balance_operations(&mut balance_operations); - settlement.accrue_fill(&mut updates); - } - // A killed fill-or-kill order never touched the book, so its full - // placement reservation must be released. - for (seq, killed) in &output.expired_orders { - let (refund_token, refund_amount) = refund_for( - killed.side, - killed.price, - killed.remaining_quantity, - base_scale, - ); - balance_operations.push(event::BalanceOperation::Unreserve { - order: *seq, - token: refund_token, - amount: refund_amount, - }); - } - if matches!(persistence, StableMemoryOptions::Write) { + let write = matches!(persistence, StableMemoryOptions::Write); + let MatchingOutput { + fills, + resting_orders, + filled_orders, + expired_orders, + } = output; + let (balance_operations, mut updates) = + settle(fills, &expired_orders, fee_rates, base_scale, write); + if write { #[cfg(feature = "canbench-rs")] let _p = canbench_rs::bench_scope("apply_order_updates"); - for seq in &output.resting_orders { + for seq in &resting_orders { updates.entry(*seq).or_default().status = Some(OrderStatus::Open); } - for seq in &output.filled_orders { + for seq in &filled_orders { updates.entry(*seq).or_default().status = Some(OrderStatus::Filled); } - for seq in output.expired_orders.keys() { + for seq in expired_orders.keys() { updates.entry(*seq).or_default().status = Some(OrderStatus::Expired); } for (seq, update) in updates { @@ -929,21 +907,29 @@ fn resolve_op_users( .collect() } -fn refund_for( - side: Side, - price: Price, - remaining_quantity: Quantity, +fn settle( + fills: Vec, + expired_orders: &BTreeMap, + fee_rates: FeeRates, base_scale: NonZeroU64, -) -> (PairToken, Quantity) { - match side { - Side::Buy => ( - PairToken::Quote, - price - .checked_mul_quantity_scaled(&remaining_quantity, base_scale) - .expect("BUG: price * remaining overflow — validated at placement"), - ), - Side::Sell => (PairToken::Base, remaining_quantity), + write: bool, +) -> ( + Vec, + BTreeMap, +) { + let mut ops = Vec::with_capacity(fills.len() * 3 + expired_orders.len()); + let mut updates = BTreeMap::new(); + for fill in fills { + let settlement = FillSettlement::new(fill, fee_rates, base_scale); + settlement.push_balance_operations(&mut ops); + if write { + settlement.accrue_fill(&mut updates); + } + } + for (seq, removed) in expired_orders { + RemovedOrderSettlement::new(*seq, removed, base_scale).push_balance_operations(&mut ops); } + (ops, updates) } /// `oisy_trade_types::Balance` carrying a fee amount in `free` and zero in diff --git a/canister/src/state/tests.rs b/canister/src/state/tests.rs index 829f5c38..28edcbfe 100644 --- a/canister/src/state/tests.rs +++ b/canister/src/state/tests.rs @@ -2091,7 +2091,7 @@ mod settle_fills { fn settlement_balance_ops_match_fill_shape( output in crate::test_fixtures::arbitrary::arb_matching_output() ) { - use crate::order::{self, FillSettlement, PairToken}; + use crate::order::{self, FillSettlement, PairToken, RemovedOrderSettlement}; use crate::state::event::BalanceOperation; let base_scale = std::num::NonZeroU64::new(PRICE_SCALE as u64).unwrap(); @@ -2103,17 +2103,8 @@ mod settle_fills { settlement.push_balance_operations(&mut ops); } for (seq, killed) in &output.expired_orders { - let (refund_token, refund_amount) = crate::state::refund_for( - killed.side, - killed.price, - killed.remaining_quantity, - base_scale, - ); - ops.push(BalanceOperation::Unreserve { - order: *seq, - token: refund_token, - amount: refund_amount, - }); + RemovedOrderSettlement::new(*seq, killed, base_scale) + .push_balance_operations(&mut ops); } prop_assert!( From 3b664d3bd7e7bde711bb3e1451eeb7ab5f9bfe1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 25 Jun 2026 15:01:41 +0000 Subject: [PATCH 24/35] chore(canister): regenerate canbench results after settle() refactor Co-Authored-By: Claude Opus 4.8 (1M context) --- canister/canbench_results.yml | 244 +++++++++++++++++----------------- 1 file changed, 122 insertions(+), 122 deletions(-) diff --git a/canister/canbench_results.yml b/canister/canbench_results.yml index 318e395b..72d25705 100644 --- a/canister/canbench_results.yml +++ b/canister/canbench_results.yml @@ -2,23 +2,23 @@ benches: bench_fok_fill_full_bid_side: total: calls: 1 - instructions: 812665743 + instructions: 812859988 heap_increase: 0 stable_memory_increase: 0 scopes: apply_order_updates: calls: 1 - instructions: 71229172 + instructions: 71240521 heap_increase: 0 stable_memory_increase: 0 bal: calls: 2788 - instructions: 12297101 + instructions: 12297219 heap_increase: 0 stable_memory_increase: 0 bal::debit_reserved: calls: 1394 - instructions: 4464693 + instructions: 4464705 heap_increase: 0 stable_memory_increase: 0 bal::deposit: @@ -28,42 +28,42 @@ benches: stable_memory_increase: 0 balances: calls: 1394 - instructions: 640848367 + instructions: 641029156 heap_increase: 0 stable_memory_increase: 0 balances::transfer: calls: 1394 - instructions: 637989483 + instructions: 638170219 heap_increase: 0 stable_memory_increase: 0 book::apply_plan: calls: 1 - instructions: 4520980 + instructions: 4521046 heap_increase: 0 stable_memory_increase: 0 book::match_order: calls: 1 - instructions: 6416777 + instructions: 6416989 heap_increase: 0 stable_memory_increase: 0 book::plan_fills: calls: 1 - instructions: 1892220 + instructions: 1892368 heap_increase: 0 stable_memory_increase: 0 matching: calls: 1 - instructions: 98137754 + instructions: 98149666 heap_increase: 0 stable_memory_increase: 0 order_history: calls: 1396 - instructions: 95541036 + instructions: 95552386 heap_increase: 0 stable_memory_increase: 0 order_history::apply_update: calls: 698 - instructions: 68168729 + instructions: 68179985 heap_increase: 0 stable_memory_increase: 0 order_history::get: @@ -73,7 +73,7 @@ benches: stable_memory_increase: 0 qty: calls: 13245 - instructions: 25137219 + instructions: 25137490 heap_increase: 0 stable_memory_increase: 0 qty::add: @@ -93,7 +93,7 @@ benches: stable_memory_increase: 0 qty::mul_u128: calls: 697 - instructions: 2839788 + instructions: 2839835 heap_increase: 0 stable_memory_increase: 0 qty::mul_u64: @@ -103,64 +103,64 @@ benches: stable_memory_increase: 0 settling: calls: 1 - instructions: 710863213 + instructions: 711045502 heap_increase: 0 stable_memory_increase: 0 bench_fok_killed_full_bid_side: total: calls: 1 - instructions: 2415824 + instructions: 2416080 heap_increase: 0 stable_memory_increase: 0 scopes: apply_order_updates: calls: 1 - instructions: 123467 + instructions: 123479 heap_increase: 0 stable_memory_increase: 0 bal: calls: 1 - instructions: 8642 + instructions: 8640 heap_increase: 0 stable_memory_increase: 0 bal::unreserve: calls: 1 - instructions: 7078 + instructions: 7077 heap_increase: 0 stable_memory_increase: 0 balances: calls: 1 - instructions: 249421 + instructions: 249484 heap_increase: 0 stable_memory_increase: 0 balances::unreserve: calls: 1 - instructions: 247600 + instructions: 247664 heap_increase: 0 stable_memory_increase: 0 book::match_order: calls: 1 - instructions: 1894154 + instructions: 1894301 heap_increase: 0 stable_memory_increase: 0 book::plan_fills: calls: 1 - instructions: 1892220 + instructions: 1892368 heap_increase: 0 stable_memory_increase: 0 matching: calls: 1 - instructions: 2025511 + instructions: 2025680 heap_increase: 0 stable_memory_increase: 0 order_history: calls: 2 - instructions: 165262 + instructions: 165275 heap_increase: 0 stable_memory_increase: 0 order_history::apply_update: calls: 1 - instructions: 117853 + instructions: 117868 heap_increase: 0 stable_memory_increase: 0 order_history::get: @@ -170,7 +170,7 @@ benches: stable_memory_increase: 0 qty: calls: 699 - instructions: 1138025 + instructions: 1138071 heap_increase: 0 stable_memory_increase: 0 qty::add: @@ -185,19 +185,19 @@ benches: stable_memory_increase: 0 settling: calls: 1 - instructions: 369862 + instructions: 369923 heap_increase: 0 stable_memory_increase: 0 bench_get_my_orders: total: calls: 1 - instructions: 53713631 + instructions: 53721150 heap_increase: 0 stable_memory_increase: 0 scopes: order_history: calls: 1010 - instructions: 43237318 + instructions: 43237790 heap_increase: 0 stable_memory_increase: 0 order_history::get: @@ -207,19 +207,19 @@ benches: stable_memory_increase: 0 order_history::orders_after: calls: 10 - instructions: 3402839 + instructions: 3403253 heap_increase: 0 stable_memory_increase: 0 bench_get_order_book_depth_default: total: calls: 1 - instructions: 723830 + instructions: 723900 heap_increase: 0 stable_memory_increase: 0 scopes: qty: calls: 200 - instructions: 275465 + instructions: 275500 heap_increase: 0 stable_memory_increase: 0 qty::add: @@ -230,13 +230,13 @@ benches: bench_get_order_book_depth_max: total: calls: 1 - instructions: 6000371 + instructions: 6000477 heap_increase: 0 stable_memory_increase: 0 scopes: qty: calls: 1697 - instructions: 2332454 + instructions: 2332507 heap_increase: 0 stable_memory_increase: 0 qty::add: @@ -247,13 +247,13 @@ benches: bench_get_order_book_ticker: total: calls: 1 - instructions: 10099 + instructions: 10097 heap_increase: 0 stable_memory_increase: 0 scopes: qty: calls: 2 - instructions: 2865 + instructions: 2864 heap_increase: 0 stable_memory_increase: 0 qty::add: @@ -264,23 +264,23 @@ benches: bench_gtc_fill_full_bid_side: total: calls: 1 - instructions: 812665729 + instructions: 812859974 heap_increase: 0 stable_memory_increase: 0 scopes: apply_order_updates: calls: 1 - instructions: 71229178 + instructions: 71240527 heap_increase: 0 stable_memory_increase: 0 bal: calls: 2788 - instructions: 12297101 + instructions: 12297219 heap_increase: 0 stable_memory_increase: 0 bal::debit_reserved: calls: 1394 - instructions: 4464693 + instructions: 4464705 heap_increase: 0 stable_memory_increase: 0 bal::deposit: @@ -290,42 +290,42 @@ benches: stable_memory_increase: 0 balances: calls: 1394 - instructions: 640848367 + instructions: 641029156 heap_increase: 0 stable_memory_increase: 0 balances::transfer: calls: 1394 - instructions: 637989483 + instructions: 638170219 heap_increase: 0 stable_memory_increase: 0 book::apply_plan: calls: 1 - instructions: 4520980 + instructions: 4521046 heap_increase: 0 stable_memory_increase: 0 book::match_order: calls: 1 - instructions: 6416755 + instructions: 6416967 heap_increase: 0 stable_memory_increase: 0 book::plan_fills: calls: 1 - instructions: 1892220 + instructions: 1892368 heap_increase: 0 stable_memory_increase: 0 matching: calls: 1 - instructions: 98137738 + instructions: 98149650 heap_increase: 0 stable_memory_increase: 0 order_history: calls: 1396 - instructions: 95541044 + instructions: 95552394 heap_increase: 0 stable_memory_increase: 0 order_history::apply_update: calls: 698 - instructions: 68168735 + instructions: 68179991 heap_increase: 0 stable_memory_increase: 0 order_history::get: @@ -335,7 +335,7 @@ benches: stable_memory_increase: 0 qty: calls: 13245 - instructions: 25137219 + instructions: 25137490 heap_increase: 0 stable_memory_increase: 0 qty::add: @@ -355,7 +355,7 @@ benches: stable_memory_increase: 0 qty::mul_u128: calls: 697 - instructions: 2839788 + instructions: 2839835 heap_increase: 0 stable_memory_increase: 0 qty::mul_u64: @@ -365,24 +365,24 @@ benches: stable_memory_increase: 0 settling: calls: 1 - instructions: 710863215 + instructions: 711045504 heap_increase: 0 stable_memory_increase: 0 bench_process_pending_orders_1000: total: calls: 1 - instructions: 1037763412 + instructions: 1037992384 heap_increase: 0 stable_memory_increase: 0 scopes: apply_order_updates: calls: 1 - instructions: 104407624 + instructions: 104423789 heap_increase: 0 stable_memory_increase: 0 bal: calls: 3481 - instructions: 17860103 + instructions: 17860262 heap_increase: 0 stable_memory_increase: 0 bal::debit_reserved: @@ -392,62 +392,62 @@ benches: stable_memory_increase: 0 bal::deposit: calls: 1538 - instructions: 4834908 + instructions: 4834914 heap_increase: 0 stable_memory_increase: 0 bal::unreserve: calls: 405 - instructions: 2440443 + instructions: 2440449 heap_increase: 0 stable_memory_increase: 0 balances: calls: 1943 - instructions: 797727717 + instructions: 797933464 heap_increase: 0 stable_memory_increase: 0 balances::transfer: calls: 1538 - instructions: 701190188 + instructions: 701372163 heap_increase: 0 stable_memory_increase: 0 balances::unreserve: calls: 405 - instructions: 94135063 + instructions: 94158741 heap_increase: 0 stable_memory_increase: 0 book::apply_plan: calls: 1000 - instructions: 7807039 + instructions: 7811184 heap_increase: 0 stable_memory_increase: 0 book::insert_order: calls: 473 - instructions: 1295731 + instructions: 1296385 heap_increase: 0 stable_memory_increase: 0 book::match_order: calls: 1000 - instructions: 14290821 + instructions: 14295838 heap_increase: 0 stable_memory_increase: 0 book::plan_fills: calls: 1000 - instructions: 3187471 + instructions: 3188249 heap_increase: 0 stable_memory_increase: 0 matching: calls: 1 - instructions: 149295126 + instructions: 149316637 heap_increase: 0 stable_memory_increase: 0 order_history: calls: 1785 - instructions: 131677007 + instructions: 131693173 heap_increase: 0 stable_memory_increase: 0 order_history::apply_update: calls: 1010 - instructions: 99760306 + instructions: 99776378 heap_increase: 0 stable_memory_increase: 0 order_history::get: @@ -457,7 +457,7 @@ benches: stable_memory_increase: 0 qty: calls: 17175 - instructions: 34836473 + instructions: 34836768 heap_increase: 0 stable_memory_increase: 0 qty::add: @@ -477,7 +477,7 @@ benches: stable_memory_increase: 0 qty::mul_u128: calls: 1174 - instructions: 5131846 + instructions: 5131905 heap_increase: 0 stable_memory_increase: 0 qty::mul_u64: @@ -487,34 +487,34 @@ benches: stable_memory_increase: 0 settling: calls: 1 - instructions: 882983571 + instructions: 883190972 heap_increase: 0 stable_memory_increase: 0 bench_process_pending_orders_1000_no_fills: total: calls: 1 - instructions: 96831972 + instructions: 96847358 heap_increase: 0 stable_memory_increase: 0 scopes: apply_order_updates: calls: 1 - instructions: 85778401 + instructions: 85793494 heap_increase: 0 stable_memory_increase: 0 book::apply_plan: calls: 1000 - instructions: 3308080 + instructions: 3308223 heap_increase: 0 stable_memory_increase: 0 book::insert_order: calls: 1000 - instructions: 1699579 + instructions: 1699675 heap_increase: 0 stable_memory_increase: 0 book::match_order: calls: 1000 - instructions: 7097071 + instructions: 7097308 heap_increase: 0 stable_memory_increase: 0 book::plan_fills: @@ -524,34 +524,34 @@ benches: stable_memory_increase: 0 matching: calls: 1 - instructions: 96287318 + instructions: 96302678 heap_increase: 0 stable_memory_increase: 0 order_history: calls: 1000 - instructions: 82276019 + instructions: 82291066 heap_increase: 0 stable_memory_increase: 0 order_history::apply_update: calls: 1000 - instructions: 80205796 + instructions: 80220796 heap_increase: 0 stable_memory_increase: 0 bench_process_pending_orders_1000_with_fees: total: calls: 1 - instructions: 1063243986 + instructions: 1063473268 heap_increase: 0 stable_memory_increase: 0 scopes: apply_order_updates: calls: 1 - instructions: 106639270 + instructions: 106655739 heap_increase: 0 stable_memory_increase: 0 bal: calls: 3481 - instructions: 17168624 + instructions: 17168777 heap_increase: 0 stable_memory_increase: 0 bal::debit_reserved: @@ -566,57 +566,57 @@ benches: stable_memory_increase: 0 bal::unreserve: calls: 405 - instructions: 2440456 + instructions: 2440462 heap_increase: 0 stable_memory_increase: 0 balances: calls: 1943 - instructions: 805366507 + instructions: 805572248 heap_increase: 0 stable_memory_increase: 0 balances::transfer: calls: 1538 - instructions: 708386798 + instructions: 708568767 heap_increase: 0 stable_memory_increase: 0 balances::unreserve: calls: 405 - instructions: 94552731 + instructions: 94576409 heap_increase: 0 stable_memory_increase: 0 book::apply_plan: calls: 1000 - instructions: 7807039 + instructions: 7811184 heap_increase: 0 stable_memory_increase: 0 book::insert_order: calls: 473 - instructions: 1295731 + instructions: 1296385 heap_increase: 0 stable_memory_increase: 0 book::match_order: calls: 1000 - instructions: 14290821 + instructions: 14295838 heap_increase: 0 stable_memory_increase: 0 book::plan_fills: calls: 1000 - instructions: 3187471 + instructions: 3188249 heap_increase: 0 stable_memory_increase: 0 matching: calls: 1 - instructions: 166706746 + instructions: 166728573 heap_increase: 0 stable_memory_increase: 0 order_history: calls: 1785 - instructions: 133918361 + instructions: 133934831 heap_increase: 0 stable_memory_increase: 0 order_history::apply_update: calls: 1010 - instructions: 101974322 + instructions: 101990698 heap_increase: 0 stable_memory_increase: 0 order_history::get: @@ -626,7 +626,7 @@ benches: stable_memory_increase: 0 qty: calls: 24102 - instructions: 49518094 + instructions: 49518401 heap_increase: 0 stable_memory_increase: 0 qty::add: @@ -646,7 +646,7 @@ benches: stable_memory_increase: 0 qty::mul_u128: calls: 1174 - instructions: 4876089 + instructions: 4876088 heap_increase: 0 stable_memory_increase: 0 qty::mul_u64: @@ -656,13 +656,13 @@ benches: stable_memory_increase: 0 settling: calls: 1 - instructions: 890638097 + instructions: 890845492 heap_increase: 0 stable_memory_increase: 0 bench_read_events: total: calls: 1 - instructions: 16106855 + instructions: 16107037 heap_increase: 12 stable_memory_increase: 0 scopes: @@ -688,27 +688,27 @@ benches: stable_memory_increase: 0 Init: calls: 1 - instructions: 18420 + instructions: 18438 heap_increase: 0 stable_memory_increase: 0 Matching: calls: 1 - instructions: 403263 + instructions: 403317 heap_increase: 0 stable_memory_increase: 0 SetHalt: calls: 1 - instructions: 46607 + instructions: 46643 heap_increase: 0 stable_memory_increase: 0 Settling: calls: 1 - instructions: 15560329 + instructions: 15560395 heap_increase: 12 stable_memory_increase: 0 Upgrade: calls: 1 - instructions: 18957 + instructions: 18975 heap_increase: 0 stable_memory_increase: 0 Withdraw: @@ -719,13 +719,13 @@ benches: bench_upgrade_1000_no_fills: total: calls: 1 - instructions: 4657109 + instructions: 4657265 heap_increase: 0 stable_memory_increase: 128 scopes: post_upgrade: calls: 1 - instructions: 2864576 + instructions: 2864699 heap_increase: 0 stable_memory_increase: 0 post_upgrade::into_state: @@ -735,7 +735,7 @@ benches: stable_memory_increase: 0 post_upgrade::load_snapshot: calls: 1 - instructions: 1620847 + instructions: 1620973 heap_increase: 0 stable_memory_increase: 0 post_upgrade::load_stable_memory: @@ -745,7 +745,7 @@ benches: stable_memory_increase: 0 pre_upgrade: calls: 1 - instructions: 1681620 + instructions: 1681655 heap_increase: 0 stable_memory_increase: 128 pre_upgrade::from_state: @@ -755,19 +755,19 @@ benches: stable_memory_increase: 0 pre_upgrade::save_snapshot: calls: 1 - instructions: 1595697 + instructions: 1595734 heap_increase: 0 stable_memory_increase: 128 bench_upgrade_full_depth: total: calls: 1 - instructions: 60733107 + instructions: 60767469 heap_increase: 0 stable_memory_increase: 128 scopes: post_upgrade: calls: 1 - instructions: 36668491 + instructions: 36702814 heap_increase: 0 stable_memory_increase: 0 post_upgrade::into_state: @@ -777,7 +777,7 @@ benches: stable_memory_increase: 0 post_upgrade::load_snapshot: calls: 1 - instructions: 17367087 + instructions: 17401413 heap_increase: 0 stable_memory_increase: 0 post_upgrade::load_stable_memory: @@ -787,7 +787,7 @@ benches: stable_memory_increase: 0 pre_upgrade: calls: 1 - instructions: 20542508 + instructions: 20542549 heap_increase: 0 stable_memory_increase: 128 pre_upgrade::from_state: @@ -797,64 +797,64 @@ benches: stable_memory_increase: 0 pre_upgrade::save_snapshot: calls: 1 - instructions: 14651906 + instructions: 14651949 heap_increase: 0 stable_memory_increase: 128 bench_write_events: total: calls: 1 - instructions: 23066935 + instructions: 23067143 heap_increase: 7 stable_memory_increase: 0 scopes: AddLimitOrder: calls: 1 - instructions: 13220 + instructions: 13237 heap_increase: 0 stable_memory_increase: 0 AddTradingPair: calls: 1 - instructions: 16936 + instructions: 16955 heap_increase: 0 stable_memory_increase: 0 CancelLimitOrder: calls: 1 - instructions: 6487 + instructions: 6502 heap_increase: 0 stable_memory_increase: 0 Deposit: calls: 1 - instructions: 10285 + instructions: 10302 heap_increase: 0 stable_memory_increase: 0 Init: calls: 1 - instructions: 23108 + instructions: 23129 heap_increase: 0 stable_memory_increase: 0 Matching: calls: 1 - instructions: 621823 + instructions: 621854 heap_increase: 1 stable_memory_increase: 0 SetHalt: calls: 1 - instructions: 69642 + instructions: 69665 heap_increase: 0 stable_memory_increase: 0 Settling: calls: 1 - instructions: 22260219 + instructions: 22260256 heap_increase: 6 stable_memory_increase: 0 Upgrade: calls: 1 - instructions: 21606 + instructions: 21627 heap_increase: 0 stable_memory_increase: 0 Withdraw: calls: 1 - instructions: 10724 + instructions: 10741 heap_increase: 0 stable_memory_increase: 0 version: 0.4.1 From c2e8354a91d809889b6c23816aa9a74321776e66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 25 Jun 2026 15:18:37 +0000 Subject: [PATCH 25/35] perf(canister): gate full settlement under the write gate during replay Move the entire settlement of a matching event -- the `settle()` call, the status merges, the `order_history` apply, and the `pending_settling_events` push -- inside the `StableMemoryOptions::Write` branch of `record_matching_event`. Only `process_pending_orders` (the in-memory order-book rebuild) stays outside the gate, since replay needs it. Because `settle` now only ever runs under Write, drop its `write` parameter and make `accrue_fill` unconditional inside it. Behavior is identical: under Skip (post-upgrade replay) the settlement outputs were either discarded -- `replay_events` clears the whole `pending_settling_events` queue at the end -- or no-op'd (`record_settling_event` returns early under Skip), and the `order_history` apply was already gated; under Write nothing changes. The net effect is skipping the wasted balance-operation building and per-fill u256 arithmetic during replay, which also subsumes the prior `accrue_fill`-under-Skip concern. Co-Authored-By: Claude Opus 4.8 (1M context) --- canister/src/state/mod.rs | 66 +++++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/canister/src/state/mod.rs b/canister/src/state/mod.rs index 37cfd641..2b0f333e 100644 --- a/canister/src/state/mod.rs +++ b/canister/src/state/mod.rs @@ -394,39 +394,40 @@ impl State { let fee_rates = book.fee_rates(); let output = book.process_pending_orders(&event.orders); - let write = matches!(persistence, StableMemoryOptions::Write); - let MatchingOutput { - fills, - resting_orders, - filled_orders, - expired_orders, - } = output; - let (balance_operations, mut updates) = - settle(fills, &expired_orders, fee_rates, base_scale, write); - if write { - #[cfg(feature = "canbench-rs")] - let _p = canbench_rs::bench_scope("apply_order_updates"); - for seq in &resting_orders { - updates.entry(*seq).or_default().status = Some(OrderStatus::Open); - } - for seq in &filled_orders { - updates.entry(*seq).or_default().status = Some(OrderStatus::Filled); - } - for seq in expired_orders.keys() { - updates.entry(*seq).or_default().status = Some(OrderStatus::Expired); + if matches!(persistence, StableMemoryOptions::Write) { + let MatchingOutput { + fills, + resting_orders, + filled_orders, + expired_orders, + } = output; + let (balance_operations, mut updates) = + settle(fills, &expired_orders, fee_rates, base_scale); + { + #[cfg(feature = "canbench-rs")] + let _p = canbench_rs::bench_scope("apply_order_updates"); + for seq in &resting_orders { + updates.entry(*seq).or_default().status = Some(OrderStatus::Open); + } + for seq in &filled_orders { + updates.entry(*seq).or_default().status = Some(OrderStatus::Filled); + } + for seq in expired_orders.keys() { + updates.entry(*seq).or_default().status = Some(OrderStatus::Expired); + } + for (seq, update) in updates { + let order_id = OrderId::new(event.book_id, seq); + self.order_history.apply_update(&order_id, update, now); + } } - for (seq, update) in updates { - let order_id = OrderId::new(event.book_id, seq); - self.order_history.apply_update(&order_id, update, now); + if !balance_operations.is_empty() { + self.pending_settling_events + .push_back(event::SettlingEvent { + book_id: event.book_id, + balance_operations, + }); } } - if !balance_operations.is_empty() { - self.pending_settling_events - .push_back(event::SettlingEvent { - book_id: event.book_id, - balance_operations, - }); - } } /// Apply a declarative list of balance operations to `self.balances`. @@ -912,7 +913,6 @@ fn settle( expired_orders: &BTreeMap, fee_rates: FeeRates, base_scale: NonZeroU64, - write: bool, ) -> ( Vec, BTreeMap, @@ -922,9 +922,7 @@ fn settle( for fill in fills { let settlement = FillSettlement::new(fill, fee_rates, base_scale); settlement.push_balance_operations(&mut ops); - if write { - settlement.accrue_fill(&mut updates); - } + settlement.accrue_fill(&mut updates); } for (seq, removed) in expired_orders { RemovedOrderSettlement::new(*seq, removed, base_scale).push_balance_operations(&mut ops); From c4279802e1aa2a08f4412c9033d3b46500667aa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 25 Jun 2026 15:38:22 +0000 Subject: [PATCH 26/35] test(canister): drop review-flagged comments in state tests Co-Authored-By: Claude Opus 4.8 (1M context) --- canister/src/state/tests.rs | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/canister/src/state/tests.rs b/canister/src/state/tests.rs index 28edcbfe..a4ed987a 100644 --- a/canister/src/state/tests.rs +++ b/canister/src/state/tests.rs @@ -1951,9 +1951,6 @@ mod settle_fills { let buy = record_of(&state, BUYER, buy_id); assert_eq!(buy.status, OrderStatus::Filled); assert_eq!(buy.filled_quantity, Quantity::from(2 * lot)); - // The two fills executed at distinct maker prices (100 and 101), so - // `filled_quote` is their summed notional — distinct from - // `filled_quantity` and proving each fill's price is rolled up. assert_eq!(buy.filled_quote, Quantity::from(100 * lot + 101 * lot)); assert_eq!(buy.filled_fee, Quantity::ZERO); } @@ -2060,23 +2057,12 @@ mod settle_fills { let buy = record_of(&state, BUYER, buy_id); assert_eq!(buy.status, OrderStatus::Pending); assert_eq!(buy.filled_quantity, Quantity::ZERO); - // The realized-value scalars are written under the same `Write` - // gate as `filled_quantity`, so replay under `Skip` leaves them at - // zero too — no double-counting of quote or fee. assert_eq!(buy.filled_quote, Quantity::ZERO); assert_eq!(buy.filled_fee, Quantity::ZERO); assert_eq!(buy.last_updated_at, None); } } - // The old `settle_fill_ordering` proptest lived here, testing that two - // `settle_fill` calls on independent fills commuted. `settle_fill` has - // been retired — settlement is now a flat `Vec` in - // `SettlingEvent`. Commutativity isn't claimed for arbitrary op sequences - // (two Transfers from the same debtor can fail depending on order), only - // for op sequences produced by `FillSettlement::new` + - // `FillSettlement::push_balance_operations` from a valid `MatchingOutput`. - proptest! { /// `FillSettlement::new` + `push_balance_operations` preserve structural invariants /// over any `MatchingOutput` the arbitrary strategy can produce: @@ -2236,11 +2222,6 @@ mod settle_fills { Some(Quantity::from(quote_fee)), ); - // The order-level scalars roll up the same realized values. Both - // sides traded the full `qty`, so each records `filled_quote == - // notional` (≠ `filled_quantity`, which would be `qty`). The buyer's - // `filled_fee` is the base fee, the seller's is the quote fee — never - // swapped. let (buyer_id, seller_id) = match taker_side { Side::Buy => (second_id, first_id), Side::Sell => (first_id, second_id), From 3099210ebdc36febf620b512da475c006e1da39d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 25 Jun 2026 15:39:08 +0000 Subject: [PATCH 27/35] test(canister): drop duplicate ckUSDT metadata fixture Route the ckUSDT worked-example pair through SupportedTokens::CKUSDT instead of a duplicate ckusdt_metadata fixture. Co-Authored-By: Claude Opus 4.8 (1M context) --- canister/src/state/tests.rs | 5 +++-- canister/src/test_fixtures/mod.rs | 7 ------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/canister/src/state/tests.rs b/canister/src/state/tests.rs index a4ed987a..ec31a446 100644 --- a/canister/src/state/tests.rs +++ b/canister/src/state/tests.rs @@ -2407,7 +2407,8 @@ mod settle_fills { // smallest-unit figures match the DEFI-2901 spec's example literally: // `PRICE_10 = 10_000_000` is 10 ckUSDT/ICP and `notional 20_000_000` // is 20 ckUSDT. - use crate::test_fixtures::{ckusdt_metadata, icp_ckusdt_trading_pair}; + use crate::test_fixtures::icp_ckusdt_trading_pair; + use crate::test_fixtures::tokens::SupportedTokens; // Maker B and the two distinct maker levels need a third principal. const MAKER_B: Principal = Principal::from_slice(&[0x03]); @@ -2424,7 +2425,7 @@ mod settle_fills { OrderBookId::ZERO, icp_ckusdt_trading_pair(), icp_metadata(), - ckusdt_metadata(), + SupportedTokens::CKUSDT.token_metadata().into(), TICK_SIZE, LOT_SIZE, MIN_NOTIONAL, diff --git a/canister/src/test_fixtures/mod.rs b/canister/src/test_fixtures/mod.rs index 50d4b542..21d2743f 100644 --- a/canister/src/test_fixtures/mod.rs +++ b/canister/src/test_fixtures/mod.rs @@ -57,13 +57,6 @@ pub fn ckbtc_metadata() -> TokenMetadata { } } -pub fn ckusdt_metadata() -> TokenMetadata { - TokenMetadata { - symbol: "ckUSDT".to_string(), - decimals: 6, - } -} - pub fn base_metadata() -> TokenMetadata { TokenMetadata { symbol: "BASE".to_string(), From c2fa1508b46ed7da4a071b04205483bc1e631d64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 25 Jun 2026 15:39:25 +0000 Subject: [PATCH 28/35] test(canister): drive realized-scalar examples from a TestCase table Fold the two worked-example tests into one table-driven test whose cases differ only in the orders placed and the per-order expectations, echoing the case description into every assertion. Co-Authored-By: Claude Opus 4.8 (1M context) --- canister/src/state/tests.rs | 202 +++++++++++++++++++++++------------- 1 file changed, 131 insertions(+), 71 deletions(-) diff --git a/canister/src/state/tests.rs b/canister/src/state/tests.rs index ec31a446..0a4a93f7 100644 --- a/canister/src/state/tests.rs +++ b/canister/src/state/tests.rs @@ -2402,7 +2402,7 @@ mod settle_fills { state } - // The two worked-example tests below register ICP/ckUSDT (base ICP + // The worked-example test below registers ICP/ckUSDT (base ICP // 8 decimals / quote ckUSDT 6 decimals, `base_scale = 10^8`) so the // smallest-unit figures match the DEFI-2901 spec's example literally: // `PRICE_10 = 10_000_000` is 10 ckUSDT/ICP and `notional 20_000_000` @@ -2438,81 +2438,141 @@ mod settle_fills { state } - /// A buy taker sweeps two maker levels (2 ICP @ 10, 3 ICP @ 11) with - /// taker 10 bps / maker 5 bps. The taker's `filled_quote` is the realized - /// notional 53 ckUSDT (the 7-ckUSDT reservation surplus is excluded), and - /// its `filled_fee` is the base-denominated 0.005 ICP. Each maker records - /// its own quote-denominated fee. No other test exercises a taker - /// sweeping multiple maker levels with fees and surplus exclusion. #[test] - fn buy_taker_sweeping_two_levels_rolls_up_quote_and_fee() { - let mut state = setup_ckusdt_with_fees(5, 10); - let pair = icp_ckusdt_trading_pair(); - - let maker_a = - test_fixtures::place_order(&mut state, SELLER, &pair, Side::Sell, PRICE_10, QTY_2); - let maker_b = - test_fixtures::place_order(&mut state, MAKER_B, &pair, Side::Sell, PRICE_11, QTY_3); - let taker = - test_fixtures::place_order(&mut state, BUYER, &pair, Side::Buy, PRICE_12, QTY_5); - EXECUTOR.run_once(&mut state, &mock_runtime_for(Principal::anonymous())); + fn rolls_up_realized_quote_and_fee() { + let test_cases = vec![ + TestCase { + desc: "buy taker sweeps two maker levels, surplus excluded".to_string(), + orders: vec![ + PlacedOrder::new(SELLER, Side::Sell, PRICE_10, QTY_2).expect(Expect { + status: OrderStatus::Filled, + filled_quantity: QTY_2, + filled_quote: 20_000_000, + filled_fee: 10_000, + vwap: None, + }), + PlacedOrder::new(MAKER_B, Side::Sell, PRICE_11, QTY_3).expect(Expect { + status: OrderStatus::Filled, + filled_quantity: QTY_3, + filled_quote: 33_000_000, + filled_fee: 16_500, + vwap: None, + }), + PlacedOrder::new(BUYER, Side::Buy, PRICE_12, QTY_5).expect(Expect { + status: OrderStatus::Filled, + filled_quantity: QTY_5, + filled_quote: 53_000_000, + filled_fee: 500_000, + vwap: Some(10_600_000), + }), + ], + }, + TestCase { + desc: "order is taker on entry then maker within one batch".to_string(), + orders: vec![ + PlacedOrder::new(SELLER, Side::Sell, PRICE_10, QTY_2), + PlacedOrder::new(BUYER, Side::Buy, PRICE_10, QTY_5).expect(Expect { + status: OrderStatus::Filled, + filled_quantity: QTY_5, + filled_quote: 50_000_000, + filled_fee: 350_000, + vwap: None, + }), + PlacedOrder::new(MAKER_B, Side::Sell, PRICE_10, QTY_3), + ], + }, + ]; + + for case in test_cases { + let mut state = setup_ckusdt_with_fees(5, 10); + let pair = icp_ckusdt_trading_pair(); + let placed: Vec<_> = case + .orders + .iter() + .map(|order| { + let id = test_fixtures::place_order( + &mut state, + order.owner, + &pair, + order.side, + order.price, + order.quantity, + ); + (order, id) + }) + .collect(); + EXECUTOR.run_once(&mut state, &mock_runtime_for(Principal::anonymous())); + + let base_scale = 100_000_000u128; + for (order, id) in placed { + let Some(expect) = &order.expect else { + continue; + }; + let record = record_of(&state, order.owner, id); + assert_eq!(record.status, expect.status, "BUG ({}): status", case.desc); + assert_eq!( + record.filled_quantity, + Quantity::from(expect.filled_quantity), + "BUG ({}): filled_quantity", + case.desc + ); + assert_eq!( + record.filled_quote, + Quantity::from(expect.filled_quote), + "BUG ({}): filled_quote", + case.desc + ); + assert_eq!( + record.filled_fee, + Quantity::from(expect.filled_fee), + "BUG ({}): filled_fee", + case.desc + ); + if let Some(vwap) = expect.vwap { + let actual = record.filled_quote.as_u128().unwrap() * base_scale + / record.filled_quantity.as_u128().unwrap(); + assert_eq!(actual, vwap, "BUG ({}): vwap", case.desc); + } + } + } + } - // Taker buy: gross 5 ICP filled, realized notional 20 + 33 = 53 - // ckUSDT, fee 0.002 + 0.003 = 0.005 ICP (base-denominated). The - // 7-ckUSDT reservation surplus is released, not part of filled_quote. - let taker = record_of(&state, BUYER, taker); - assert_eq!(taker.status, OrderStatus::Filled); - assert_eq!(taker.filled_quantity, Quantity::from(QTY_5)); - assert_eq!(taker.filled_quote, Quantity::from(53_000_000u128)); - assert_eq!(taker.filled_fee, Quantity::from(500_000u128)); - // VWAP = filled_quote × base_scale / filled_quantity = 10.6 ckUSDT/ICP. - let base_scale = 100_000_000u128; - let vwap = taker.filled_quote.as_u128().unwrap() * base_scale - / taker.filled_quantity.as_u128().unwrap(); - assert_eq!(vwap, 10_600_000); - - // Maker A (Fill 1): 20 ckUSDT notional, 0.01 ckUSDT fee (quote). - let maker_a = record_of(&state, SELLER, maker_a); - assert_eq!(maker_a.filled_quantity, Quantity::from(QTY_2)); - assert_eq!(maker_a.filled_quote, Quantity::from(20_000_000u128)); - assert_eq!(maker_a.filled_fee, Quantity::from(10_000u128)); - - // Maker B (Fill 2): 33 ckUSDT notional, 0.0165 ckUSDT fee (quote). - let maker_b = record_of(&state, MAKER_B, maker_b); - assert_eq!(maker_b.filled_quantity, Quantity::from(QTY_3)); - assert_eq!(maker_b.filled_quote, Quantity::from(33_000_000u128)); - assert_eq!(maker_b.filled_fee, Quantity::from(16_500u128)); + struct TestCase { + desc: String, + orders: Vec, } - /// A single order that crosses on entry (taker leg) and then rests and is - /// hit (maker leg) within the same batch accrues both fills' realized - /// quote and fee, written exactly once. The taker leg is charged the - /// taker rate, the maker leg the maker rate. - #[test] - fn order_filling_both_ways_in_one_batch_rolls_up_both_legs() { - let mut state = setup_ckusdt_with_fees(5, 10); - let pair = icp_ckusdt_trading_pair(); - - // A resting ask the middle order will cross as taker. - test_fixtures::place_order(&mut state, SELLER, &pair, Side::Sell, PRICE_10, QTY_2); - // The order under test: a buy for 5 ICP @ 10 — crosses the 2-ICP ask - // (taker), then rests with 3 ICP open. - let pivot = - test_fixtures::place_order(&mut state, BUYER, &pair, Side::Buy, PRICE_10, QTY_5); - // A sell that hits the pivot's resting 3 ICP (pivot is now maker). - test_fixtures::place_order(&mut state, MAKER_B, &pair, Side::Sell, PRICE_10, QTY_3); - EXECUTOR.run_once(&mut state, &mock_runtime_for(Principal::anonymous())); + struct PlacedOrder { + owner: Principal, + side: Side, + price: u128, + quantity: u128, + expect: Option, + } + + impl PlacedOrder { + fn new(owner: Principal, side: Side, price: u128, quantity: u128) -> Self { + Self { + owner, + side, + price, + quantity, + expect: None, + } + } + + fn expect(mut self, expect: Expect) -> Self { + self.expect = Some(expect); + self + } + } - // Taker leg: 2 ICP @ 10 → notional 20 ckUSDT. Maker leg: 3 ICP @ 10 - // → notional 30 ckUSDT. filled_quote = 20 + 30 = 50 ckUSDT. - let pivot = record_of(&state, BUYER, pivot); - assert_eq!(pivot.status, OrderStatus::Filled); - assert_eq!(pivot.filled_quantity, Quantity::from(QTY_5)); - assert_eq!(pivot.filled_quote, Quantity::from(50_000_000u128)); - // Buyer pays base fee on both legs: taker leg 10 bps × 2 ICP = - // 0.002 ICP (200_000); maker leg 5 bps × 3 ICP = 0.0015 ICP - // (150_000). Total 0.0035 ICP (350_000), all base-denominated. - assert_eq!(pivot.filled_fee, Quantity::from(350_000u128)); + struct Expect { + status: OrderStatus, + filled_quantity: u128, + filled_quote: u128, + filled_fee: u128, + vwap: Option, } } From f6760835283904a9dccdd89e06ceaf191b4e439b Mon Sep 17 00:00:00 2001 From: gregorydemay Date: Thu, 25 Jun 2026 18:10:17 +0200 Subject: [PATCH 29/35] DEFI-2901: complete expectations --- canister/src/state/tests.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/canister/src/state/tests.rs b/canister/src/state/tests.rs index 0a4a93f7..5ffee6c0 100644 --- a/canister/src/state/tests.rs +++ b/canister/src/state/tests.rs @@ -2470,15 +2470,27 @@ mod settle_fills { TestCase { desc: "order is taker on entry then maker within one batch".to_string(), orders: vec![ - PlacedOrder::new(SELLER, Side::Sell, PRICE_10, QTY_2), + PlacedOrder::new(SELLER, Side::Sell, PRICE_10, QTY_2).expect(Expect { + status: OrderStatus::Filled, + filled_quantity: QTY_2, + filled_quote: 20_000_000, + filled_fee: 10_000, + vwap: None, + }), PlacedOrder::new(BUYER, Side::Buy, PRICE_10, QTY_5).expect(Expect { status: OrderStatus::Filled, filled_quantity: QTY_5, filled_quote: 50_000_000, - filled_fee: 350_000, + filled_fee: 200_000 + 150_000, + vwap: None, + }), + PlacedOrder::new(MAKER_B, Side::Sell, PRICE_10, QTY_3).expect(Expect { + status: OrderStatus::Filled, + filled_quantity: QTY_3, + filled_quote: 30_000_000, + filled_fee: 30_000, vwap: None, }), - PlacedOrder::new(MAKER_B, Side::Sell, PRICE_10, QTY_3), ], }, ]; From d1d23b43359da5c524f20a501a94ded258d13077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 25 Jun 2026 16:38:40 +0000 Subject: [PATCH 30/35] test(canister): move record_of helper into test_fixtures Addresses review comment 3475763033: the per-record lookup helper was duplicated in three test modules. Move a single copy into the top-level test_fixtures module and route every call site to it. Co-Authored-By: Claude Opus 4.8 (1M context) --- canister/src/state/tests.rs | 61 ++++++------------------------- canister/src/test_fixtures/mod.rs | 17 +++++++++ 2 files changed, 28 insertions(+), 50 deletions(-) diff --git a/canister/src/state/tests.rs b/canister/src/state/tests.rs index 5ffee6c0..23b884a5 100644 --- a/canister/src/state/tests.rs +++ b/canister/src/state/tests.rs @@ -1667,19 +1667,6 @@ mod settle_fills { use super::*; use crate::order::{OrderRecord, OrderStatus, TimeInForce}; - /// The persisted record for `order_id` as `owner` sees it via - /// `get_user_order`. - fn record_of( - state: &State, - owner: Principal, - order_id: crate::order::OrderId, - ) -> OrderRecord { - state - .get_user_order(&owner, order_id) - .map(|(_, _, record)| record) - .expect("order record present") - } - fn status_of( state: &State, owner: Principal, @@ -1780,7 +1767,7 @@ mod settle_fills { EXECUTOR.run_once(&mut state, &mock_runtime_for(Principal::anonymous())); // The maker rests `Open` with `0 < filled_quantity < quantity`. - let sell = record_of(&state, SELLER, sell_id); + let sell = test_fixtures::record_of(&state, SELLER, sell_id); test_fixtures::assert_eq_ignoring_timestamp( &sell, &OrderRecord { @@ -1825,7 +1812,7 @@ mod settle_fills { EXECUTOR.run_once(&mut state, &mock_runtime_for(Principal::anonymous())); // The fully consumed maker reports `filled_quantity == quantity`. - let sell = record_of(&state, SELLER, sell_id); + let sell = test_fixtures::record_of(&state, SELLER, sell_id); test_fixtures::assert_eq_ignoring_timestamp( &sell, &OrderRecord { @@ -1843,7 +1830,7 @@ mod settle_fills { }, ); // The taker rests `Open` with one of three lots filled. - let buy = record_of(&state, BUYER, buy_id); + let buy = test_fixtures::record_of(&state, BUYER, buy_id); test_fixtures::assert_eq_ignoring_timestamp( &buy, &OrderRecord { @@ -1890,7 +1877,7 @@ mod settle_fills { EXECUTOR.run_once(&mut state, &mock_runtime_for(Principal::anonymous())); // Across two batches the maker accrued both fills, one write // per batch, and now sits at one of two lots filled, still `Open`. - let sell = record_of(&state, SELLER, sell_id); + let sell = test_fixtures::record_of(&state, SELLER, sell_id); assert_eq!(sell.status, OrderStatus::Open); assert_eq!(sell.filled_quantity, Quantity::from(lot)); assert_eq!(status_of(&state, BUYER, buy1_id), Some(OrderStatus::Filled)); @@ -1904,7 +1891,7 @@ mod settle_fills { lot, ); EXECUTOR.run_once(&mut state, &mock_runtime_for(Principal::anonymous())); - let sell = record_of(&state, SELLER, sell_id); + let sell = test_fixtures::record_of(&state, SELLER, sell_id); assert_eq!(sell.status, OrderStatus::Filled); assert_eq!(sell.filled_quantity, sell.quantity); assert_eq!(status_of(&state, BUYER, buy2_id), Some(OrderStatus::Filled)); @@ -1973,7 +1960,7 @@ mod settle_fills { 100 * PRICE_SCALE, 3 * lot, ); - let placed = record_of(&state, SELLER, sell_id); + let placed = test_fixtures::record_of(&state, SELLER, sell_id); assert_eq!(placed.created_at, Timestamp::EPOCH); assert_eq!(placed.last_updated_at, None); @@ -1983,7 +1970,7 @@ mod settle_fills { &mut state, &mocks::mock_runtime_at(BUYER, Timestamp::new(100)), ); - let after_first = record_of(&state, SELLER, sell_id); + let after_first = test_fixtures::record_of(&state, SELLER, sell_id); assert_eq!(after_first.created_at, Timestamp::EPOCH); assert_eq!(after_first.last_updated_at, Some(Timestamp::new(100))); assert_eq!(after_first.filled_quantity, Quantity::from(lot)); @@ -2001,7 +1988,7 @@ mod settle_fills { &mut state, &mocks::mock_runtime_at(BUYER, Timestamp::new(200)), ); - let after_second = record_of(&state, SELLER, sell_id); + let after_second = test_fixtures::record_of(&state, SELLER, sell_id); assert_eq!(after_second.created_at, Timestamp::EPOCH); assert_eq!(after_second.last_updated_at, Some(Timestamp::new(200))); assert_eq!(after_second.status, OrderStatus::Filled); @@ -2370,17 +2357,6 @@ mod settle_fills { assert_eq!(state.balances.fee_balance(&pair.quote), None); } - fn record_of( - state: &TestState, - owner: Principal, - order_id: crate::order::OrderId, - ) -> crate::order::OrderRecord { - state - .get_user_order(&owner, order_id) - .map(|(_, _, record)| record) - .expect("order record present") - } - fn setup_with_fees(maker_bps: u16, taker_bps: u16) -> TestState { let fee_rates = FeeRates { maker: BasisPoint::new(maker_bps).unwrap(), @@ -2644,9 +2620,7 @@ mod settle_fills { mod fill_or_kill { use super::*; - use crate::order::{ - BasisPoint, OrderBookSnapshot, OrderId, OrderRecord, OrderSeq, OrderStatus, - }; + use crate::order::{BasisPoint, OrderBookSnapshot, OrderId, OrderSeq, OrderStatus}; use crate::test_fixtures::tokens::SupportedTokens; use std::collections::BTreeSet; @@ -3036,7 +3010,7 @@ mod settle_fills { EXECUTOR.run_once(&mut state, &mock_runtime_for(Principal::anonymous())); // The FOK was killed; its quote reservation is released. - let fok = record_of(&state, BUYER, fok_id); + let fok = test_fixtures::record_of(&state, BUYER, fok_id); assert_eq!(fok.status, OrderStatus::Expired); assert_eq!(fok.filled_quantity, Quantity::ZERO); assert_eq!( @@ -3045,25 +3019,12 @@ mod settle_fills { ); // The GTC rested Open, fully reserved, untouched by the FOK kill. - let gtc = record_of(&state, SELLER, gtc_id); + let gtc = test_fixtures::record_of(&state, SELLER, gtc_id); assert_eq!(gtc.status, OrderStatus::Open); assert_eq!(gtc.filled_quantity, Quantity::ZERO); assert_eq!(state.get_balance(&SELLER, &pair.base), balance(0u64, lot)); } - /// The persisted record for `order_id` as `owner` sees it via - /// `get_user_order`. - fn record_of( - state: &State, - owner: Principal, - order_id: crate::order::OrderId, - ) -> OrderRecord { - state - .get_user_order(&owner, order_id) - .map(|(_, _, record)| record) - .expect("order record present") - } - /// The resting orders of `pair`'s book — its bid and ask levels — with /// the next-sequence counter and (drained) pending queue excluded, so /// two snapshots compare equal iff the resting book is byte-identical. diff --git a/canister/src/test_fixtures/mod.rs b/canister/src/test_fixtures/mod.rs index 21d2743f..6a6dc8d0 100644 --- a/canister/src/test_fixtures/mod.rs +++ b/canister/src/test_fixtures/mod.rs @@ -463,6 +463,23 @@ pub fn assert_eq_ignoring_timestamp(actual: &order::OrderRecord, expected: &orde assert_eq!(&normalized, expected); } +/// The persisted record for `order_id` as `owner` sees it via +/// `get_user_order`. +pub fn record_of( + state: &state::State, + owner: Principal, + order_id: order::OrderId, +) -> order::OrderRecord +where + MH: ic_stable_structures::Memory, + MB: ic_stable_structures::Memory, +{ + state + .get_user_order(&owner, order_id) + .map(|(_, _, record)| record) + .expect("order record present") +} + pub fn balances() -> TokenBalance { TokenBalance::new(VectorMemory::default()) } From f0e21a081c25f80bd134b4ec80c5eef567c65412 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 25 Jun 2026 16:38:47 +0000 Subject: [PATCH 31/35] test(canister): assert filled records via assert_eq_ignoring_timestamp Addresses review comments 3475754140, 3475755767 and 3475757310: replace the per-field equality checks at three fill-result sites with the existing test_fixtures::assert_eq_ignoring_timestamp helper. Co-Authored-By: Claude Opus 4.8 (1M context) --- canister/src/state/tests.rs | 86 +++++++++++++++++++++++++++++-------- 1 file changed, 69 insertions(+), 17 deletions(-) diff --git a/canister/src/state/tests.rs b/canister/src/state/tests.rs index 23b884a5..02c77192 100644 --- a/canister/src/state/tests.rs +++ b/canister/src/state/tests.rs @@ -1935,11 +1935,23 @@ mod settle_fills { ); EXECUTOR.run_once(&mut state, &mock_runtime_for(Principal::anonymous())); - let buy = record_of(&state, BUYER, buy_id); - assert_eq!(buy.status, OrderStatus::Filled); - assert_eq!(buy.filled_quantity, Quantity::from(2 * lot)); - assert_eq!(buy.filled_quote, Quantity::from(100 * lot + 101 * lot)); - assert_eq!(buy.filled_fee, Quantity::ZERO); + let buy = test_fixtures::record_of(&state, BUYER, buy_id); + test_fixtures::assert_eq_ignoring_timestamp( + &buy, + &OrderRecord { + owner: BUYER, + side: Side::Buy, + price: Price::new(101 * PRICE_SCALE), + quantity: Quantity::from(2 * lot), + filled_quantity: Quantity::from(2 * lot), + status: OrderStatus::Filled, + created_at: buy.created_at, + last_updated_at: buy.last_updated_at, + time_in_force: TimeInForce::GoodTilCanceled, + filled_quote: Quantity::from(100 * lot + 101 * lot), + filled_fee: Quantity::ZERO, + }, + ); } /// `created_at` is stamped once at placement and never moves, while @@ -2041,11 +2053,23 @@ mod settle_fills { StableMemoryOptions::Skip, ); - let buy = record_of(&state, BUYER, buy_id); - assert_eq!(buy.status, OrderStatus::Pending); - assert_eq!(buy.filled_quantity, Quantity::ZERO); - assert_eq!(buy.filled_quote, Quantity::ZERO); - assert_eq!(buy.filled_fee, Quantity::ZERO); + let buy = test_fixtures::record_of(&state, BUYER, buy_id); + test_fixtures::assert_eq_ignoring_timestamp( + &buy, + &OrderRecord { + owner: BUYER, + side: Side::Buy, + price: Price::new(100 * PRICE_SCALE), + quantity: Quantity::from(lot), + filled_quantity: Quantity::ZERO, + status: OrderStatus::Pending, + created_at: buy.created_at, + last_updated_at: buy.last_updated_at, + time_in_force: TimeInForce::GoodTilCanceled, + filled_quote: Quantity::ZERO, + filled_fee: Quantity::ZERO, + }, + ); assert_eq!(buy.last_updated_at, None); } } @@ -2117,7 +2141,7 @@ mod settle_fills { mod fees { use super::*; - use crate::order::{BasisPoint, OrderStatus}; + use crate::order::{BasisPoint, OrderRecord, OrderStatus, TimeInForce}; /// Fill deducts fees on both sides at the role-specific rates. /// Parameterized over which side crosses (taker): @@ -2213,12 +2237,40 @@ mod settle_fills { Side::Buy => (second_id, first_id), Side::Sell => (first_id, second_id), }; - let buy = record_of(&state, BUYER, buyer_id); - assert_eq!(buy.filled_quote, Quantity::from(notional)); - assert_eq!(buy.filled_fee, Quantity::from(base_fee)); - let sell = record_of(&state, SELLER, seller_id); - assert_eq!(sell.filled_quote, Quantity::from(notional)); - assert_eq!(sell.filled_fee, Quantity::from(quote_fee)); + let buy = test_fixtures::record_of(&state, BUYER, buyer_id); + test_fixtures::assert_eq_ignoring_timestamp( + &buy, + &OrderRecord { + owner: BUYER, + side: Side::Buy, + price: Price::new(price * PRICE_SCALE), + quantity: Quantity::from(qty), + filled_quantity: Quantity::from(qty), + status: OrderStatus::Filled, + created_at: buy.created_at, + last_updated_at: buy.last_updated_at, + time_in_force: TimeInForce::GoodTilCanceled, + filled_quote: Quantity::from(notional), + filled_fee: Quantity::from(base_fee), + }, + ); + let sell = test_fixtures::record_of(&state, SELLER, seller_id); + test_fixtures::assert_eq_ignoring_timestamp( + &sell, + &OrderRecord { + owner: SELLER, + side: Side::Sell, + price: Price::new(price * PRICE_SCALE), + quantity: Quantity::from(qty), + filled_quantity: Quantity::from(qty), + status: OrderStatus::Filled, + created_at: sell.created_at, + last_updated_at: sell.last_updated_at, + time_in_force: TimeInForce::GoodTilCanceled, + filled_quote: Quantity::from(notional), + filled_fee: Quantity::from(quote_fee), + }, + ); } /// Zero rates is a regression guard: the fill path with From 933e131f16c8fbb5c6049379ea8ad6fe45d213ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 25 Jun 2026 16:38:54 +0000 Subject: [PATCH 32/35] test(canister): drive worked-example matching with mock_runtime_for_timer Addresses review comment 3475779389: the worked-example loop runs matching on a timer, so it needs no caller principal. Use mock_runtime_for_timer. Co-Authored-By: Claude Opus 4.8 (1M context) --- canister/src/state/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/canister/src/state/tests.rs b/canister/src/state/tests.rs index 02c77192..3604aba6 100644 --- a/canister/src/state/tests.rs +++ b/canister/src/state/tests.rs @@ -2541,14 +2541,14 @@ mod settle_fills { (order, id) }) .collect(); - EXECUTOR.run_once(&mut state, &mock_runtime_for(Principal::anonymous())); + EXECUTOR.run_once(&mut state, &mocks::mock_runtime_for_timer()); let base_scale = 100_000_000u128; for (order, id) in placed { let Some(expect) = &order.expect else { continue; }; - let record = record_of(&state, order.owner, id); + let record = test_fixtures::record_of(&state, order.owner, id); assert_eq!(record.status, expect.status, "BUG ({}): status", case.desc); assert_eq!( record.filled_quantity, From 772edb24555b69532930d384894545726cdd4415 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Thu, 25 Jun 2026 16:52:59 +0000 Subject: [PATCH 33/35] perf(canister): gate cancel settlement under the write gate during replay Move the refund settlement of a canceled limit order -- the `RemovedOrderSettlement` balance-operation production and the `pending_settling_events` push -- inside the `StableMemoryOptions::Write` branch of `record_cancel_limit_order`, alongside the already write-gated `order_history` status apply. Only the in-memory `book.remove_order(seq)` mutation stays outside the gate, since the state rebuild on replay needs it. This mirrors the matching-side fix in c2e8354. Behavior is identical: under Skip (post-upgrade replay) the SettlingEvent was either no-op'd (`record_settling_event` returns early under Skip) or discarded (`replay_events` clears the whole `pending_settling_events` queue at the end); under Write nothing changes. The net effect is skipping wasted refund computation and queue churn on replay. Co-Authored-By: Claude Opus 4.8 (1M context) --- canister/src/state/mod.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/canister/src/state/mod.rs b/canister/src/state/mod.rs index 2b0f333e..da59ff9b 100644 --- a/canister/src/state/mod.rs +++ b/canister/src/state/mod.rs @@ -355,21 +355,21 @@ impl State { let removed = book.remove_order(seq).expect( "BUG: canceled order request was validated, but canceled order not found in book", ); - let mut balance_operations = Vec::with_capacity(1); - RemovedOrderSettlement::new(seq, &removed, base_scale) - .push_balance_operations(&mut balance_operations); if matches!(persistence, StableMemoryOptions::Write) { self.order_history.apply_update( &order_id, OrderUpdate::status(OrderStatus::Canceled), now, ); + let mut balance_operations = Vec::with_capacity(1); + RemovedOrderSettlement::new(seq, &removed, base_scale) + .push_balance_operations(&mut balance_operations); + self.pending_settling_events + .push_back(event::SettlingEvent { + book_id, + balance_operations, + }); } - self.pending_settling_events - .push_back(event::SettlingEvent { - book_id, - balance_operations, - }); } /// Drive engine matching for the given book; when `persistence` is From 3fc7aede1881e172f142c706b76f0d92ff07c1b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Fri, 26 Jun 2026 07:28:49 +0000 Subject: [PATCH 34/35] test(canister): build token-id fixtures via SupportedTokens::token_id Co-Authored-By: Claude Opus 4.8 (1M context) --- canister/src/test_fixtures/mod.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/canister/src/test_fixtures/mod.rs b/canister/src/test_fixtures/mod.rs index 6a6dc8d0..598c4dad 100644 --- a/canister/src/test_fixtures/mod.rs +++ b/canister/src/test_fixtures/mod.rs @@ -7,6 +7,7 @@ use crate::order::{ Price, Quantity, Side, TickSize, TimeInForce, TokenId, TokenMetadata, TradingPair, }; use crate::state::StableMemoryOptions; +use crate::test_fixtures::tokens::SupportedTokens; use crate::user::{UserId, UserRegistry}; use crate::{Timestamp, order, state}; use candid::Principal; @@ -167,15 +168,15 @@ pub fn icp_ckusdt_trading_pair() -> TradingPair { } pub fn ckbtc_token_id() -> TokenId { - TokenId::new(Principal::from_text("mxzaz-hqaaa-aaaar-qaada-cai").unwrap()) + SupportedTokens::CKBTC.token_id().into() } pub fn ckusdt_token_id() -> TokenId { - TokenId::new(Principal::from_text("cngnf-vqaaa-aaaar-qag4q-cai").unwrap()) + SupportedTokens::CKUSDT.token_id().into() } pub fn icp_token_id() -> TokenId { - TokenId::new(Principal::from_text("ryjl3-tyaaa-aaaaa-aaaba-cai").unwrap()) + SupportedTokens::ICP.token_id().into() } fn order(id: u64, side: Side, price: impl Into, quantity: impl Into) -> Order { From e28d07365b1445ff56fd45c9fed6412f3eaec008 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Demay?= Date: Fri, 26 Jun 2026 07:28:55 +0000 Subject: [PATCH 35/35] test(int): drop redundant comment in cancel partial-fill refund test Co-Authored-By: Claude Opus 4.8 (1M context) --- integration_tests/tests/tests.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 7b72a22b..3f99010b 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -731,9 +731,6 @@ mod cancel_limit_order { #[tokio::test] async fn should_cancel_partially_filled_buy_and_refund_residual() { - // Non-zero maker/taker fees so the partially filled buy accrues a - // non-trivial `filled_fee` and a `filled_quote` consistent with the - // realized notional. const MAKER_FEE_BPS: u16 = 10; const TAKER_FEE_BPS: u16 = 23; let setup = Setup::new().await;