RAIN CLOB — Matching Rules Specification (DRAFT v0.1)
Status: DRAFT for review. This spec is normative: the engine is tested against this document. Any divergence between code and spec is a bug in one of them, and the spec wins until amended. Audience: engine implementers, auditors, and market makers who need to model queue position exactly. Scope: one order book per market per outcome side, trading fully collateralized binary outcome tokens against USDC collateral . All matching is on-chain .
Keywords MUST / MUST NOT / REJECT are normative.
1. Primitive Types & Determinism
| Quantity | Type | Semantics |
|---|---|---|
price | u64 | Integer ticks. price_in_collateral = price × tick_size. Tick size is a per-market immutable parameter set at market creation. For binary markets, valid prices are 1 ..= max_tick − 1 (exclusive bounds: 0 and 1.0 outcome-certainty prices are not quotable). |
qty | u64 | Integer lots. qty_in_tokens = qty × lot_size. Per-market immutable parameter. |
seq | u64 | Global, per-market, strictly monotonically increasing arrival sequence number, assigned by the engine at the moment an operation is accepted into the engine (§3.2). Never reused, never reset. |
order_id | u64 | Engine-assigned, unique per market, equals the seq of the accepting placement. |
client_order_id | u128 | Caller-supplied opaque tag. See §10.3 for uniqueness rules. |
D1 — Integer-only arithmetic. All matching arithmetic MUST use unsigned integer types (u64/u128 intermediates). Floating point MUST NOT appear anywhere in the engine, including fee math (fees use integer basis points with explicitly specified rounding, §8).
D2 — Total determinism. Given the same initial state and the same totally ordered input sequence, the engine MUST produce byte-identical state and an identical event sequence. There are no clocks, no randomness, no iteration over unordered collections.
D3 — Rounding. Where division occurs (fees only; matching itself never divides), rounding is floor (round toward zero) and the remainder accrues to the protocol treasury. This MUST be specified per formula in §8.
2. Order Types, Flags, Time-in-Force
2.1 Order types
- Limit:
(side, price, qty, tif, flags, client_order_id). - Market:
(side, qty, tif ∈ {IOC, FOK}, flags, client_order_id). A market order has no price and can never rest.tif = GTCwith a market order → REJECT (INVALID_TIF).
2.2 Flags
post_only(limit only): if, at execution time, the order would cross the opposite best (i.e., would take liquidity for any quantity), the entire order is REJECTED (POST_ONLY_WOULD_CROSS). It is NOT re-priced, NOT partially posted, NOT silently converted.
Justification for REJECT over re-price: re-pricing ("book-or-cancel at adjusted price") puts the maker at a price they did not quote, which breaks exact queue-position modeling and creates hidden adverse fills for MM systems that assume their stated price or nothing. REJECT is the unambiguous, modelable semantic; the MM's own software decides how to respond. This also keeps the on-chain code path minimal (no re-quote loop, fewer gas branches).
reduce_only: the order may only decrease the account's net position in the outcome token on that side. At execution, the matchable quantity is clamped tomax(0, current_reducible_amount); if the clamp yields 0 at acceptance time, REJECT (REDUCE_ONLY_NO_POSITION). If a resting reduce-only order would, due to position changes from other fills, exceed the remaining reducible amount, the engine MUST automatically reduce its open quantity (emittingOrderReduced) or cancel it when reducible amount reaches 0 (emittingOrderCanceled{reason: REDUCE_ONLY_AUTO}). The clamp is evaluated at every position-changing event for that account, inseqorder.post_only+ market order → REJECT (INVALID_FLAG_COMBINATION).post_only+ IOC/FOK → REJECT (contradiction: post-only cannot take, IOC/FOK cannot rest).
2.3 Time-in-force
| TIF | Resting allowed | Partial fills | Semantics |
|---|---|---|---|
| GTC | yes | yes | Match what crosses; remainder rests in the book. Good until canceled or market expiry (§10.9). |
| IOC | no | yes | Match what crosses now; any unfilled remainder is canceled immediately (OrderCanceled{reason: IOC_REMAINDER}). |
| FOK | no | no | Atomic feasibility check (§5.3): either the entire quantity fills in this single operation, or nothing executes and the order is canceled (OrderCanceled{reason: FOK_UNFILLABLE}). No state change besides the event. |
3. Priority & Arrival Semantics
3.1 Strict price-time priority
- Price priority: for buys, higher price matches first; for sells, lower price matches first.
- Time priority within a price level: strict FIFO by
seq. Lowerseqfills first. No size pro-rata, ever. - An order's queue position is fixed at acceptance. The ONLY operations that change priority are: (a) cancel + new placement (new
seq), (b) any quantity increase via replace (newseq, §6.3). A pure quantity decrease preservesseqand queue position.
3.2 Arrival = engine acceptance order
- The total order over all operations is defined by on-chain transaction execution order (block order, then intra-block index), then intra-transaction operation order for batches (§6).
- Each accepted operation receives the next
seq. Rejected operations consume aseqas well (the reject event carries it) — this makes the event log a gap-free total order and lets MMs detect missed events. (Flag: cheap alternative is not numbering rejects; we prefer gap-free for auditability. Confirm at review.)
3.3 Sequencer note (out of engine scope, in trust scope)
The engine is deterministic given transaction order. Transaction ordering fairness (FIFO sequencer commitment) is a Layer-2 chain policy — covered by the Phase-6 MEV review, not this spec. The spec guarantees: identical ordered input ⇒ identical fills.
4. The Matching Loop (normative algorithm)
For an incoming matchable order (limit GTC/IOC, market, FOK after feasibility pass):
while incoming.remaining > 0:
best = opposite_book.best_level()
if best is None: break # empty book
if incoming is limit and not crosses(incoming.price, best.price): break
resting = best.fifo_head()
if self_trade(incoming.account, resting.account):
apply STP policy (§7) # may consume resting and/or incoming
continue
fill_qty = min(incoming.remaining, resting.remaining)
execute fill at resting.price # ALWAYS the resting (maker) price
emit Fill (maker side, taker side) # §9
if resting.remaining == 0: remove resting (emit OrderFilled-final)
if bounded-matching budget exhausted: suspend (§5.4)
post-loop: apply TIF residue rules (§2.3) or rest remainder (GTC limit)M1 — Maker price rule. Every fill executes at the resting order's price. Price improvement always accrues to the taker.
M2 — Level exhaustion order. Levels are consumed strictly best-first; within a level strictly FIFO; a partially filled resting order keeps its position at the head of its level.
5. Fills, Thin Books, FOK, Bounded Matching
5.1 Partial fills
- A resting GTC order may be partially filled any number of times; its
seq, price, and queue position never change;remainingdecreases monotonically. - Each partial fill emits a
Fillevent (§9). The final fill additionally marks the order closed.
5.2 Market orders vs. empty/thin books
- Market order, empty opposite book: zero fills; order is canceled with
OrderCanceled{reason: NO_LIQUIDITY}. It MUST NOT rest, MUST NOT error the transaction. - Market order, thin book (book exhausts before the order's qty): fills whatever liquidity exists, remainder canceled (
IOCsemantics;reason: NO_LIQUIDITYon the remainder). There is no price protection in the engine itself in v1; takers wanting bounded execution MUST use a marketable limit order (limit + IOC). (Flag for review: optionalmax_slippage_ticksparameter is a possible v1.1 addition; excluded from v1 for gas/simplicity.)
5.3 FOK feasibility semantics
- Feasibility is evaluated against the book state at the order's
seq, after STP exclusion: sum matchable quantity over opposite levels that the order's price crosses (for market FOK: over all levels), excluding resting orders that would trigger STP against the taker (those cannot legally fill). - If feasible quantity ≥ order quantity: execute the matching loop to full completion atomically within the same operation (FOK is exempt from bounded-matching suspension; instead, FOK has a hard
max_fill_levelscap — crossing more levels than the cap → treated as infeasible,FOK_UNFILLABLE. Cap value: per-market parameter, default proposed 32. This keeps FOK gas-bounded without continuations). - If infeasible: no fills, no book mutation, single
OrderCanceled{reason: FOK_UNFILLABLE}event.
5.4 Bounded matching & continuation (deep crosses)
No single operation may exceed the block gas limit even at a book depth of 10,000 resting orders . Mechanism (the recommended option, specified here):
- Each matching operation carries a fill budget
B= max fills (maker orders consumed or partially consumed) per transaction.Bis a chain-level constant calibrated in Phase 2 so worst-case gas ≤ ~50% of block gas limit. - If the loop hits
Bwith the incoming order still matchable, the engine suspends: the incoming order's remainder is stored as a continuation order at the top of a per-book pending-match slot, and aMatchSuspended{order_id, remaining, seq}event is emitted. - While a continuation is pending, the book is in matching-in-progress state: new placements on that book are accepted but MUST NOT match ahead of the pending continuation (they queue behind it by
seq); cancels remain allowed (cancels never match). - Resumption: anyone may call
continue_match(market)(the original taker, a keeper, or the protocol's cranker); each call consumes up toBmore fills. Resumption preserves the original order'sseqfor priority purposes. Fills occurring in continuation transactions are otherwise identical to normal fills. - Termination: continuation completes when the order's remaining qty hits 0, its limit price no longer crosses, or TIF residue rules apply (IOC remainder cancels at the end of the first transaction only if no continuation is desired — v1 rule: IOC/market orders are subject to suspension too and their "immediate" window is the chain of continuation calls; the remainder cancels when the continuation chain ends. Flag: alternative is "IOC never continues, remainder cancels at budget exhaustion" — simpler, slightly unfair to large takers. Decision needed; recommended default: IOC cancels at budget exhaustion, GTC/market continue.)
(The exact continuation policy is the one open sub-decision in this spec; everything else here is settled.)
6. Batch Operations
6.1 Surface
batch(ops: [Op]) where Op ∈ {Place, Cancel, Replace}; max batch length: per-chain constant (proposed 32).
6.2 Atomicity — all-or-nothing
- The batch executes atomically: if ANY op in the batch fails validation (malformed, unknown order, post-only cross, FOK infeasible, insufficient collateral, …), the ENTIRE batch reverts: no state changes, no fills, a single
BatchRejected{index, reason, seq}event identifying the first failing op. - Justification: MMs use batches as "make my quotes look exactly like this"; partial application leaves them in unknown states. All-or-nothing is modelable.
- Note: an IOC inside a batch that fills 0 due to no liquidity is NOT a failure (it's a valid outcome). Failure = validation/semantic error, not "didn't trade".
6.3 Intra-batch ordering & sequencing
- Ops execute strictly in array order. Each op receives its own consecutive
seq(a batch of k accepted ops consumes k sequence numbers). - Cancels in a batch take effect before later ops in the same batch (canonical MM pattern:
[cancel old quotes…, place new quotes…]). - Replace = atomic cancel + place. Semantics: price change or qty increase ⇒ new
seq(loses queue position); pure qty decrease ⇒ retains originalseqand position, emitsOrderReduced. A replace of an order that has been fully filled earlier in the same batch or block fails withUNKNOWN_ORDER(and thus reverts the batch). - Within a batch, ops MUST NOT match against each other's freshly placed orders if same account — that is self-trading and STP (§7) applies normally.
7. Self-Trade Prevention (STP)
Triggered when the incoming (taker) order would fill against a resting order of the same account (defined as the same vault account address; sub-account granularity is a flagged open question). Policy is a per-order parameter with a chain-level default; all three policies MUST be implemented and switchable.
DEFAULT POLICY: CANCEL_NEWEST (the most queue-protective option). All three policies are fully defined below and switchable per order:
7.1 CANCEL_NEWEST (cancel taker)
The incoming order is canceled at the moment of the would-be self-match: matching stops, taker's remaining quantity is canceled (OrderCanceled{reason: STP_CANCEL_NEWEST}). Fills already executed before encountering the self-order stand. The resting order is untouched. Property: protects resting queue position; taker pays the consequence.
7.2 CANCEL_OLDEST (cancel maker)
The resting order is canceled (OrderCanceled{reason: STP_CANCEL_OLDEST}), the matching loop continues with the next order in FIFO at that level (or next level). The incoming order keeps matching/normal residue rules apply. Property: taker's intent dominates; account loses old queue position.
7.3 DECREMENT_AND_CANCEL
Let t = taker remaining, m = resting (maker) remaining at encounter:
d = min(t, m). Decrement both bydwith no fill executed, no fee, no trade event — emitSelfTradeDecremented{maker_order_id, taker_order_id, qty: d}.- If
m − d == 0: resting order removed (OrderCanceled{reason: STP_DECREMENT}). - If
t − d == 0: incoming order is done (OrderCanceled{reason: STP_DECREMENT}for its remainder record); else the loop continues to the next resting order.
Property: net exposure result identical to self-fill minus fees/prints; never produces a misleading trade print.
STP and post-only: a post-only order is rejected for crossing before STP evaluation (the cross test does not care who owns the opposite order). So self-cross with post-only ⇒ POST_ONLY_WOULD_CROSS reject. (Edge case E11.) STP and FOK: see §5.3 — self-owned resting liquidity is excluded from feasibility.
8. Fees (interface to the fee module)
- Fees never affect matching priority or price; they are settled in the vault layer on each fill.
- Taker fee:
fee = floor(notional × taker_bps / 10_000)wherenotional = fill_qty × resting_price(in collateral units, integer). Maker rebate:rebate = floor(notional × maker_rebate_bps / 10_000); protocol cut =fee − rebate(constraint:maker_rebate_bps ≤ taker_bps, enforced at parameter set). Rounding remainders accrue to treasury (D3). - Parameter values: governance-set.
9. Event Schema (book reconstruction from logs alone)
Requirement : replaying ONLY the event stream must reconstruct the book byte-identically. Every event carries (market_id, seq); seq is gap-free per market (§3.2).
| Event | Fields (beyond market_id, seq) | Emitted when |
|---|---|---|
OrderAccepted | order_id(=seq), account, side, price, qty, tif, flags, client_order_id | Any accepted placement (incl. ones that fully fill immediately — emitted before its fills) |
OrderRejected | account, client_order_id, reason, echo of params | Any rejected op (consumes a seq) |
Fill | maker_order_id, taker_order_id, price (=maker), qty, maker_account, taker_account, maker_remaining_after, taker_remaining_after | Every fill, partial or final |
OrderCanceled | order_id, reason ∈ {USER, IOC_REMAINDER, FOK_UNFILLABLE, NO_LIQUIDITY, STP_*, REDUCE_ONLY_AUTO, MARKET_EXPIRED, …}, remaining_at_cancel | Any removal other than full fill |
OrderReduced | order_id, new_qty | Qty-decrease replace or reduce-only auto-clamp |
OrderReplaced | old_order_id, new_order_id, price, qty | Replace that re-queues (links old→new for client tracking) |
MatchSuspended / MatchResumed / MatchCompleted | order_id, remaining | Bounded-matching continuation lifecycle (§5.4) |
SelfTradeDecremented | maker_order_id, taker_order_id, qty | §7.3 only |
BatchStart / BatchEnd / BatchRejected | batch_id, op_count / first_failed_index, reason | Batch framing (lets readers attribute seqs to one atomic batch) |
MarketStateChanged | new_state ∈ {TRADING, HALTED, EXPIRED, RESOLVED}, [book_cleared: bool] | Lifecycle transitions; EXPIRED/RESOLVED imply bulk cancel semantics (E12) |
Reconstruction rule: a reader maintaining {orders by id, levels as FIFO lists ordered by seq} and applying events in seq order MUST reach a state whose canonical serialization hash equals the engine's. This is tested by the Phase-1 replay harness across the full randomized corpus.
10. Edge Cases (enumerated, normative)
| # | Case | Behavior |
|---|---|---|
| E1 | qty == 0 | REJECT ZERO_QTY. Applies to place and to replace-to-zero (use cancel instead). |
| E2 | qty < min_order_size (per-market lots) or qty not a multiple of lot granularity | REJECT BELOW_MIN_SIZE. Anti-dust . Exception: fill remainders below min size are allowed to rest IF created by partial fill (you can't control the taker), but a replace may not shrink an order below min size. |
| E3 | price == 0 or price ≥ max_tick | REJECT INVALID_PRICE. |
| E4 | Duplicate client_order_id among the same account's open orders | REJECT DUPLICATE_CLIENT_ORDER_ID. After the prior order closes, the id may be reused. Engine uniqueness is on order_id; client id is a per-account open-order uniqueness convenience. |
| E5 | Cancel of a partially filled order | Cancels the remaining quantity only; prior fills stand; OrderCanceled{remaining_at_cancel}. |
| E6 | Cancel of unknown / already-closed order | Standalone op: no state change, OrderRejected{UNKNOWN_ORDER} (NOT a tx revert — races between fill and cancel are normal MM flow and must be cheap). Inside a batch: counts as failure ⇒ batch reverts (§6.2). (Flag: we may want a cancel_soft batch op that tolerates UNKNOWN_ORDER to keep the common race from reverting quote updates — recommended addition; confirm.) |
| E7 | Cancel by non-owner | REJECT NOT_OWNER. |
| E8 | Account open-order cap exceeded | REJECT ORDER_CAP_EXCEEDED (per-account cap, chain parameter; anti-griefing, brief §3 P6). |
| E9 | Insufficient collateral / outcome tokens to escrow at placement | REJECT INSUFFICIENT_FUNDS (checked at acceptance; escrow model: funds locked on place, released on cancel/fill). |
| E10 | Limit buy price ≥ best ask with post_only | REJECT POST_ONLY_WOULD_CROSS (§2.2) — including when the only crossing liquidity is the account's own (E11). |
| E11 | Self-cross with post-only | Same as E10: post-only cross check precedes STP; REJECT. |
| E12 | Market expiry (endTime reached) / resolution | Book transitions to EXPIRED: all resting orders are canceled (lazily or eagerly — implementation choice, but events MUST reflect cancellation reason: MARKET_EXPIRED before any redemption accounting; no new placements accepted: REJECT MARKET_NOT_TRADING). |
| E13 | Market halted (governance) | No new placements/matches; cancels REMAIN allowed (users can always pull quotes). |
| E14 | Incoming GTC limit crosses, partially fills, remainder rests | Remainder rests at its limit price with the ORIGINAL seq (it was accepted once). |
| E15 | Replace race: order fully fills before replace executes | Replace fails UNKNOWN_ORDER (batch: reverts batch; standalone: reject event). |
| E16 | FOK where feasibility passes but STP would block part of required qty | Infeasible by definition (§5.3, STP-aware feasibility): FOK_UNFILLABLE, no fills. |
| E17 | Two orders, same account, same price level | Allowed (both rest); they only interact via STP if one side later takes. |
| E18 | reduce_only placement with zero position | REJECT REDUCE_ONLY_NO_POSITION (§2.2). |
| E19 | Batch exceeding max batch length | REJECT whole batch BATCH_TOO_LARGE. |
| E20 | Continuation pending + market expiry | Expiry cancels the continuation remainder (MARKET_EXPIRED); completed fills stand. |
11. Open Items (explicitly flagged for review)
- STP default policy —
CANCEL_NEWESTrecommended (all three implemented regardless). - IOC under bounded-matching suspension — continue vs. cancel-at-budget (recommended: cancel-at-budget; §5.4).
- Reject events consuming
seq— recommended yes (gap-free log; §3.2). cancel_softbatch op — recommended yes (E6).- FOK
max_fill_levelsdefault (32) and fill budgetB— calibrate in Phase 2 gas profiling. - Market-order slippage bound — deferred to v1.1 unless MM partners demand it.
- Sub-account granularity for STP — v1: account = vault address.
DRAFT v0.1 — to be frozen as v1.0 after joint review; Phase 1 property tests are written against the frozen version.