id: DEFI-2852 title: Expand order records with partial-fill information tags: [orders, order-status, partial-fill, query-api]
Expand order records with partial-fill information
Motivation
Today a caller cannot tell how much of a resting order has been filled. OrderRecord
carries the original quantity and a coarse status (Pending / Open / Filled /
Canceled); the only fill-derived datum anywhere is CanceledOrderInfo.remaining_quantity,
captured at cancel time. A maker order that is partially consumed but stays on the book
reports Open with no indication that any of it traded — you only learn the remaining
amount if you cancel, or see Filled once it completes. Every major spot venue exposes
filled-so-far as a first-class field (Binance executedQty, Kraken vol_exec, Coinbase
filled_size); we want the same.
We also have two overlapping read endpoints: get_order_status(OrderId) (un-scoped, any
principal, returns a bare OrderStatus) and get_my_orders (caller-scoped, paginated,
returns full OrderRecords — which already embed status). The fill information belongs
on OrderRecord, which makes get_order_status redundant once get_my_orders can also
fetch a single order by id.
Requirements
- R1 — Filled amount is reported.
get_my_ordersreturns each order’s cumulative filled amount in base-token units viaOrderRecord.filled_quantity. Remaining is derivable asquantity − filled_quantity. - R2 — Partial fill is visible. A resting order that has been partially filled reports
0 < filled_quantity < quantityandstatus == Open. - R3 — Full fill. A fully filled order reports
filled_quantity == quantityandstatus == Filled. - R4 — Pending. An order not yet matched reports
filled_quantity == 0. - R5 — Cancel retains the fill. A canceled order reports the
filled_quantityaccumulated before cancel (unchanged by the cancel);statusis the unit variantCanceled. Remaining-at-cancel isquantity − filled_quantity. - R6 — Point lookup, owner-scoped.
get_my_orderswith theById(id)filter returns the single matchingUserOrderwhen the order belongs to the caller, and an empty result otherwise (unknown id, or an order owned by another principal). The two filter modes are mutually exclusive by construction (ByIdcarries no cursor;ByPagecarriesafter/length), so no page parameter is interpreted in lookup mode. - R7 —
get_order_statusremoved. Theget_order_statusendpoint no longer exists in the canister interface;OrderStatusno longer has aNotFoundvariant. Absence from aget_my_ordersresult is the sole signal that an order does not exist / is not the caller’s. - R8 — One stable-memory write per order per batch. Recording a matching event writes
each affected order’s record at most once, folding its status transition, its accumulated
fill delta, and its
last_updated_atinto a single read-modify-write. - R9 — Invariant and durability.
filled_quantityis monotonic non-decreasing and never exceedsquantity. This is enforced by an always-on check that traps on violation (aBUG:panic, per the canister’s existing convention) — not adebug_assert!, since the canister ships in release wheredebug_assert!is compiled out — in addition tochecked_addfor overflow.filled_quantityis persisted in the stable-memory order history, survives canister upgrade, and the matching write path staysWrite-gated so event-log replay does not double-count fills. - R10 — Order timestamps.
OrderRecordexposescreated_at(renamed fromtimestamp, set once at placement) andlast_updated_at: Option<Timestamp>.last_updated_atisNoneuntil the order is first modified, then carries the timestamp of the most recent modifying event (a fill, a status transition, or a cancel).
Non-goals
- Richer fill analytics. Average fill price, quote-filled value, total fees, and fill
count (the Coinbase/Kraken-style fields) are out of scope; only base
filled_quantityis added. They can layer on later as furtherOrderRecordfields without disturbing this work. - A
PartiallyFilledstatus variant. Partialness is expressed by thefilled_quantityfield againstquantity, not by splitting the resting state acrossOpen/PartiallyFilled— see Design Decisions. - Cross-account / global order lookup. Removing
get_order_statusmakes lookup-by-id owner-scoped; querying an order you do not own is no longer possible (accepted). - A stored
remaining_quantity. Remaining is always derived (quantity − filled_quantity), never persisted.
Design Decisions
-
Filled amount is a flat field on
OrderRecord, not anOrderStatusvariant.statusstays a pure lifecycle enum; how much has traded is orthogonal data that applies in several states (a resting order, a canceled order). This mirrors Kraken (vol_exec) and Coinbase (filled_size) and keeps the engine’s existingOpen/Filledtransitions unchanged. (Why not aPartiallyFilledvariant — see Discussed Alternatives.) -
Persist
filled_quantity; do not compute it by joining the live book at query time. The system is pre-launch, so the breaking record-format change carries no migration cost, and persisting keeps the read path a pure history scan —get_my_ordersnever has to reach into order-book internals to reconstruct a number. (Why not the query-time join — see Discussed Alternatives.) -
Consolidate point lookup into
get_my_orders; removeget_order_status. Fill information lives onOrderRecord, whichget_my_ordersalready returns, so a single caller-scoped endpoint subsumes the bare status query. The argument becomes an optionalById | ByPagefilter variant rather than a flat field set, so the two modes are mutually exclusive by construction (no ambiguous “id + cursor” combinations). Lookup-by-id becomes owner-scoped as a consequence (accepted; see Non-goals). -
Drop
NotFoundandCanceledOrderInfo. With lookup folded intoget_my_orders, not-found is signalled by absence from the result vector, soOrderStatus::NotFounddisappears. Withfilled_quantitypersisted, remaining-at-cancel is derivable, soCanceledOrderInfodisappears andCanceledbecomes a unit variant. -
Aggregate fill deltas in the heap, then write each order once. A single batch can fill one order across many
Fills (a taker sweeping several makers; a maker hit repeatedly), and an order can both change status and accrue fills in the same batch. Summing per-order deltas in plain memory first, then doing one read-modify-write per touched order, minimizes stable-memory roundtrips (R8).
Implementation
Constraints
The canister is event-sourced. Order records live in OrderHistory, backed by a
stable-memory StableBTreeMap (canister/src/order/history). State::record_matching_event
applies a matching event to the live OrderBook and, only under
StableMemoryOptions::Write, reflects the result into OrderHistory; post-upgrade replay
runs with Skip, since the stable map is preserved across upgrades. All new persistence
must respect that Write gate so replay does not re-apply it.
Types — libs/types and canister/oisy_trade.did
OrderRecordgainsfilled_quantity: Nat(cumulative base-token amount filled), renamestimestamptocreated_at, and gainslast_updated_at: Option<Timestamp>(R10).OrderStatusdropsNotFound;Canceledbecomes a unit variant. Resulting set:Pending,Open,Filled,Canceled.CanceledOrderInfois removed.GetMyOrdersArgscarries a single non-optionalfilterthat is either a point lookup or a page, replacing the flatafter/lengthpair. The endpoint takesopt GetMyOrdersArgs; an absent argument is the default (first page, newest first).get_order_statusis removed from the canister interface.
The candid surface (the repo’s candid equality check must pass):
// The endpoint arg is optional: `get_my_orders : (opt GetMyOrdersArgs) -> ...`.
// An absent argument defaults to the first page, newest first.
type GetMyOrdersArgs = record {
filter : GetMyOrdersFilter;
};
type GetMyOrdersFilter = variant {
ById : OrderId;
ByPage : record { after : opt OrderId; length : nat32 };
};
type OrderRecord = record {
owner : principal;
side : Side;
price : nat;
quantity : nat;
filled_quantity : nat;
status : OrderStatus;
created_at : nat64;
last_updated_at : opt nat64;
};
type OrderStatus = variant { Pending; Open; Filled; Canceled };
length is capped at MAX_ORDERS_PER_RESPONSE as today; an absent argument is equivalent
to ByPage { after = null; length = MAX_ORDERS_PER_RESPONSE }. A malformed OrderId (in
either ById or ByPage.after) is rejected exactly as the current id/cursor parsing does
(trap), so behavior is consistent across the endpoint.
Internal order record — canister/src/order
- The internal
OrderRecord(order/history) gainsfilled_quantity: Quantityandlast_updated_at: Option<Timestamp>as new trailing minicbor fields (append-only indices; never reuse), and renamestimestamptocreated_at. - The internal
OrderStatus(order/mod.rs)Canceledvariant becomes unit; theCanceledOrderInfostruct is removed. OrderHistoryreplaces theset_status-only writer with a single combined writer,apply_update(&OrderId, OrderUpdate, now: Timestamp), whereOrderUpdate { status: Option<OrderStatus>, filled_delta: Quantity }. It does oneget+ oneinsert, applying the status (if present), adding the delta viachecked_add, and settinglast_updated_at = Some(now). It enforcesfilled_quantity <= quantitywith an always-on check that traps on violation (aBUG:panic, matching the codebase’sexpect("BUG: …")convention) — not adebug_assert!, which is compiled out of the release canister and would let a corrupted value persist silently (R9).
Matching write path — canister/src/state (record_matching_event)
Under the existing Write gate, replace the compute_order_status_transitions +
set_status loop with: build a heap BTreeMap<OrderSeq, OrderUpdate> from the batch
output — for each fill in output.fills, add fill.quantity to both
fill.taker_order_seq and fill.maker_order_seq; for each seq in output.resting_orders
set status = Open; for each in output.filled_orders set status = Filled — then call
apply_update(.., now) once per entry, where now is the matching Event’s timestamp.
output.fills already carries every maker/taker pair and per-fill quantity, so no
order-book/MatchingOutput changes are needed. This is the only site that catches a maker
partially filled while it stays open (which produces no status transition today). The event
timestamp is already available to apply_state_transition (state/audit) and is threaded
into record_matching_event (and record_cancel_limit_order) for last_updated_at.
Cancel path — canister/src/state (record_cancel_limit_order)
Writes the unit Canceled status (with last_updated_at set to the cancel event’s
timestamp). The cancel flow still reads remaining_quantity from the book removal
(book.remove_order) — it is required to compute the unreserve/refund amount, unchanged.
What goes away is only its persistence in history: with CanceledOrderInfo removed,
remaining_quantity is no longer stored on the record (remaining is derived from quantity − filled_quantity).
Endpoint — canister/src/lib.rs, canister/src/main.rs
get_my_orders: an absent argument defaults toGetMyOrdersArgs::default(); then matchargs.filter.ById(id)→ resolve the caller’sUserIdand return the single owned record as a one-elementvec(empty if the id is unknown or owned by another principal).ByPage { after, length }→ the existing newest-first cursor scan. The default filter isByPage { after = None, length = MAX_ORDERS_PER_RESPONSE }.- Remove
get_order_status(business fn inlib.rs, the#[ic_cdk::query]wrapper inmain.rs, andstate::get_order_statusif otherwise unused).
Test plan
Unit (*/tests.rs, helpers/fixtures per repo convention):
order/history/tests.rs:apply_updateapplies status-only, delta-only, and status+delta in a single write and setslast_updated_attonow(R8, R10); thefilled_quantity > quantityinvariant traps in release build config (an always-on check, not a compiled-outdebug_assert!) (R9).state/tests.rs: a batch that partially fills a maker advances itsfilled_quantitywithout a status transition (R2); a fully filled order reachesfilled_quantity == quantity+Filled(R3); cancel-after-partial keepsfilled_quantityand writes unitCanceled(R5); a fill spanning multipleFills for one order writes that order once (R8);created_atis unchanged after fills whilelast_updated_atadvances (R10). Replay underSkipleavesfilled_quantityuntouched (R9).
Integration (integration_tests/tests/tests.rs, PocketIC):
- Place a maker, partially fill it with a crossing taker, then
get_my_ordersshows0 < filled_quantity < quantity,Open(R2); complete the fill →filled_quantity == quantity,Filled(R3). get_my_orderswithById(id)returns the single owned order; returns empty for an unknown id and for an id owned by a different principal;ByPage/absent filter pages as before (R6).- Cancel a partially filled order →
Canceled,filled_quantitypreserved (R5). - Existing tests that called
get_order_statusare migrated toget_my_orders(R7).
Verification:
cargo fmt --all
just lint
cargo test -p oisy_trade_canister
cargo test -p oisy_trade_int_tests
# + the repo's candid equality check (see justfile / CI)
Delivery / PR sequence
A single PR. The filled_quantity field and the write path that populates it are
inseparable — shipping the field without the write path would expose an always-zero value,
and the endpoint consolidation depends on the field existing. Acceptance: all of R1–R10.
(If review size warrants, this could split into “types + endpoint consolidation + always-zero field” then “matching/cancel write path”, but the field is vacuous until the second lands, so one PR is preferred.)
Discussed Alternatives
-
A
PartiallyFilledOrderStatus variant (Binance style). Rejected: it splits the single “resting on the book” concept acrossOpenandPartiallyFilled, while the matching engine marks every resterOpen— the distinction would have to be recomputed at the response boundary anyway. A flat field expresses partialness without touching the lifecycle enum and generalizes to the canceled case. -
Compute filled at query time by joining the live order book. The book’s
resting_ordersindex makes a resting order’s remaining quantity reachable by id, so filled could be derived asquantity − live_remainingwithout persisting anything. This was the simpler option when migration was a concern — but the system is pre-launch, so there is no migration to avoid, and the join couplesget_my_ordersto order-book internals and only works while the order is still live. Persisting keeps reads a pure history scan and a single source of truth on the record. -
Keep
get_order_statusfor un-scoped lookup. Rejected: nothing requires looking up an order you do not own, and folding the lookup intoget_my_ordersremoves a redundant endpoint and theNotFoundvariant. Owner-scoped lookup is the accepted consequence.