Skip to content

feat(matching): FOK matching gate + execution wiring (3/3)#169

Draft
gregorydemay wants to merge 10 commits into
gdemay/DEFI-2853-fok-data-modelfrom
gdemay/DEFI-2853-fok-matching-wiring
Draft

feat(matching): FOK matching gate + execution wiring (3/3)#169
gregorydemay wants to merge 10 commits into
gdemay/DEFI-2853-fok-data-modelfrom
gdemay/DEFI-2853-fok-matching-wiring

Conversation

@gregorydemay

@gregorydemay gregorydemay commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Routes fill-or-kill (FOK) limit orders through the matching engine, completing the FOK feature. A FOK either fully fills against resting liquidity at its price or better, or is killed with zero execution — it never rests and never settles a partial fill. The kill is structural: matching plans the fills read-only first, and when the full quantity is not satisfiable the book is left provably untouched. A killed FOK reaches the Expired terminal state with its placement reservation fully released; the caller observes the outcome asynchronously via get_my_orders. GTC behavior is unchanged.

Spec: docs/src/development/specs/DEFI-2853-add-fill-or-kill-orders-fok.md (#156).

Requirement coverage:

  • R3 — FOK whose full quantity is satisfiable fills completely and reaches Filled; never rests.
  • R4 — FOK against no liquidity reaches Expired with zero fill, no book trace, and the reservation released.
  • R5 — FOK against some-but-insufficient liquidity is killed exactly like the no-liquidity case; never a partial fill.
  • R6 — a FOK fill bills the FOK (crossing) side the taker rate and its resting counterparty the maker rate, with no FOK-specific fee logic.
  • R10 — a worst-case FOK sweeping one fully-populated, fragmented book side in a single message stays well within the per-message instruction limit (canbench).
  • R11 — add_limit_order returns an order ID immediately; the FOK transitions only Pending → Filled or Pending → Expired, never Open.
  • Design doc records the async FOK model, the Canceled vs Expired distinction, and that time_in_force constrains how long an order may rest.

📚 PR stack

gregorydemay and others added 4 commits June 22, 2026 12:35
…vation on kill

Add an `execute(order, require_full)` entry point on the order book: it plans
the fills read-only and, when `require_full` is set and the plan does not fully
fill, returns a `Killed` outcome before any mutation, leaving the book
untouched. `process_pending_orders` derives `require_full` from each order's
`time_in_force` and records killed FOKs in `MatchingOutput::expired_orders`.
`record_matching_event` maps those to `status = Expired` and releases the full
placement reservation, reusing the cancel path's unreserve/refund.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…utcome

Unit tests assert a FOK fully fills (Filled / filled == quantity), kills with
zero execution and no book trace when liquidity is absent or insufficient
(Expired / filled == 0, reservation released), and that a FOK fill bills the
FOK side the taker rate and its resting counterparty the maker rate.
End-to-end PocketIC tests place FOKs via add_limit_order and observe the
terminal Filled / Expired outcome via get_my_orders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A single fill-or-kill order that sweeps the entire fragmented ask side of the
Binance depth snapshot in one message, confirming the atomic FOK stays well
within the per-message instruction limit. Regenerate canbench_results.yml.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Record the asynchronous FOK model (evaluated upon execution, never resting),
the Expired terminal state and how it differs from the user-initiated
Canceled, and that time_in_force constrains how long an order may rest in the
book.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 22, 2026

Copy link
Copy Markdown

canbench 🏋 (dir: canister) 8c4d3e5 2026-06-23 05:07:21 UTC

canister/canbench_results.yml is up to date
📦 canbench_results_benchmark.csv available in artifacts

---------------------------------------------------

Summary:
  instructions:
    status:   Regressions and new benchmarks 🔴➕
    counts:   [total 13 | regressed 4 | improved 0 | new 1 | unchanged 8]
    change:   [max +7.73M | p75 +2.49M | median +1.23M | p25 +419 | min 0]
    change %: [max +36.98% | p75 +2.32% | median +0.25% | p25 0.00% | min 0.00%]

  heap_increase:
    status:   New benchmarks added ➕
    counts:   [total 13 | regressed 0 | improved 0 | new 1 | unchanged 12]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

  stable_memory_increase:
    status:   New benchmarks added ➕
    counts:   [total 13 | regressed 0 | improved 0 | new 1 | unchanged 12]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

---------------------------------------------------

Only significant changes:
| status | name                                                                     |  calls |     ins |  ins Δ% |  HI |  HI Δ% | SMI |  SMI Δ% |
|--------|--------------------------------------------------------------------------|--------|---------|---------|-----|--------|-----|---------|
|   +    | bench_upgrade_1000_no_fills::post_upgrade::load_snapshot                 |      1 |   1.75M | +74.86% |   0 |  0.00% |   0 |   0.00% |
|   +    | bench_upgrade_1000_no_fills::pre_upgrade::save_snapshot                  |      1 |   1.67M | +51.00% |   0 |  0.00% | 128 |   0.00% |
|   +    | bench_upgrade_1000_no_fills::pre_upgrade                                 |      1 |   1.75M | +48.70% |   0 |  0.00% | 128 |   0.00% |
|   +    | bench_upgrade_1000_no_fills                                              |        |   4.85M | +36.98% |   0 |  0.00% | 128 |   0.00% |
|   +    | bench_upgrade_1000_no_fills::post_upgrade                                |      1 |   2.98M | +32.62% |   0 |  0.00% |   0 |   0.00% |
|   +    | bench_upgrade_full_depth::post_upgrade::load_snapshot                    |      1 |  18.13M | +30.52% |   0 |  0.00% |   0 |   0.00% |
|   +    | bench_upgrade_full_depth::pre_upgrade::save_snapshot                     |      1 |  15.02M | +26.58% |   0 |  0.00% | 128 |   0.00% |
|   +    | bench_upgrade_1000_no_fills::pre_upgrade::from_state                     |      1 |  65.44K | +18.38% |   0 |  0.00% |   0 |   0.00% |
|   +    | bench_upgrade_full_depth::pre_upgrade                                    |      1 |  20.91M | +18.14% |   0 |  0.00% | 128 |   0.00% |
|   +    | bench_upgrade_full_depth                                                 |        |  62.07M | +14.23% |   0 |  0.00% | 128 |   0.00% |
|   +    | bench_upgrade_full_depth::post_upgrade                                   |      1 |  37.70M | +13.63% |   0 |  0.00% |   0 |   0.00% |
|   +    | bench_read_events::AddLimitOrder                                         |      1 |  10.85K |  +7.36% |   0 |  0.00% |   0 |   0.00% |
|   +    | bench_write_events::AddLimitOrder                                        |      1 |  13.50K |  +4.29% |   0 |  0.00% |   0 |   0.00% |
|   +    | bench_process_pending_orders_1000_no_fills::order_history::apply_update  |  1.00K |  79.08M |  +3.75% |   0 |  0.00% |   0 |   0.00% |
|   +    | bench_process_pending_orders_1000_no_fills::order_history                |  1.00K |  81.14M |  +3.65% |   0 |  0.00% |   0 |   0.00% |
|   +    | bench_process_pending_orders_1000_no_fills::status                       |      1 |  84.22M |  +3.52% |   0 |  0.00% |   0 |   0.00% |
|   +    | bench_get_my_orders::order_history::get                                  |  1.00K |  38.20M |  +3.05% |   0 |  0.00% |   0 |   0.00% |
|   +    | bench_process_pending_orders_1000::order_history::apply_update           |  1.01K |  96.37M |  +3.03% |   0 |  0.00% |   0 |   0.00% |
|   +    | bench_process_pending_orders_1000_with_fees::order_history::apply_update |  1.01K |  96.37M |  +3.03% |   0 |  0.00% |   0 |   0.00% |
|   +    | bench_process_pending_orders_1000::status                                |      1 | 106.55M |  +2.73% |   0 |  0.00% |   0 |   0.00% |
|   +    | bench_process_pending_orders_1000_with_fees::status                      |      1 | 106.55M |  +2.73% |   0 |  0.00% |   0 |   0.00% |
|   +    | bench_process_pending_orders_1000_with_fees::order_history               |  1.78K | 128.94M |  +2.72% |   0 |  0.00% |   0 |   0.00% |
|   +    | bench_process_pending_orders_1000::order_history                         |  1.78K | 128.96M |  +2.72% |   0 |  0.00% |   0 |   0.00% |
|   +    | bench_get_my_orders::order_history                                       |  1.01K |  43.19M |  +2.71% |   0 |  0.00% |   0 |   0.00% |
|   +    | bench_process_pending_orders_1000_no_fills::matching                     |      1 |  94.09M |  +2.67% |   0 |  0.00% |   0 |   0.00% |
|  ...   | ... 6 rows omitted ...                                                   |        |         |         |     |        |     |         |
|   +    | bench_process_pending_orders_1_large::order_history                      |  1.40K |  92.91M |  +2.14% |   0 |  0.00% |   0 |   0.00% |
|   -    | bench_process_pending_orders_1000_no_fills::book::plan_fills             |  1.00K | 501.00K |  -9.57% |   0 |  0.00% |   0 |   0.00% |
|  new   | bench_fill_or_kill_sweep_full_ask_side                                   |        |   7.24B |         | 122 |        |   0 |         |
|  new   | bench_fill_or_kill_sweep_full_ask_side::bal                              | 25.00K | 131.61M |         | 122 |        |   0 |         |
|  new   | bench_fill_or_kill_sweep_full_ask_side::bal::debit_reserved              | 10.00K |  33.78M |         |  41 |        |   0 |         |
|  new   | bench_fill_or_kill_sweep_full_ask_side::bal::deposit                     | 10.00K |  30.83M |         |  81 |        |   0 |         |
|  new   | bench_fill_or_kill_sweep_full_ask_side::bal::unreserve                   |  5.00K |  28.64M |         |   0 |        |   0 |         |
|  new   | bench_fill_or_kill_sweep_full_ask_side::balances                         | 15.00K |   5.97B |         | 122 |        |   0 |         |
|  new   | bench_fill_or_kill_sweep_full_ask_side::balances::transfer               | 10.00K |   4.65B |         | 122 |        |   0 |         |
|  new   | bench_fill_or_kill_sweep_full_ask_side::balances::unreserve              |  5.00K |   1.29B |         |   0 |        |   0 |         |
|  new   | bench_fill_or_kill_sweep_full_ask_side::book::apply_plan                 |      1 |  35.57M |         |   0 |        |   0 |         |
|  new   | bench_fill_or_kill_sweep_full_ask_side::book::match_order                |      1 |  48.72M |         |   0 |        |   0 |         |
|  new   | bench_fill_or_kill_sweep_full_ask_side::book::plan_fills                 |      1 |  13.15M |         |   0 |        |   0 |         |
|  new   | bench_fill_or_kill_sweep_full_ask_side::matching                         |      1 | 687.89M |         |   0 |        |   0 |         |
|  new   | bench_fill_or_kill_sweep_full_ask_side::order_history                    | 10.00K | 671.15M |         |   0 |        |   0 |         |
|  new   | bench_fill_or_kill_sweep_full_ask_side::order_history::apply_update      |  5.00K | 469.93M |         |   0 |        |   0 |         |
|  new   | bench_fill_or_kill_sweep_full_ask_side::order_history::get               |  5.00K | 179.32M |         |   0 |        |   0 |         |
|  new   | bench_fill_or_kill_sweep_full_ask_side::qty                              | 95.00K | 183.10M |         |  41 |        |   0 |         |
|  new   | bench_fill_or_kill_sweep_full_ask_side::qty::add                         | 30.00K |  10.59M |         |   0 |        |   0 |         |
|  new   | bench_fill_or_kill_sweep_full_ask_side::qty::checked_sub                 | 35.00K |  12.66M |         |   0 |        |   0 |         |
|  new   | bench_fill_or_kill_sweep_full_ask_side::qty::div_rem_u64                 | 10.00K |   5.98M |         |   0 |        |   0 |         |
|  new   | bench_fill_or_kill_sweep_full_ask_side::qty::mul_u128                    | 10.00K |  40.28M |         |   0 |        |   0 |         |
|  new   | bench_fill_or_kill_sweep_full_ask_side::qty::mul_u64                     | 10.00K |   6.19M |         |   0 |        |   0 |         |
|  new   | bench_fill_or_kill_sweep_full_ask_side::settling                         |      1 |   6.52B |         | 122 |        |   0 |         |
|  new   | bench_fill_or_kill_sweep_full_ask_side::status                           |      1 | 532.27M |         |   0 |        |   0 |         |

ins = instructions, HI = heap_increase, SMI = stable_memory_increase, Δ% = percent change

---------------------------------------------------
CSV results saved to canbench_results.csv

Comment thread canister/src/state/mod.rs
balance_operations.push(event::BalanceOperation::Unreserve {
order: *seq,
token: refund_token,
amount: refund_amount,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧐 🟠 Medium — the FOK-kill refund computation here is a near-exact copy of the cancel-path block in record_cancel_limit_order (Side::Buy => (PairToken::Quote, price.checked_mul_quantity_scaled(&qty, base_scale).expect(...)); Side::Sell => (PairToken::Base, qty)), and a third near-twin lives in the placement-reservation path. The spec frames this as "reuses the same unreserve/refund computation the cancel path already performs" — but it is realized by copy-paste, not a shared helper, so the three sites can now silently drift (e.g. a future scaling fix lands in one and not the others). Suggest extracting a small fn refund_for(side: Side, price: Price, qty: &Quantity, base_scale) -> (PairToken, Quantity) and calling it from all three sites; the expect("BUG: ... overflow") message is then defined once. Behaviour is correct today (mutation-tested: dropping this loop fails both reservation-release tests), so this is a maintainability gate, not a correctness bug.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Done in b399d6d. Extracted refund_for(side, price, remaining_quantity, base_scale) -> (PairToken, Quantity) (a free fn alongside compute_balance_operations in state/mod.rs) and called it at both PairToken refund sites: the cancel path in record_cancel_limit_order and the FOK-kill path in record_matching_event. Behavior is identical — all reservation/refund tests pass unchanged. I left the placement-reservation path (record_limit_order) and validate_limit_order as-is: those resolve to concrete TokenIds (pair.quote/pair.base), not PairToken, so they don't share the helper's return type.

Comment thread canister/src/order/book.rs Outdated
/// `price × remaining_quantity` (or just `remaining_quantity` for Sell) is
/// the amount to unreserve.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧐 🔵 Nit — this PR adds Encode, Decode to RemovedOrder only because it is now embedded in MatchingOutput.expired_orders (#[n(3)]), and MatchingOutput derives Encode/Decode. But MatchingOutput is never actually serialized — it is produced by process_pending_orders and consumed transiently by record_matching_event; only MatchingEvent { orders: Vec<OrderSeq> } is persisted. I confirmed by removing both derives (and the #[n(..)] attrs): nothing at a call site needs them. So the new RemovedOrder Encode/Decode is dead capability, inherited from an already-unused derive on MatchingOutput. Optional: drop Encode/Decode + the #[n] field tags from both MatchingOutput and RemovedOrder. Pre-existing on MatchingOutput, so fine to leave — flagging since the PR propagates it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Done in b92a81d. Confirmed neither MatchingOutput nor RemovedOrder is ever serialized — no minicbor::encode/decode, to_vec, from_slice, or stable-memory persistence references either type; both are only constructed and consumed in-memory. Dropped the Encode, Decode derives from both types and removed the now-dead #[n(..)] field attrs (including expired_orders'). The minicbor::{Decode, Encode} import stays — other types in the module still use it.

Comment thread docs/src/development/design.md Outdated
FOK can't fully fill
|----------------------+
v v
+------------+ +------------+

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧐 🔵 Nit — the diagram branches processes queue into Open vs Expired, but a FOK that fully fills goes Pending → Filled directly without passing through Open (and without resting). The prose just below (point 5 + the Time-in-Force section: "transitions only Pending → Filled or Pending → Expired; it never reaches Open") is accurate, so this is purely a diagram-simplification nit — a fully-filling FOK has no arrow to Filled that bypasses Open in the picture. Non-blocking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Done in d747d24. The diagram now branches the matching outcome three ways — fully fills (direct → Filled), rests/partial (→ Open), and FOK can't fully fill (→ Expired) — so a fully-filling FOK or GTC reaches Filled without passing through Open.

@gregorydemay

Copy link
Copy Markdown
Contributor Author

🧐 Review — FOK matching gate + execution wiring (3/3) — VERDICT: CHANGES_REQUESTED

(Posted as a comment: GitHub rejects a formal request-changes review on a self/bot-authored PR.)

1 🟠 Medium, 2 🔵 Nits (all inline). CI fully green. The core safety property is sound; the gate is a maintainability one, not correctness.

Correctness — verified by mutation testing, not inspection:

  • Kill is mutation-free (R4/R5): execute short-circuits with MatchResult::Killed on require_full && !plan.taker_remaining().is_zero() before apply_plan runs — structural. Mutating the gate to never fire breaks 5 FOK tests incl. the book-snapshot-equality "mutation-free" test. No partial-then-expire path: the killed order is pop_front'd and dropped, never re-inserted, no fill emitted.
  • R3 full fill / boundary: full and exactly-equal liquidity ⇒ Filled, filled_quantity == quantity, never rests.
  • R5 some-but-insufficient: distinct from no-liquidity; both ⇒ Expired, zero fill, resting book byte-identical.
  • require_full driven by time_in_force: all 388 canister unit tests pass unmodified ⇒ GTC unchanged, FOK never reaches Open.
  • Reservation release on kill: one Unreserve per expired order over full quantity, reusing the cancel refund math; dropping it fails both release tests. Single release (order never entered the book) ⇒ no double-release/leak.
  • R6 (no FOK-specific fee code): existing taker/maker assignment untouched; fee test pins BOTH legs (FOK→taker, resting→maker); mutating the FOK leg to maker rate fails it.
  • R10: worst-case sweep ~7.24B instructions, well within the per-message limit; baseline regenerated; benchmark CI green.
  • R11 + design-doc AC: integration tests confirm immediate OrderId + terminal Filled/Expired via get_my_orders; design.md documents the async upon-execution model, never-rests, and Canceled vs Expired.

Killed-book snapshot: zeroing next_seq for the equality check is the correct reading — a FOK legitimately consumes one sequence at enqueue, and next_seq is not observable book state. Agreed.

Maintainability sweep:

  • duplication: 🟠 one finding — FOK-kill refund block duplicates the cancel block (+ placement twin); copy-paste, not a shared helper (inline).
  • unused derives: 🔵 — RemovedOrder gains dead Encode/Decode inherited from MatchingOutput's own unused derive (inline).
  • primitive-obsession params: none — require_full: bool is the spec's deliberate gate, not an ambiguous quantity.
  • divergent invariant handling: none — placement/cancel/FOK-kill refund all handle Buy→quote(scaled)/Sell→base identically and expect("BUG: …overflow") consistently.
  • silent fallbacks: none — kill/overflow paths expect-trap; no unwrap_or_default/ok() discard/NaN/let _ =.

Tests earn their place: each FOK test covers a distinct R; arb_matching_output hard-coding expired_orders empty is fine (its only consumer ignores the field; no MatchingOutput roundtrip proptest to blind). Invariant checks are always-on, not debug_assert!. No redundant/oracle/tautological tests.

Addressing the 🟠 (extract the shared refund helper) clears the verdict.

gregorydemay and others added 3 commits June 22, 2026 12:54
Extract a single helper shared by the cancel path and the FOK-kill path
so the two reservation-refund computations cannot drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dOrder

Neither type is ever serialized; the derives (and their CBOR field
indices) were dead weight inherited from MatchingOutput's derive list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A fully-filling order (FOK or GTC) transitions Pending to Filled without
passing through Open; the diagram now branches on the matching outcome.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@gregorydemay gregorydemay left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧐 Re-review — FOK matching gate + execution wiring (3/3) — VERDICT: READY

All three prior findings resolved; CI fully green (unit-tests, integration-tests, benchmark, lint, reproducible-build, candid check all pass). No new comments since the last pass. 388 canister unit tests pass on the head commit (d747d24).

Prior findings — verified resolved:

  • 🟠 Refund duplication (b399d6d) — refund_for(side, price, remaining_quantity, base_scale) -> (PairToken, Quantity) extracted; both PairToken refund sites (cancel path in record_cancel_limit_order, FOK-kill path in record_matching_event) now call it, and the expect("BUG: …overflow") message is defined once. The diff is a verbatim extraction of the two identical match arms — behavior-preserving by construction; all reservation/refund tests pass unchanged. Leaving the placement / validate_limit_order path as-is is the right call: it resolves to concrete TokenIds (pair.quote/pair.base), not PairToken, so it cannot share this helper's return type, and the shared scaling math it does duplicate already lives in checked_mul_quantity_scaled — no meaningful drift risk.
  • 🔵 Dead derives (b92a81d) — confirmed neither MatchingOutput nor RemovedOrder is serialized anywhere; Encode/Decode derives and #[n(..)] field tags dropped from both. The minicbor::{Decode, Encode} import stays (still used by OrderBookSnapshot and others); lint green.
  • 🔵 Design diagram (d747d24) — the lifecycle diagram now branches the matching outcome three ways, with a direct Pending → Filled arrow that bypasses Open for a fully-filling FOK/GTC.

Nothing regressed — the FOK feature/test/benchmark/docs commits are unchanged from the first pass; the only delta is the three targeted fix commits above (no scope creep). The core guarantees still hold: structural mutation-free kill (gate short-circuits before apply_plan), single reservation release on kill, both-legs fee assignment (FOK→taker, resting→maker) with no FOK-specific fee code, and the R10 worst-case sweep (~7.24B instructions) well within the per-message limit.

Maintainability sweep: duplication — the flagged 🟠 is now cleared (shared refund_for); no other substantial duplication. Unused derives — cleared (dead Encode/Decode removed). Primitive-obsession params — none (require_full: bool is the spec's deliberate gate, not a quantity). Divergent invariant handling — none (placement/cancel/FOK-kill refunds handle Buy→quote(scaled)/Sell→base identically; consistent expect("BUG: …")). Silent fallbacks — none (kill/overflow paths expect-trap; no unwrap_or_default/.ok() discard/NaN/let _ =).

No blockers, no mediums, no nits remaining. Ready for human approval.

@gregorydemay

Copy link
Copy Markdown
Contributor Author

🤖 Spec-driven loop complete — ready for your review.

PR 3 (3/3) of the DEFI-2853 stack: the FOK matching gate + execution wiring. FOK orders now route through matching — a FOK either fully fills (Filled) or is killed with zero execution and no book mutation (Expired, reservation released); GTC behavior is unchanged.

  • Reviewer VERDICT: READY. Covers R3, R4, R5, R6, R10, R11 + the design-doc AC; core guarantees (mutation-free kill, reservation release, both-legs fee assignment) verified by mutation testing.
  • CI all green (incl. the R10 worst-case canbench: a FOK sweeping 5000 fragmented levels ≈ 7.24B instructions, well within the 40B per-message cap).
  • Review round: one Medium (refund duplication → extracted refund_for helper) + two nits (dead Encode/Decode derives removed; lifecycle diagram now shows the direct Pending → Filled path) — all fixed (b399d6d, b92a81d, d747d24).
  • Stacked on feat(order)!: add FOK data model — TimeInForce, Expired status (2/3) #164; diff is feat(matching): FOK matching gate + execution wiring (3/3) #169's own delta only. No new Candid surface.

The 3 review threads are addressed + replied, left unresolved for you to close.

Draft — marking ready, approval, and merge are yours.

# Conflicts:
#	canister/canbench_results.yml
@gregorydemay

Copy link
Copy Markdown
Contributor Author

🤖 Synced onto the updated #164: merged bc4ac29 (RestingOrder now carries time_in_force) into this branch as 4b8d6b4 (--no-ff, no history rewrite). Mechanical base-sync only — no matching-gate logic changed, so the READY verdict still holds. The one conflict was the canbench baseline; regenerated on the merged tree, and the snapshot/upgrade totals match #164's to within 32 instructions (0.00005%). CI fully green.

gregorydemay and others added 2 commits June 23, 2026 04:57
Pin the user-initiated cancel path for a fill-or-kill order, distinct
from the engine's expire/kill path:

- cancelling a Pending FOK ends Canceled (not Expired) and refunds the
  placement reservation, and
- cancelling a FOK the engine already killed is rejected with
  OrderAlreadyTerminal.

The Pending window is only deterministically observable at the unit
level, where matching is driven explicitly; at the integration level the
eager post-placement matching timer races the cancel, so no integration
test is added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@gregorydemay gregorydemay left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧐 Re-review (scoped to the two changes since my prior READY): base-sync merge a66bf98 + new test-only commit f6bde59. CI fully green.

Base-sync (a66bf98 + 55d6851): the merge touches only canister/src/order/tests.rs (drops the redundant should_preserve_time_in_force_of_resting_order resting-TIF test). No book.rs / state / execute files in the merge — the plan/execute matching gate, the require_full/Killed path, and record_matching_event are byte-untouched. Logic undisturbed, confirmed.

New tests (f6bde59, state/tests.rs::cancel_limit_order): correct, non-tautological, and they earn their place.

  • Coverage established by mutation, not inspection:
    • validate_cancel_limit_order made to accept Expired (Pending|Open|Expired => Ok) → only should_reject_canceling_expired_fok fails across the entire suite (392 pass, 1 fail). No existing terminal-cancel test (Filled / second-cancel) covers the Expired arm, so this closes a real gap.
    • Refund amount zeroed in record_cancel_limit_ordershould_cancel_pending_fok_into_canceled_and_refund fails. The reservation refund is genuinely asserted (via the shared assert_cancel_refunds), not pulled from the actual.
    • Cancel made to write Expired instead of Canceled → the Pending-FOK test fails, so it does pin the Canceled-vs-Expired distinction (the Canceled assertion is also shared with the GTC pending-cancel tests; the FOK test's unique contribution is exercising a FOK-typed order through the cancel/refund path, which nothing else does).
  • No redundancy with the existing fill_or_kill integration tests (those cover Filled / Expired-no-liquidity / Expired-insufficient) or the GTC cancel tests.
  • place_fok_order is a clean parameterization over place_order_with_tif — no copy-paste.

Integration-test skip — justified. add_limit_order fires drive_matching via set_timer(Duration::ZERO, …) (lib.rs:129), so advancing PocketIC time to land a cancel would also fire matching; the Pending window is not deterministically observable end-to-end, and a test relying on the race would be flaky. The spec's R8 integration AC ("a user cancel still yields Canceled") is already met by the existing GTC cancel integration test; the FOK Pending-cancel is correctly pinned at the unit level where matching is driven explicitly. No real end-to-end coverage missing.

Maintainability sweep: duplication: none found (shared helpers, ~6-line tests). Unused derives: none (no new types). Primitive-obsession params: none (place_fok_order mirrors the typed place_order signature). Divergent invariant handling: none — validate_cancel handles Filled|Canceled|Expired uniformly as OrderAlreadyTerminal at the single site. Silent fallbacks: none.

No blockers, no mediums, no nits. CI green.

VERDICT: READY

@gregorydemay

Copy link
Copy Markdown
Contributor Author

🤖 Stack upkeep + new coverage — still READY.

  • Re-synced onto feat(order)!: add FOK data model — TimeInForce, Expired status (2/3) #164 (a66bf98): pulled the redundant-test removal (55d6851); mechanical, no matching-gate logic touched.
  • Cancel-FOK coverage (f6bde59, forward note from feat(order)!: add FOK data model — TimeInForce, Expired status (2/3) #164): two unit tests in cancel_limit_order
    • should_cancel_pending_fok_into_canceled_and_refund: a Pending FOK cancelled by the user ends Canceled (not Expired) with the reservation refunded.
    • should_reject_canceling_expired_fok: once the engine has killed a FOK (Expired), cancel returns OrderAlreadyTerminal.
    • No integration test: add_limit_order fires a zero-delay matching timer, so the Pending window isn't deterministically observable end-to-end (would be flaky); existing GTC-cancel and fill_or_kill integration tests already cover the rest.

Reviewer re-confirmed by mutation testing — the expired-cancel test closes a real gap (no prior test covered the Expired cancel arm). VERDICT: READY, CI all green. The three earlier review threads remain replied + unresolved for you to close. Draft — approval and merge are yours.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant