Public specification. This is the normative matching-rules spec the engine is tested against. It is a living document; the engine and the on-chain contract are proven to conform to it.

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

QuantityTypeSemantics
priceu64Integer 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).
qtyu64Integer lots. qty_in_tokens = qty × lot_size. Per-market immutable parameter.
sequ64Global, 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_idu64Engine-assigned, unique per market, equals the seq of the accepting placement.
client_order_idu128Caller-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

2.2 Flags

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

2.3 Time-in-force

TIFResting allowedPartial fillsSemantics
GTCyesyesMatch what crosses; remainder rests in the book. Good until canceled or market expiry (§10.9).
IOCnoyesMatch what crosses now; any unfilled remainder is canceled immediately (OrderCanceled{reason: IOC_REMAINDER}).
FOKnonoAtomic 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

  1. Price priority: for buys, higher price matches first; for sells, lower price matches first.
  2. Time priority within a price level: strict FIFO by seq. Lower seq fills first. No size pro-rata, ever.
  3. 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 (new seq, §6.3). A pure quantity decrease preserves seq and queue position.

3.2 Arrival = engine acceptance order

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):

text
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

5.2 Market orders vs. empty/thin books

5.3 FOK feasibility semantics

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):

(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

6.3 Intra-batch ordering & sequencing


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:

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)


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

EventFields (beyond market_id, seq)Emitted when
OrderAcceptedorder_id(=seq), account, side, price, qty, tif, flags, client_order_idAny accepted placement (incl. ones that fully fill immediately — emitted before its fills)
OrderRejectedaccount, client_order_id, reason, echo of paramsAny rejected op (consumes a seq)
Fillmaker_order_id, taker_order_id, price (=maker), qty, maker_account, taker_account, maker_remaining_after, taker_remaining_afterEvery fill, partial or final
OrderCanceledorder_id, reason ∈ {USER, IOC_REMAINDER, FOK_UNFILLABLE, NO_LIQUIDITY, STP_*, REDUCE_ONLY_AUTO, MARKET_EXPIRED, …}, remaining_at_cancelAny removal other than full fill
OrderReducedorder_id, new_qtyQty-decrease replace or reduce-only auto-clamp
OrderReplacedold_order_id, new_order_id, price, qtyReplace that re-queues (links old→new for client tracking)
MatchSuspended / MatchResumed / MatchCompletedorder_id, remainingBounded-matching continuation lifecycle (§5.4)
SelfTradeDecrementedmaker_order_id, taker_order_id, qty§7.3 only
BatchStart / BatchEnd / BatchRejectedbatch_id, op_count / first_failed_index, reasonBatch framing (lets readers attribute seqs to one atomic batch)
MarketStateChangednew_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)

#CaseBehavior
E1qty == 0REJECT ZERO_QTY. Applies to place and to replace-to-zero (use cancel instead).
E2qty < min_order_size (per-market lots) or qty not a multiple of lot granularityREJECT 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.
E3price == 0 or price ≥ max_tickREJECT INVALID_PRICE.
E4Duplicate client_order_id among the same account's open ordersREJECT 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.
E5Cancel of a partially filled orderCancels the remaining quantity only; prior fills stand; OrderCanceled{remaining_at_cancel}.
E6Cancel of unknown / already-closed orderStandalone 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.)
E7Cancel by non-ownerREJECT NOT_OWNER.
E8Account open-order cap exceededREJECT ORDER_CAP_EXCEEDED (per-account cap, chain parameter; anti-griefing, brief §3 P6).
E9Insufficient collateral / outcome tokens to escrow at placementREJECT INSUFFICIENT_FUNDS (checked at acceptance; escrow model: funds locked on place, released on cancel/fill).
E10Limit buy price ≥ best ask with post_onlyREJECT POST_ONLY_WOULD_CROSS (§2.2) — including when the only crossing liquidity is the account's own (E11).
E11Self-cross with post-onlySame as E10: post-only cross check precedes STP; REJECT.
E12Market expiry (endTime reached) / resolutionBook 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).
E13Market halted (governance)No new placements/matches; cancels REMAIN allowed (users can always pull quotes).
E14Incoming GTC limit crosses, partially fills, remainder restsRemainder rests at its limit price with the ORIGINAL seq (it was accepted once).
E15Replace race: order fully fills before replace executesReplace fails UNKNOWN_ORDER (batch: reverts batch; standalone: reject event).
E16FOK where feasibility passes but STP would block part of required qtyInfeasible by definition (§5.3, STP-aware feasibility): FOK_UNFILLABLE, no fills.
E17Two orders, same account, same price levelAllowed (both rest); they only interact via STP if one side later takes.
E18reduce_only placement with zero positionREJECT REDUCE_ONLY_NO_POSITION (§2.2).
E19Batch exceeding max batch lengthREJECT whole batch BATCH_TOO_LARGE.
E20Continuation pending + market expiryExpiry cancels the continuation remainder (MARKET_EXPIRED); completed fills stand.

11. Open Items (explicitly flagged for review)

  1. STP default policyCANCEL_NEWEST recommended (all three implemented regardless).
  2. IOC under bounded-matching suspension — continue vs. cancel-at-budget (recommended: cancel-at-budget; §5.4).
  3. Reject events consuming seq — recommended yes (gap-free log; §3.2).
  4. cancel_soft batch op — recommended yes (E6).
  5. FOK max_fill_levels default (32) and fill budget B — calibrate in Phase 2 gas profiling.
  6. Market-order slippage bound — deferred to v1.1 unless MM partners demand it.
  7. 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.