id: DEFI-2853 title: Fill-or-Kill (FOK) limit orders tags: [orders, time-in-force, matching-engine, fees]
Fill-or-Kill (FOK) limit orders
Motivation
Today every limit order is implicitly Good-Til-Canceled (GTC): it rests in the book until filled or canceled and may fill partially over time. This is the right primitive for market-making, but the wrong one for swap-style UX. A swap user expects atomic semantics — “give me exactly X for at least Y, all at once, or don’t touch my funds” — which is exactly what the Oisy integration needs.
Fill-or-Kill (FOK) adds that primitive. It is not a new order type; it is a
time_in_force value on the existing limit order:
- GTC (current default): rests until filled or canceled; partial fills allowed.
- FOK (new): when the matching engine executes the order, the entire quantity must fill against resting liquidity at the order’s price or better, otherwise the whole order is killed with zero execution. No resting, no partial fill.
FOK is the matching-engine half of the swap story; the value to the client is a terminal “filled or killed” outcome rather than a resting order it has to manage.
Requirements
- R1 — Optional TIF, backwards compatible.
LimitOrderRequestaccepts an optionaltime_in_force. An absent value defaults toGoodTilCanceled, so every existing client keeps working unchanged. - R2 — GTC unchanged. A
GoodTilCanceledorder (explicit or defaulted) behaves exactly as today: it may rest, may fill partially, and reachesOpen/Filled/Canceledthrough the existing transitions. No GTC observable behavior changes. - R3 — FOK full fill. A FOK order whose full
quantitycan be satisfied against resting liquidity at its price or better fills completely and reachesstatus == Filledwithfilled_quantity == quantity. It never rests in the book. - R4 — FOK kill. A FOK order whose full quantity cannot be satisfied reaches
status == Expiredwithfilled_quantity == 0, leaves no trace in the book (it never rests), and moves no balances (the reservation taken at placement is fully released). - R5 — No partial FOK. The “some liquidity but not enough” case is killed exactly like the
“no liquidity” case:
Expired,filled_quantity == 0. FOK never settles a partial fill. - R6 — FOK pays taker; its counterparty keeps the maker rate. A FOK never rests, so in every
fill it produces it is the crossing (taker) side and pays
FeeRates.taker, regardless of pair. The resting order it crosses is the maker and keepsFeeRates.maker— choosing FOK must not penalize the liquidity provider it fills against. This falls out of the existing resting-side→maker / crossing-side→taker assignment with no FOK-specific fee logic (see Fee logic). - R7 — GTC fees unchanged. GTC fee assignment is untouched: the resting side of a fill pays the maker rate, the incoming (crossing) side pays the taker rate, exactly as today.
- R8 —
Expiredis distinct fromCanceled.OrderStatusgains a new unit variantExpired, surfaced in the public Candid type.Canceledkeeps its current meaning — explicit cancellation initiated by a user or an administrator (cancel_limit_order, admin sweep) — andExpiredis reserved for system-initiated, time-in-force-driven terminations. This ticket produces exactly one such case (a FOK that cannot fully fill); a future IOC would terminate asExpiredtoo. A client can distinguish “I changed my mind” (Canceled) from “the engine couldn’t honor my time-in-force” (Expired). - R9 — TIF is durable and observable. An order’s
time_in_forceis recorded on its order record, surfaced onOrderRecordin the query API, and round-trips through the state snapshot and event-log replay. The matching engine can always determine an order’s TIF when it evaluates it. - R10 — Bounded cost. A FOK is evaluated atomically and cannot be chunked across messages, so
its cost must fit one message. The work is bounded by the number of resting orders it crosses:
the plan pass is a flattened iteration over the crossing
BTreeMap<_, VecDeque<_>>levels, and apply + settlement is one step per fill. The worst case — a single large FOK that sweeps one fully-populated, fragmented side of the book — is covered by a canbench benchmark that must stay within the per-message instruction limit. - R11 — Async outcome, never
Open.add_limit_orderenqueues a FOK and returns anOrderIdexactly as it does for a GTC order — it does not block on the matching result. The FOK is evaluated when the matching engine processes it, transitioning onlyPending → FilledorPending → Expired; it never reachesOpen. The caller observes the terminal outcome viaget_my_orders.
Non-goals
- IOC (Immediate-Or-Cancel). Conceptually the sibling of FOK (same
TimeInForceenum, same always-taker fee, sameExpiredterminal state — see R8) but it does allow partial fill of the available quantity. Deferred to a follow-up; it reuses the same plan/execute matching this ticket introduces — applying whatever crosses, then canceling the unfilled remainder. That is a third remainder disposition alongside GTC’s rest and FOK’s require-full-or-kill, not simplyrequire_full = false(which is the GTC path, and rests the remainder). LIMIT_MAKER/ post-only. The opposite end of the TIF spectrum (reject if it would cross). Separate scope.- Self-Trade Prevention and
EXPIRED_IN_MATCH. STP is not in scope, so there is no distinct STP-driven expiry sub-case to model;Expiredhere means only “FOK could not fully fill”. - Changing GTC semantics or the FIFO matching order of existing orders beyond what the execution-model decision strictly requires.
Design Decisions
Execution model — asynchronous FOK
A FOK order queues as Pending exactly like a GTC order; the existing timer-driven executor
evaluates it when it pulls the order for matching, transitioning it only Pending → Filled or
Pending → Expired — never Open, since a FOK never rests.
Rationale. Time-in-force governs how long an order stays active in the book — and
add_limit_order does not put the order in the book. It lands in a pre-processing (pending)
queue and only reaches the book when the matching engine processes it. So the correct moment to
evaluate “can this fill in full” is when the engine pulls the order for matching, not at the
Candid call. This is also exactly Binance’s wording — a FOK “will expire if the full order
cannot be filled upon execution” — and it keeps FOK on the same execution path, FIFO ordering,
and message-chunking as every other order: no second matching entry point, no per-call
instruction-bound special case.
Matching is asynchronous and timer-driven: add_limit_order validates the order, reserves
balance, enqueues it as Pending, and returns an OrderId; a separately-scheduled zero-delay
timer (drive_matching) then matches it in a later message (docs/src/development/design.md,
Matching Engine). FOK reuses this unchanged; the caller observes the terminal Filled /
Expired outcome via get_my_orders.
Time-to-evaluation is best-effort, not immediate. The executor schedules books by pending
backlog, so under sustained load on busier books a FOK in a quieter book can wait — and while it
waits it holds its placement reservation. This is a pre-existing, non-FOK-specific property of
the cross-book scheduler (it delays GTC too); scheduling fairness is tracked separately in
DEFI-2899 and is out of scope here. What this ticket pins down is the meaning:
time_in_force is a constraint on how long an order may rest in the book, evaluated when the
engine processes it — not measured from submission. (Of Binance/Kraken/Coinbase, only Coinbase is
explicit that it is measured from submission; we adopt the rest-in-book reading.) (Why not
synchronous inline matching — see Discussed Alternatives.)
Split matching into a read-only plan and a mutating execute
OrderBook::match_order today fills greedily and mutates the book as it goes (it reduces
resting quantities and pops fully-consumed makers in fill_against_queue, and rests any
remainder at the tail). So “fill fully or do nothing” cannot be expressed by calling
match_order and reacting to a PartiallyFilled result — by then the book is already mutated
and partial fills already exist.
We therefore restructure matching into two phases. plan_fills walks the crossing prices
read-only and records the fills it would make (maker_seq, fill price, quantity, and whether
the maker is emptied) plus whether the order fully fills — touching no book state.
apply_plan then replays that plan, performing the mutations. A single parameter,
require_full, gates the two: when it is set and the plan does not fully fill, execute returns
a killed outcome before apply_plan runs, so the book is provably untouched. GTC calls
execute(order, require_full = false) and behaves exactly as today; FOK calls
execute(order, require_full = true).
Chosen over the two narrower alternatives (a non-mutating liquidity pre-check that then reuses
the unchanged match_order, and an operation-log rollback that undoes a partial match — both
in Discussed Alternatives) because plan/execute makes the no-mutation-on-kill guarantee
structural rather than test-enforced, keeps a single matching traversal with no duplicated
crossing predicate, and generalizes to other time-in-force values: the time-in-force selects
what happens to the taker’s unfilled remainder after whatever crossed has been applied — GTC
rests it, FOK requires the whole order to fill or kills it (require_full), and a future IOC
would cancel it — all on the same single plan/apply traversal. The cost is a constant-factor
second pass on the GTC hot path (see Performance).
Expired is a unit variant, matching the current Canceled
The ticket drafted Canceled(CanceledOrderInfo) and Expired(ExpiredOrderInfo), but
DEFI-2852 has since landed and made Canceled a plain unit variant (and removed
NotFound): OrderStatus = { Pending, Open, Filled, Canceled }. Per-order fill data now lives
in flat OrderRecord fields (filled_quantity, last_updated_at), not in a status payload.
Expired follows that established shape — a unit variant — and a killed FOK is simply a
record with status == Expired and filled_quantity == 0. No ExpiredOrderInfo struct.
Dependencies are satisfied on current main
The ticket lists DEFI-2848 (price encoding) and DEFI-2850 (min/max notional) as prerequisites;
both, plus DEFI-2852 (order status), are merged. The scaled-settlement math
(Price::checked_mul_quantity_scaled, Fill::quote_amount) and the shared notional gate
(OrderBook::check_notional, called from State::validate_limit_order) are present, so FOK
orders pass through the same tick/lot/notional/balance validation as GTC with no extra work
beyond confirming the path is shared (R-coverage in the test plan).
Implementation
Constraints
- The canister is event-sourced: an
AddLimitOrderEventis recorded viastate::audit::process_eventand re-applied on replay; matching results are applied byState::record_matching_eventonly underStableMemoryOptions::Write(replay runsSkip). Any new persistence (the TIF field, theExpiredtransition) must flow through this chain and respect theWritegate so replay does not double-apply. In particular,AddLimitOrderEventitself must carrytime_in_force(append-only minicbor field, defaulting toGoodTilCanceledfor pre-existing events): a FOK logged asPendingand replayed beforeprocess_pending_ordersruns must reconstruct as a FOK, not default to GTC and rest (R9). - Matching/settlement are synchronous and free of inter-canister calls, but bounded by the per-message instruction limit; the timer-driven model chunks GTC matching to stay within it. A single FOK is atomic and cannot be chunked (R10).
OrderStatusis shared between the internal engine (canister/src/order/mod.rs) and the public Candid type (libs/types/src/lib.rs); both must gainExpired.
Public types & Candid — libs/types/src/lib.rs, canister/oisy_trade.did
- New
TimeInForceenum:GoodTilCanceled,FillOrKill. LimitOrderRequestgainstime_in_force: Option<TimeInForce>(absent ⇒GoodTilCanceled, R1). Candid:time_in_force : opt TimeInForce.OrderStatusgains the unit variantExpired(R8). Candid:variant { Pending; Open; Filled; Canceled; Expired }.OrderRecordgainstime_in_force: TimeInForce(R9).- The candid equality check must pass (
oisy_trade.didregenerated/updated).
Order model — canister/src/order
PendingOrder/Ordercarrytime_in_force;PendingOrder::try_from(LimitOrderRequest)reads the optional field, defaulting toGoodTilCanceled.- Internal
OrderStatus(order/mod.rs) gainsExpired(next minicbor index). - Internal
OrderRecord(order/history) gainstime_in_forceas a new trailing minicbor field (append-only index; never reuse) so it round-trips through history and snapshot (R9).
Plan/execute matching — canister/src/order/book.rs
Refactor match_order into a read-only plan and a mutating apply, joined by a single
execute(order, require_full) entry point. The refactor is behavior-preserving for GTC.
plan_fills(side, price, quantity) -> FillPlan— read-only. Iterates the crossing price levels best-first (asksascending whileprice ≤ order price;bidsdescending whileprice ≥ order price— the same crossing predicate as today’smatch_orderbreak) and, FIFO within each level, records onePlannedFill { maker_seq, maker_price, fill_qty, maker_emptied }per maker it would touch, accumulating until the order is satisfied. ReturnsFillPlan { fills, fully_filled }. No mutation.apply_plan(side, &FillPlan, &mut Order, &mut fills_out, &mut filled_orders)— mutating. Replays the plan: for eachPlannedFill, reduce the maker (held at the front of its level — re-acquire the level cursor only whenmaker_pricechanges, so cost staysO(L log p + f), notO(f log p)), reduce the taker, push theFill, and onmaker_emptiedpop the maker, drop itsresting_ordersindex entry, insert intofilled_orders, and remove the level if its queue empties. An always-on check (expect("BUG: …")/assert_eq!, matching the codebase convention — notdebug_assert!, which the release canister compiles out, per the DEFI-2852 invariant convention) asserts the maker at the level front matchesplanned.maker_seq, trapping on any plan/apply divergence rather than corrupting the book.execute(order, require_full) -> Execution—let plan = plan_fills(..); ifrequire_full && !plan.fully_filledreturnExecution::Killed { seq }before any mutation (R4, R5); elseapply_plan(..)and, per the existing tail, returnFilledwhen the remainder is zero or rest it (insert_order) and returnResting.match_orderbecomesexecute(order, false)(GTC unchanged, R2); FOK isexecute(order, true).
MatchingOutput gains expired_orders: BTreeSet<OrderSeq> alongside fills / resting_orders
/ filled_orders. process_pending_orders runs the per-order loop — pop_front, execute with
require_full derived from the order’s time_in_force, and on Killed insert into
expired_orders (the order, already popped, leaves no book trace) (R3, R4, R5, R10).
Fee logic — canister/src/state (compute_balance_operations)
Fees are assigned per fill from fill.taker_side (resting side → maker rate, crossing side →
taker rate). No FOK-specific fee logic is needed: a FOK never rests, so it is always the
crossing side of any fill it produces — the existing assignment already bills it FeeRates.taker
and leaves the resting counterparty on FeeRates.maker (R6). Billing the resting maker the taker
rate merely because the crosser chose FOK would penalize the liquidity provider and is not
intended; the ticket’s fee_rate_for_fill sketch that returned the taker rate for both legs is
dropped. GTC assignment is unchanged (R7). The only fee work is a test asserting a FOK fill bills
the FOK side the taker rate and its resting counterparty the maker rate.
Execution wiring — canister/src/execute, canister/src/state (record_matching_event)
add_limit_order is unchanged: it validates, reserves balance, and enqueues the FOK as
Pending, returning an OrderId (R11). The plan/execute branch lives entirely in the order book
(above); record_matching_event consumes the resulting MatchingOutput, mapping each
expired_orders entry to status = Expired and releasing the reservation taken at placement. A
FOK never transitions to Open; GTC keeps its Pending → Open / Filled transitions. The
reservation-release on kill reuses the same unreserve/refund computation the cancel path already
performs over the order’s full quantity (it never entered the book, so there is no RemovedOrder
to read) (R4, R5).
Performance
The plan/execute refactor costs GTC a constant-factor second pass: today’s single fused
matching pass becomes a read-only plan_fills walk plus an apply_plan replay, plus one
transient O(f) FillPlan allocation (f = fills). Asymptotics are unchanged at
O(L log p + f) provided apply_plan holds the level cursor across a level’s fills rather
than re-looking-up by price per fill. The non-crossing/rest case is negligibly affected
(plan_fills stops at the first non-crossing level). The overhead is expected to be small
relative to the settlement that follows in record_matching_event (per-fill fee math and
balance operations, plus stable-memory writes), but it falls on the hot path — confirm with the
canbench suite (the bench_scopes! instrumentation already in lib.rs) before relying on the
estimate.
Docs — docs/src/development/design.md
Document the time_in_force field, the asynchronous execution model (FOK is evaluated when the
engine processes it — “upon execution” — and so transitions Pending → Filled or
Pending → Expired and never rests), and the Canceled (user-initiated) vs Expired
(system-initiated FOK kill) distinction. (R8, plus the AC requiring the design doc to record the
model.)
Test plan
Unit (*/tests.rs, fixtures in canister/src/test_fixtures):
order/book.rstests:- GTC regression / plan==apply: a property test over arbitrary books + orders asserts
execute(order, false)produces the identical fills, resting state, and final book as the pre-refactormatch_order— i.e. the refactor is behavior-preserving (R2). The always-on plan/apply-divergence check inapply_planholds throughout. - Kill is mutation-free: when
plan_fills(..).fully_filledisfalse,execute(order, true)returnsKilledand the book is byte-identical to its pre-call state — compared via theOrderBookSnapshotround-trip (noPartialEqonOrderBookneeded) (R4, R5). Covers the some-but-insufficient liquidity case explicitly (R5). - Boundary + fill: available liquidity exactly equal to
quantity⇒Filled(inclusive); a fillable FOK producesFilledwith the expected fills (R3).
- GTC regression / plan==apply: a property test over arbitrary books + orders asserts
canister/src/benchmarks.rs(canbench): a worst-case FOK that sweeps one fully-populated, fragmented side of the book in a single message, asserting it stays within the per-message instruction limit (R10).state/tests.rs: a FOK that fully fills recordsFilled/filled_quantity == quantityand releases nothing extra (R3); a FOK that can’t fill recordsExpired/filled_quantity == 0, no book trace, reservation released (R4, R5); a FOK fill bills the FOK side the taker rate and its resting counterparty the maker rate (R6); GTC fee assignment unchanged — maker when resting, taker when crossing (R7); defaulted TIF isGoodTilCanceled(R1, R2).order/history+state/snapshottests:time_in_forceround-trips through the record and a snapshot; replay underSkipdoes not re-settle a FOK (R9).
Integration (integration_tests/tests/tests.rs, PocketIC) — these encode the acceptance
criteria end-to-end:
- FOK against sufficient resting liquidity ⇒
Filled,filled_quantity == requested(R3). - FOK against no liquidity ⇒
Expired,filled_quantity == 0, no resting trace (R4). - FOK against insufficient liquidity (some, but < quantity) ⇒
Expired,filled_quantity == 0(R5 — the no-partial guarantee). - Fee on a FOK fill: the FOK side is billed the taker rate and its resting counterparty the maker rate, on an asymmetric-decimal pair (R6).
- GTC fees unchanged: maker-if-resting, taker-on-immediate-cross (R7).
Expiredis distinct fromCanceledin the Candid surface; a user cancel still yieldsCanceled(R8).- Absent
time_in_forcebehaves as GTC (R1, R2).
Verification:
cargo fmt --all
just lint
cargo test -p oisy_trade_canister
cargo test -p oisy_trade_int_tests
# + the repo's candid equality check (see justfile / CI)
Delivery / PR sequence
A stack, each PR independently compilable/testable, isolating the behavior-preserving refactors
from the FOK feature itself. The plan/execute PR sits on a separately-extracted,
behavior-preserving OrderQueue storage refactor — its own base PR off main — so each layer
reviews on its own. The FOK delivery proper is the three PRs below.
- PR 1 (1/3) — plan/execute refactor (pure implementation detail). Introduce
plan_fillsandapply_plan, rewritematch_orderas plan-then-apply, and add the always-on plan/apply-divergence check. No new public types, no behavior change, no new requirement — this is purely how matching is structured internally. The acceptance signal is that every existing order-book/matching test passes unmodified — that is the behavior-preservation proof; no parallel reference-matcher test is needed. Therequire_fullgate,Killedoutcome, andexpired_ordersare not introduced here (they have no caller yet and would be dead code) — they arrive with the FOK wiring in PR 3.- Acceptance: existing matching tests green without edits (behavior preserved, R2).
- PR 2 (2/3) — FOK data model.
TimeInForceenum; optionaltime_in_forceonLimitOrderRequestdefaulting to GTC;OrderStatus::Expired(internal + public + Candid);time_in_forceon the order model, record, snapshot,AddLimitOrderEvent, andOrderRecord. No fee code: R6 needs none (a FOK is always the crossing/taker side, so the existing fee assignment already bills it the taker rate — see Fee logic). Types are defined and surfaced but FOK is not yet routed through matching.- Acceptance: R1, R2, R7, R8, R9. (R6 is satisfied structurally; its fill test lands in PR 3 with the routing.)
- PR 3 (3/3) — FOK matching gate + execution wiring. Add the
require_fullgate toexecute(theKilledoutcome) andexpired_orderstoMatchingOutput; driverequire_fullfromtime_in_forceinprocess_pending_orders; mapexpired_orderstoPending → Expiredplus reservation release inrecord_matching_event; updatedesign.md; add unit + end-to-end integration tests.- Acceptance: R3, R4, R5, R6, R10, R11, and the design-doc AC.
Discussed Alternatives
- A separate
add_fok_orderendpoint. Rejected (per the ticket): the only thing that varies is the TIF enum value, so a second endpoint would just duplicate the entire tick/lot/notional/balance validation path and create a second place to keep it in sync. One endpoint, one extra optional field, mirrors Binance/CEX convention. - A payload-carrying
Expired(ExpiredOrderInfo)(the ticket’s draft). Rejected: DEFI-2852 already moved per-order fill data to flatOrderRecordfields and madeCanceleda unit variant. A unitExpiredis consistent with the current model; the “executedQty == 0” property is expressed by the existingfilled_quantityfield, not a status payload. - Synchronous inline FOK (match within the
add_limit_ordercall). This would match a FOK against the live book inside the update call and return the terminal outcome in one round-trip (closer to Coinbase’s “filled immediately at submission”). Rejected: time-in-force describes how long an order stays active in the book, and the Candid call does not put the order in the book — it enqueues it for pre-processing. Evaluating “fill or kill” at the call would conflate submission with book-entry. It would also add a second matching entry point, jump the FOK ahead of GTC orders already queued (breaking FIFO), and make the per-message instruction bound a hard gate because a single atomic FOK cannot be chunked. The asynchronous model (Binance’s “upon execution”) avoids all of this; the only cost is that the caller pollsget_my_ordersfor the outcome instead of reading it from the call result. - Non-mutating liquidity pre-check, then reuse the unchanged
match_order. A read-only walk summing crossing liquidity gates a kill; if it passes, run today’smatch_order(guaranteedFilled). More surgical — it leaves the GTC hot path at exactly one pass (zero regression) and pays the second walk only on FOK; a killed FOK is a single cheap read-only walk. Rejected for this ticket in favor of the broader plan/execute refactor: the pre-check’s no-mutation guarantee is by avoidance (it depends on the walk’s crossing predicate staying in lockstep withmatch_order’s, a divergence risk closed only by a property test), and it does not generalize to IOC, which needs to commit a partial — exactly what plan/execute’sapply_planprovides. We accept a constant-factor GTC cost (see Performance) to get the structural guarantee and the IOC-ready shape. - Operation-log rollback. Run the unchanged
match_orderwhile journaling each mutation onto a stack, then pop-and-invert the stack if a FOK did not fully fill. Rejected: it carries the largest correctness surface of the options (every mutating primitive must emit a correct, correctly-ordered inverse, or a kill silently corrupts the book), and its performance profile is backwards for this feature — it makes the kill path the most expensive (full mutate, then full reverse with index/level churn) when a kill (“insufficient liquidity”) is exactly the case that should be cheap and side-effect-free. It also yields no benefit for a future IOC, which keeps its partial and never rolls back. - A dedicated
PartiallyFilled/ richer FOK-specific status set. Out of scope and unnecessary: FOK only ever reachesFilledorExpired, both of which already exist (or are added as the singleExpiredunit variant).