Skip to content

feat(order): persist per-fill records in stable memory (3/5)#179

Merged
gregorydemay merged 137 commits into
mainfrom
gdemay/DEFI-2901-fill-store
Jul 1, 2026
Merged

feat(order): persist per-fill records in stable memory (3/5)#179
gregorydemay merged 137 commits into
mainfrom
gdemay/DEFI-2901-fill-store

Conversation

@gregorydemay

@gregorydemay gregorydemay commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Persists every execution as a side-projected per-fill record in stable memory — the granular layer beneath the merged order-level scalars. Builds on the trade store landed in #193: this PR wires the producer that writes fills into that store on settlement. To keep the matching→settling event boundary small, the settling event now carries a lean, normalized per-fill record (fill identity, quantity, fee-rate snapshot) instead of the full settlement; the settling phase recovers each fill's side and execution price from the order records and recomputes its notional and fees before persisting the two trade legs. Persistence runs under the existing Write gate so replay does not double-write. No retrieval endpoint ships here; the get_my_trades feed is #186.

Spec: docs/src/development/specs/DEFI-2901-persist-fills.md

Requirements coverage

  • R3 — every fill persists, side-projected, the auditing fields with no counterparty.
  • R6 (per-fill)price is the maker's price, notional the executed notional, fee the side's realized amount, is_maker the per-fill role.
  • R8 — fill persistence is Write-gated and replay-safe; records are durable across upgrade.
  • R11 — fills and balance ops share one computed source; the settling recompute is pinned to the fill's fee-rate snapshot so the persisted trade legs can never diverge from the transfers.
  • R12 — settlement instruction cost measured with a canbench micro-benchmark.

Performance impact

Measured against main via the full-Binance-snapshot sweep benches. Splitting the fill benches by phase shows the cost is concentrated entirely in settlingmatching stays flat, confirming it never touches stable memory:

Benchmark Phase main this PR Δ %
gtc_fill_full_bid_side matching 99.2M 99.8M +0.6M +0.6%
settling 677.6M 895.8M +218.2M +32.2%
total 780.0M 1000.4M +220.4M +28.3%
process_pending_orders_1000 matching 150.6M 150.5M −0.1M −0.1%
settling 839.4M 1068.1M +228.8M +27.3%
total 994.7M 1225.2M +230.5M +23.2%
process_pending_orders_1000_with_fees matching 168.2M 168.2M +0.1M +0.1%
settling 843.6M 1087.7M +244.1M +28.9%
total 1016.9M 1263.0M +246.1M +24.2%

(fok_fill_full_bid_side is identical to gtc_fill_full_bid_side — same fill path.)

  • Matching is flat (±0.6%): the fill work only derives the lean SettledFill on the heap; it never persists.
  • Settling absorbs the entire cost (+27–32%): per fill it writes two side-projected trade legs, each into the primary map and the by-user index (4 stable-memory inserts/fill), then resolves owners and recomputes notional/fees from the fill's fee-rate snapshot.
  • Event log: write_events 22.3M → 28.9M (+29%), read_events 14.9M → 18.8M (+26%) — the cost of the lean record on SettlingEvent (vs. re-serializing the full settlement, which would cost ~3× more). Worst-case SettlingEvent is 208,030 B.
  • notional is computed once per fill (the shared fees() returns it), avoiding a redundant per-fill bignum recompute.
  • All of this runs under the Write gate, so post-upgrade replay pays none of it — the no-fills and upgrade benches are unchanged (<0.5%).

📚 PR stack

🤖 Generated with Claude Code

gregorydemay and others added 19 commits June 23, 2026 10:16
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) <noreply@anthropic.com>
…USDT

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`record_matching_event` materialized every fill's settlement into a
`Vec<FillSettlement>` 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…me 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…r-level-scalars

# Conflicts:
#	canister/src/state/mod.rs
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) <noreply@anthropic.com>
…e_operations

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses review comment 3465971111.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…se_scale)

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

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

canbench 🏋 (dir: canister) df85b87 2026-07-01 17:01:32 UTC

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

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

Summary:
  instructions:
    status:   Regressions detected 🔴
    counts:   [total 14 | regressed 6 | improved 0 | new 0 | unchanged 8]
    change:   [max +244.57M | p75 +165.56M | median +45.07K | p25 0 | min -32]
    change %: [max +29.23% | p75 +25.12% | median +0.11% | p25 0.00% | min -0.00%]

  heap_increase:
    status:   Regressions and improvements 🔴🟢
    counts:   [total 14 | regressed 1 | improved 1 | new 0 | unchanged 12]
    change:   [max +8 | p75 0 | median 0 | p25 0 | min -4]
    change %: [max +114.29% | p75 0.00% | median 0.00% | p25 0.00% | min -33.33%]

  stable_memory_increase:
    status:   No significant changes 👍
    counts:   [total 14 | regressed 0 | improved 0 | new 0 | unchanged 14]
    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_fok_fill_full_bid_side::qty::div_rem_u64                |  1.39K | 842.33K | +100.00% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_fok_fill_full_bid_side::qty::mul_u64                    |  1.39K | 862.89K | +100.00% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_gtc_fill_full_bid_side::qty::div_rem_u64                |  1.39K | 842.33K | +100.00% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_gtc_fill_full_bid_side::qty::mul_u64                    |  1.39K | 862.89K | +100.00% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_fok_fill_full_bid_side::qty::mul_u128                   |  1.39K |   5.62M |  +97.72% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_gtc_fill_full_bid_side::qty::mul_u128                   |  1.39K |   5.62M |  +97.72% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_process_pending_orders_1000_with_fees::qty::div_rem_u64 |  5.02K |   2.95M |  +85.07% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_process_pending_orders_1000_with_fees::qty::mul_u64     |  5.02K |   3.11M |  +85.07% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_process_pending_orders_1000::qty::div_rem_u64           |  1.94K |   1.14M |  +65.53% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_process_pending_orders_1000::qty::mul_u64               |  1.94K |   1.20M |  +65.50% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_process_pending_orders_1000_with_fees::qty::mul_u128    |  1.94K |   7.89M |  +61.85% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_process_pending_orders_1000::qty::mul_u128              |  1.94K |   8.15M |  +58.79% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_process_pending_orders_1000_with_fees::qty              | 31.02K |  66.11M |  +34.39% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_fok_fill_full_bid_side::settling                        |      1 | 895.84M |  +31.90% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_gtc_fill_full_bid_side::settling                        |      1 | 895.84M |  +31.90% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_write_events::Settling                                  |      1 |  28.25M |  +30.05% | 14 | +133.33% |   0 |   0.00% |
|   +    | bench_write_events                                            |        |  28.86M |  +29.23% | 15 | +114.29% |   0 |   0.00% |
|   +    | bench_process_pending_orders_1000_with_fees::settling         |      1 |   1.09B |  +28.68% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_gtc_fill_full_bid_side                                  |        |   1.00B |  +27.96% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_fok_fill_full_bid_side                                  |        |   1.00B |  +27.96% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_process_pending_orders_1000::settling                   |      1 |   1.07B |  +27.05% |  0 |    0.00% |   0 |   0.00% |
|  +/-   | bench_read_events::Settling                                   |      1 |  18.41M |  +26.09% |  8 |  -33.33% |   0 |   0.00% |
|  +/-   | bench_read_events                                             |        |  18.75M |  +25.49% |  8 |  -33.33% |   0 |   0.00% |
|   +    | bench_fok_fill_full_bid_side::qty                             | 15.34K |  31.25M |  +24.42% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_gtc_fill_full_bid_side::qty                             | 15.34K |  31.25M |  +24.42% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_process_pending_orders_1000_with_fees                   |        |   1.26B |  +24.02% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_process_pending_orders_1000                             |        |   1.23B |  +23.07% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_process_pending_orders_1000::qty                        | 19.48K |  41.12M |  +18.14% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_process_pending_orders_1000::bal::debit_reserved        |  1.54K |   5.54M |  +13.44% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_process_pending_orders_1000_with_fees::qty::add         | 13.50K |   4.76M |  +12.86% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_fok_fill_full_bid_side::bal::deposit                    |  1.39K |   3.79M |   +5.84% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_gtc_fill_full_bid_side::bal::deposit                    |  1.39K |   3.79M |   +5.84% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_fok_fill_full_bid_side::bal::debit_reserved             |  1.39K |   4.67M |   +4.68% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_gtc_fill_full_bid_side::bal::debit_reserved             |  1.39K |   4.67M |   +4.68% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_fok_fill_full_bid_side::bal                             |  2.79K |  12.83M |   +3.97% |  0 |    0.00% |   0 |   0.00% |
|   +    | bench_gtc_fill_full_bid_side::bal                             |  2.79K |  12.83M |   +3.97% |  0 |    0.00% |   0 |   0.00% |
|   -    | bench_process_pending_orders_1000::bal::deposit               |  1.54K |   4.18M |  -13.57% |  0 |    0.00% |   0 |   0.00% |
|  new   | bench_fok_fill_full_bid_side::fills                           |    697 | 207.33M |          |  0 |          |   0 |         |
|  new   | bench_fok_fill_full_bid_side::fills::append                   |    697 | 206.19M |          |  0 |          |   0 |         |
|  new   | bench_gtc_fill_full_bid_side::fills                           |    697 | 207.33M |          |  0 |          |   0 |         |
|  new   | bench_gtc_fill_full_bid_side::fills::append                   |    697 | 206.19M |          |  0 |          |   0 |         |
|  new   | bench_process_pending_orders_1000::fills                      |    769 | 217.30M |          |  0 |          |   0 |         |
|  new   | bench_process_pending_orders_1000::fills::append              |    769 | 216.00M |          |  0 |          |   0 |         |
|  new   | bench_process_pending_orders_1000_with_fees::fills            |    769 | 217.49M |          |  0 |          |   0 |         |
|  new   | bench_process_pending_orders_1000_with_fees::fills::append    |    769 | 216.19M |          |  0 |          |   0 |         |

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

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

@gregorydemay

Copy link
Copy Markdown
Contributor Author

🧐 Reviewer verdict — DEFI-2901 PR 2 of 3 ("Fill store + per-order feed")

Reviewed only PR 2's incremental commit (53d475c) on top of PR #171; did not re-review PR 1's order-level scalars.

What is solid (no changes needed):

  • R3 — FillRecord persists exactly the spec field set, side-projected, counterparty never stored; the should_persist_a_record_with_no_counterparty_fields test destructures the whole record so a counterparty field can't be added unnoticed.
  • R6 / R11 — FillSettlement::fill_records builds both legs from the single computed notional / taker_fee / maker_fee (same source as the balance ops), at the maker price, with per-fill role and side. State tests pin the worked-example numbers and the crosses-then-rests case (is_maker false then true).
  • R8 — writes are Write-gated; replay_under_skip_writes_no_fills proves replay double-write safety; the FillSeq counter and records live in their own stable regions (no upgrade-serialization cost).
  • R5 / R10 — error envelope is correct: malformed id/cursor → Err(RequestError), unknown/not-owned id or unknown cursor → Ok([]); length clamped to MAX_FILLS_PER_RESPONSE. Well covered in lib + integration tests.
  • R4 (ByOrder) — owner-scoped, newest-first; not-owned/unknown → empty page.
  • R12 — bench_settlement_fill_persistence isolates the per-fill insert cost (fills/fills::append scopes); canbench_results.yml refreshed.
  • MemoryIds 7 (fills) and 9 (seq) added, 8 reserved for the PR-3 by_user index — no collision with 0–6 or PR 1. ByAccount is a non-trapping empty-page stub that builds no account-wide index (correctly deferred to PR 3).
  • Candid change is additive; the in-repo check_candid_interface_compatibility test and the candid-backward-compat CI gate both pass. All 410 canister unit tests pass locally.

🔴 Blocker — end-to-end pagination is non-functional (the design tension, resolved against the PR).
fills_after returns the FillSeq cursor for each fill, but get_my_trades discards it (canister/src/lib.rs:480, .map(|(_seq, record)| record.into())) and the public Trade carries no seq/cursor field. A client can fetch page 1 (the newest length fills) but has no value to pass back as after, so it can never advance to page 2. This breaks part of PR 2's own acceptance — R4 (ByOrder) and R10 both specify pagination via the after cursor, and the spec prose says the cursor is "the last value they received," yet a Trade exposes nothing to feed back. Contrast the precedent it mirrors: get_my_orders { ByPage }'s cursor is the OrderId, which IS a field on the returned record, so "pass the previous page's last order id" works there. The store paginates correctly (the should_page_via_after_cursor unit test can only assert this because it sees the internal FillSeq); the gap is purely at the public boundary, and no test paginates past page 1 through get_my_trades because it is impossible to write one. The docs make it worse by actively misleading: libs/types/src/lib.rs:358 ("a prior page's last [Trade]'s cursor") and oisy_trade.did:391 ("pass the previous page's last cursor as after") both describe a cursor field that does not exist.
The spec is internally contradictory here (the .did Trade block has no cursor, yet the prose promises a returned cursor). Two clean resolutions — your call with the human:
(a) add a cursor field to the public Trade (the FillSeq rendered as the opaque FillCursor text) so a client can resume — makes pagination functional and resolves the contradiction; or
(b) explicitly defer beyond-first-page paging and fix both doc comments to say so, rather than describing a "last cursor" that does not exist.

🔵 Nit — FillSeq derives Hash (canister/src/order/fills/mod.rs:17) but is never used as a map key; it's a cursor. Sibling types OrderId/OrderSeq also derive Hash, so this is consistent with convention — mentioning only for completeness, not asking for a change.

CI: lint, unit-tests, benchmark, candid-backward-compat, reproducible-build all green; integration-tests still pending at review time — pending CI is itself a hard block on a READY verdict.

Severity tally: 1 blocker, 0 medium, 1 nit. Plus pending CI.

VERDICT: CHANGES_REQUESTED

@gregorydemay

Copy link
Copy Markdown
Contributor Author

🤖 Fixed the blocker via resolution (a) in commit c30ed99.

The cursor is now surfaced on Trade: a new cursor : FillCursor field (the first field, mirroring how get_my_orders { ByPage } exposes the OrderId that doubles as its page cursor). It is populated from that fill's FillSeq via FillRecord::into_trade(seq), encoded with the exact same scheme get_my_trades decodes for after (FillSeq's 16-char hex Display/FromStr), so a returned cursor fed back as after round-trips. get_my_trades now stamps every entry's cursor instead of discarding the internal FillSeq (the old canister/src/lib.rs:480 .map(|(_seq, record)| ...)); both the ByOrder (live) and ByAccount (empty-stub) arms go through the same map.

Pagination now round-trips end to end:

  • a unit round-trip test (should_round_trip_a_trade_cursor_back_into_a_fill_seq) asserts Trade.cursor.parse::<FillSeq>() recovers the original seq;
  • a multi-page ByOrder integration test (should_paginate_per_order_fills_via_trade_cursor) sweeps two maker levels into one buy order, fetches page 1 (length 1), feeds its last Trade.cursor back as after, and confirms page 2 continues strictly after with no overlap/gap (and page 3 is empty).

Docs fixed to match: the Trade.cursor doc on both libs/types and the .did, the after docstrings (now "the Trade::cursor of the prior page's last entry"), and the .did ByOrder comment (now "pass the previous page's last Trade.cursor").

Nit on FillSeq: Hash — left as is per your note (consistent with OrderId/OrderSeq).

Verified locally: cargo fmt --all, cargo sort --workspace --check, just lint (clippy native + wasm, clean), the additive candid backward-compat check (check_candid_interface_compatibility passes), cargo test -p oisy_trade_canister (411 pass), -p oisy_trade_types/-p oisy_trade_client, and just build + cargo test -p oisy_trade_int_tests (46 pass).

@gregorydemay

Copy link
Copy Markdown
Contributor Author

🧐 Reviewer re-review — DEFI-2901 PR 2 of 3 ("Fill store + per-order feed"), at c30ed99.

The single blocker from my prior review — non-functional pagination (no cursor on the public Trade) — is genuinely resolved via resolution (a), and the spec contradiction is gone (the updated type Trade now leads with cursor : FillCursor, prose mandates each Trade carries its cursor).

Verified:

  • Cursor round-trips with no asymmetry. FillSeq Display is {:016x}; FromStr requires exactly 16 ASCII chars via from_str_radix(_, 16). into_trade(seq) stamps cursor: seq.to_string(), which is exactly what the after decoder (cursor.parse::<FillSeq>()) accepts — a returned cursor fed back as after decodes to the same FillSeq. The new unit test should_round_trip_a_trade_cursor_back_into_a_fill_seq pins this and would fail on any encoding drift. A malformed cursor still maps to Err(InvalidCursor) (R5), not misclassified.
  • Each Trade carries its OWN cursor: fills_after yields (entry.key().seq, entry.value()) and get_my_trades maps |(seq, record)| record.into_trade(seq) — the seq is paired with its own record, not a neighbor's.
  • Multi-page ByOrder works end to end. The new integration test should_paginate_per_order_fills_via_trade_cursor sweeps two maker levels into one buy order, reads page 1 (length 1), feeds its last Trade.cursor back as after, asserts page 2 is the next-older fill (== all[1]) with a distinct cursor (no overlap), and page 3 is empty (no gap). It anchors ordering against a full unpaginated read, so it genuinely exercises page1→cursor→page2→empty.
  • R5/R10 dispositions still hold. Unknown cursor (FillCursorNotFound) and not-owned/unknown order both collapse to Ok([]) via .unwrap_or_default() — both are the spec-mandated outcome, so this is correct disposition, not a silent-fallback breach; malformed id/cursor → Err. Covered by malformed_cursor_is_err / unknown_cursor_is_empty_page (unit) and the integration tests.
  • The cursor field is candid-additive: check_candid_interface_compatibility passes locally and the candid-backward-compat CI gate is green. R3, R6 (per-fill), R8, R11, R12 are untouched by this commit; all 411 canister unit tests + 16 types tests pass locally.
  • Docs corrected to match: Trade.cursor doc on both libs/types and the .did, the after docstrings, and the .did ByOrder comment now reference Trade.cursor rather than a non-existent field.
  • ByAccount empty-page stub remains correctly deferred to PR 3 — not required here.
  • My prior FillSeq: Hash nit is acknowledged and left as-is (consistent with sibling OrderId/OrderSeq) — not a blocker.

No unresolved review threads or inline comments outstanding.

The review substance is clean — no blockers, no mediums, no new nits. The ONLY thing holding back a READY verdict is CI: lint, reproducible-build, and benchmark are still pending (gh pr checks 179). Pending CI is a hard block on READY. Once those three go green with no review substance changing, this is ready for human approval.

Severity tally: 0 blockers, 0 mediums, 0 new nits. Blocked solely on pending CI.

VERDICT: CHANGES_REQUESTED

@gregorydemay

Copy link
Copy Markdown
Contributor Author

🧐 Final reviewer verdict — DEFI-2901 PR 2 of 3 ("Fill store + per-order feed"), at c30ed99.

The substance was already certified clean in my prior re-review pass (no blockers, no mediums, no nits) — the only thing holding back a READY was three pending CI checks. Both conditions I set are now satisfied:

  1. CI is fully green — all 11 checks pass (lint, reproducible-build, benchmark, unit/integration tests, candid-backward-compat, all included). No pending, no failures.
  2. The head has not moved since the commit I certified (still c30ed99), and no new review threads or comments have appeared.

Ready for your approval.

VERDICT: READY

@gregorydemay

Copy link
Copy Markdown
Contributor Author

🤖 Ready for your review — reviewer verdict is READY, CI 11/11 green, MERGEABLE.

PR 2 of 3 (fill store + per-order get_my_trades { ByOrder } feed), stacked on #171.

This round resolved the one reviewer blocker (commit c30ed99): each Trade now carries its own cursor field (populated from the fill's FillSeq), so ByOrder pagination round-trips end to end — the client passes a prior page's last Trade.cursor back as after, mirroring get_my_orders { ByPage }. The spec (#170) was updated to match — its Trade block had promised an after cursor in prose but exposed no cursor field.

Scope held to PR 2: ByAccount is a non-trapping empty-page stub (no account-wide index built); the FILLS_BY_USER_MEMORY_ID index, trades_after, and the live ByAccount arm are PR 3.

Left as a draft — marking ready, approval, and merge are yours.

gregorydemay and others added 2 commits June 25, 2026 13:35
…r-level-scalars

Integrate the DEFI-2853 Fill-or-Kill matching/settlement work from main with
this PR's order-level realized scalars: the settlement loop builds one
FillSettlement per fill (feeding both balance operations and per-order scalar
deltas) and also unreserves the placement reservation of each killed FOK order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 1, 2026 09:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 2 comments.

Comment thread canister/src/state/mod.rs Outdated
Comment thread canister/src/order/fill.rs Outdated

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

🧐 VERDICT: READY (substance) — 0 blockers, 0 mediums, 0 nits; CI green except integration-tests still pending.

Review details

Focused re-review of the changes since the prior READY: the public candid FillEvent exposure, the SettledFillFillEvent rename, the main merge (generic History refactor), the resolve_op_usersresolve_op_orders rename, and the TradeCursorNotFound cleanup.

1. Public candid change. The map_fill_event mapping (canister/src/main.rs) is total and correct: fill_seq/taker_order_seq/maker_order_seq via .get()nat64, quantity.into()nat, fee_rates.maker.get()/fee_rates.taker.get()maker_fee_bps/taker_fee_bps (nat16). No misorder, no data loss. SettlingEvent/FillEvent appear only in the get_events return path (never as an input), so the addition is a backward-compatible Candid evolution — candid-backward-compat and check_candid_interface_compatibility both pass, so the .did matches the code-derived interface. The maker/taker swap is pinned by the integration test's distinct 10/23 bps values, so a transposed mapping would fail.

2. FillEvent rename. Complete — no stray SettledFill anywhere in the branch. The internal FillEvent is the lean event-log record (FillSeq/OrderSeq/Quantity/FeeRates, minicbor #[n(..)]), distinct from the candid DTO; both legs are persisted via TradeHistory::append.

3. main merge / generic-History refactor. Taken cleanly: OrderHistory and TradeHistory are both newtype wrappers over the shared History<M,K,V> core (the sibling-unification pattern). arb_trade_record is de-duplicated to a single definition in test_fixtures/mod.rs and fuzzes all 8 fields. trade_history is written in production (state/mod.rs settling loop) with no #[allow(dead_code)].

4. Renames/cleanup. resolve_op_orders returns ResolvedOrder { user, side, price }; TradeCursorNotFound fully removed (only the shared CursorNotFound remains). FillId/TradeId are now genuinely used, so their prior dead-code allows are correctly gone.

5. Earlier invariants still hold. Settling persistence is Skip-gated (early return) — replay-safe; owners resolved via resolve_op_orders; taker side and maker price recovered from the order records; notional/fees recomputed from the pinned fee_rates snapshot through the shared fees() helper (R11 single source). The settling_event_under_skip_writes_no_fills_and_no_balances test asserts both no-fills AND no-balance-movement under Skip vs. exactly-once under Write; the audit harness captures the real SettlingEvent (with fills) for event-log replay; the snapshot test roundtrips pending_settling_events with FillEvent.

Maintainability accounting.

  • Duplication (within diff): none found — fees() is the single shared source; map_fill_event follows the existing map_* DTO-boundary pattern in main.rs.
  • Structural duplication (repo-wide): none — the merge unifies OrderHistory/TradeHistory onto the generic History core rather than mirroring.
  • Unused derives: none found on FillEvent (Clone/PartialEq/Eq/Debug used in tests + Encode/Decode for the event log).
  • Primitive-obsession params: none — internal FillEvent uses domain newtypes; the candid DTO's bare u64/u16 is the expected wire boundary.
  • Divergent invariant handling: none — the BUG: panics for missing order/owner in resolve_op_orders match the Skip-gate/expect convention used elsewhere in settling.
  • Silent fallbacks: none found.
  • Test-only code in production modules: none — arb_fill/arb_fee_rates/arb_trade_record live in test_fixtures.

Tests. rebuilds_legs_identical_to_the_matching_computation is not a parallel-oracle liability: it pins R11's central invariant that the split matching-phase and settling-phase computations stay consistent (the exact risk the refactor introduces), deriving expectations from the real balance ops. Buy/Sell, taker/maker, and both roles-in-one-order are all exercised end-to-end. cargo test -p oisy_trade_canister — 422 passed, 0 failed; check_candid_interface_compatibility passes.

Only outstanding gate: the integration-tests CI job is still pending (it covers the candid FillEvent mapping end-to-end). Substantively the PR is clean; flip to READY once that job goes green.

@gregorydemay

Copy link
Copy Markdown
Contributor Author

🧐 VERDICT: READY (substance) — 0 blockers, 0 mediums, 0 nits; CI green except integration-tests still pending.

Review details

Focused re-review of the changes since the prior READY: the public candid FillEvent exposure, the SettledFillFillEvent rename, the main merge (generic History refactor), the resolve_op_usersresolve_op_orders rename, and the TradeCursorNotFound cleanup.

1. Public candid change. The map_fill_event mapping (canister/src/main.rs) is total and correct: fill_seq/taker_order_seq/maker_order_seq via .get()nat64, quantity.into()nat, fee_rates.maker.get()/fee_rates.taker.get()maker_fee_bps/taker_fee_bps (nat16). No misorder, no data loss. SettlingEvent/FillEvent appear only in the get_events return path (never as an input), so the addition is a backward-compatible Candid evolution — candid-backward-compat and check_candid_interface_compatibility both pass, so the .did matches the code-derived interface. The maker/taker swap is pinned by the integration test's distinct 10/23 bps values, so a transposed mapping would fail.

2. FillEvent rename. Complete — no stray SettledFill anywhere in the branch. The internal FillEvent is the lean event-log record (FillSeq/OrderSeq/Quantity/FeeRates, minicbor #[n(..)]), distinct from the candid DTO; both legs are persisted via TradeHistory::append.

3. main merge / generic-History refactor. Taken cleanly: OrderHistory and TradeHistory are both newtype wrappers over the shared History<M,K,V> core (the sibling-unification pattern). arb_trade_record is de-duplicated to a single definition in test_fixtures/mod.rs and fuzzes all 8 fields. trade_history is written in production (state/mod.rs settling loop) with no #[allow(dead_code)].

4. Renames/cleanup. resolve_op_orders returns ResolvedOrder { user, side, price }; TradeCursorNotFound fully removed (only the shared CursorNotFound remains). FillId/TradeId are now genuinely used, so their prior dead-code allows are correctly gone.

5. Earlier invariants still hold. Settling persistence is Skip-gated (early return) — replay-safe; owners resolved via resolve_op_orders; taker side and maker price recovered from the order records; notional/fees recomputed from the pinned fee_rates snapshot through the shared fees() helper (R11 single source). The settling_event_under_skip_writes_no_fills_and_no_balances test asserts both no-fills AND no-balance-movement under Skip vs. exactly-once under Write; the audit harness captures the real SettlingEvent (with fills) for event-log replay; the snapshot test roundtrips pending_settling_events with FillEvent.

Maintainability accounting.

  • Duplication (within diff): none found — fees() is the single shared source; map_fill_event follows the existing map_* DTO-boundary pattern in main.rs.
  • Structural duplication (repo-wide): none — the merge unifies OrderHistory/TradeHistory onto the generic History core rather than mirroring.
  • Unused derives: none found on FillEvent (Clone/PartialEq/Eq/Debug used in tests + Encode/Decode for the event log).
  • Primitive-obsession params: none — internal FillEvent uses domain newtypes; the candid DTO's bare u64/u16 is the expected wire boundary.
  • Divergent invariant handling: none — the BUG: panics for missing order/owner in resolve_op_orders match the Skip-gate/expect convention used elsewhere in settling.
  • Silent fallbacks: none found.
  • Test-only code in production modules: none — arb_fill/arb_fee_rates/arb_trade_record live in test_fixtures.

Tests. rebuilds_legs_identical_to_the_matching_computation is not a parallel-oracle liability: it pins R11's central invariant that the split matching-phase and settling-phase computations stay consistent (the exact risk the refactor introduces), deriving expectations from the real balance ops. Buy/Sell, taker/maker, and both roles-in-one-order are all exercised end-to-end. cargo test -p oisy_trade_canister — 422 passed, 0 failed; check_candid_interface_compatibility passes.

Only outstanding gate: the integration-tests CI job is still pending (it covers the candid FillEvent mapping end-to-end). Substantively the PR is clean; flip to READY once that job goes green.

gregorydemay and others added 2 commits July 1, 2026 09:34
Addresses Copilot review thread 3504739601: FillEvent::trade_legs no
longer reconstructs a Fill with taker_price set to maker_price. The
shared fees() helper now takes the maker price, quantity, side and fee
rates directly, so both the matching phase and the settling phase pass
the values they actually have.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses Copilot review thread 3504739531: the settling loop now
resolves each fill's taker/maker order seq via get().expect() with a
self-documenting BUG message instead of a bare map index that panics
with a generic "no entry found".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread canister/src/order/tests.rs Outdated
Comment thread canister/src/state/tests.rs Outdated
Comment thread canister/src/state/tests.rs
gregorydemay and others added 2 commits July 1, 2026 09:52
Addresses review thread 3504804236: collapse per-field TradeRecord
assertions in the trade-leg reconstruction proptest and the sell-taker
side-swap test to a single equality check against a constructed expected
record.

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

Addresses review thread 3504855794: the test name
settling_event_under_skip_writes_no_fills_and_no_balances is
self-describing; remove the multi-line doc comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 1, 2026 09:52
Extends review thread 3504804236 to the settle_fills persistence tests:
collapse the field-by-field TradeRecord checks in
buy_taker_sweeping_two_levels_persists_per_fill_records and
order_filling_both_ways_records_a_taker_and_a_maker_fill to a single
equality against the full expected records.

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings July 1, 2026 09:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated 1 comment.

Comment thread canister/src/state/mod.rs
@gregorydemay gregorydemay marked this pull request as ready for review July 1, 2026 10:08
@gregorydemay gregorydemay requested a review from a team as a code owner July 1, 2026 10:08
@zeropath-ai

zeropath-ai Bot commented Jul 1, 2026

Copy link
Copy Markdown

No security or compliance issues detected. Reviewed everything up to 324cdb5.

Security Overview
Detected Code Changes
Change Type Relevant files
Enhancement ► canister/canbench_results.yml
    Update benchmark values across multiple benches and scopes to reflect new instruction counts and calls
► canister/oisy_trade.did
    Add FillEvent type and integrate into SettlingEvent via fills field
Enhancement ► canister/src/main.rs
    Map FillEvent in SettlingEvent and compute per-fill trade legs in settlement
Enhancement ► canister/src/order/fill.rs
    Introduce FillEvent struct and integrate FeeRates snapshot and per-fill settlement flow
    Refactor FillSettlement to emit lean FillEvent and compute fees via new fees() helper
    Add fee_token helper and update imports/usages
Enhancement ► canister/src/order/mod.rs
    Expose FillEvent type in public API and adjust imports accordingly
Enhancement ► canister/src/order/tests.rs
    Add tests for settled fill trade legs, share of fill_seq, and leg comparisons
Enhancement ► canister/src/order/trades/tests.rs
    Update tests to validate trade records for taker/maker legs with correct notional and fees
Enhancement ► canister/src/state/audit/mod.rs
    Pass timestamp to record_settling_event instead of lacking timestamp
Enhancement ► canister/src/state/audit/tests.rs
    Adjust tests to call record_settling_event with timestamp and updated replay logic for settling events
Enhancement ► canister/canbench_results.yml (diff context indicates multiple bench metric updates)

@gregorydemay gregorydemay requested a review from mbjorkqvist July 1, 2026 10:09

@mbjorkqvist mbjorkqvist left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks @gregorydemay!

I was confused by that fact that even though we're here persisting records in stable memory, the canbench report shows stable_memory_increase: 0 for every fill/settling bench, including bench_gtc_fill_full_bid_side::fills::append (697 appends, 206M instructions). IIUC, the fills are persisted, but canbench's SMI counts only stable_grow page allocations inside the measured closure, and MemoryManager allocates in large multi-MiB buckets (128 pages = 8 MiB by default) whose first bucket is claimed at StableBTreeMap::init time in new_state(), i.e. during setup, before bench_fn. Each bench then writes at most a few hundred KB of trade legs (gtc: ~697 fills x 2 legs x ~140 B ~= 150 KB), which fits inside that pre-allocated bucket, so no stable_grow fires in the measured window -> SMI = 0 (same reason heap_increase is 0 there). One way to track the stable memory usage could be to add a byte-accounting unit test rather than leaning on canbench SMI: Append N fills to a fresh TradeHistory and assert the backing regions' page delta (trades_memory().size() before/after) or the summed minicbor-encoded size of a representative SeqRecord<TradeRecord> + TradeId key + by-user entry, pinning bytes-per-fill (and, mirrored, bytes-per-order) as a tracked, regression-guarded number. A canbench sized to cross bucket boundaries would also surface SMI, but it's 8-MiB-granular and noisier than a direct byte assertion.

Comment thread canister/src/order/fill.rs
@gregorydemay

Copy link
Copy Markdown
Contributor Author

🤖 @mbjorkqvist Good call on the SMI/bucket explanation — canbench reports stable_memory_increase: 0 for the fill benches because MemoryManager pre-allocates 8 MiB buckets at StableBTreeMap::init, so per-bench writes fit inside the pre-claimed bucket and never stable_grow. Added a bytes-per-fill accounting test in #195 (follow-up test PR on top of this one) that pins the store's footprint directly from the summed minicbor/Storable size of what a fill writes (both trade legs, primary map + by-user index), independent of bucket granularity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gregorydemay gregorydemay added this pull request to the merge queue Jul 1, 2026
Merged via the queue into main with commit f6d1bdd Jul 1, 2026
18 checks passed
@gregorydemay gregorydemay deleted the gdemay/DEFI-2901-fill-store branch July 1, 2026 18:24
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.

3 participants