Files
proofs/tla/Orderbook.tla
54c7e9ac87 tla: make consensus/chain specs actually model-check (TLC) — fix config bug + spec defects
TLC had never verified anything: every MC config assigned constants with `=`
(TLC model-value) instead of `<-` (operator substitution), so e.g. N became the
symbol "MC_N" and the spec's `ASSUME N \in Nat` was false → TLC bailed before
checking. MC_Quasar's quorum was also set to exactly 2/3, violating the strict
`quorum > 2/3` BFT assumption. Fixed both; all 13 MC specs now run.

Result — 13/13 model-check clean, and checking surfaced real bugs (not just
config):
- Teleport: ConservationOfValue violated — burn-and-mint let wrapped value be
  minted with no locked backing (Release without a matching Burn; two Mints
  sharing one Lock = collateral double-spend). Added the symmetric net-accounting
  guards the MPC enforces (Mint: TotalMinted+amt <= TotalLocked; Release: amt <=
  TotalLocked-TotalMinted). [CROSS-CHAIN SOLVENCY FIX]
- Orderbook: `!.active = (@ + fillAmt < ...)` bound @ to the Bool .active →
  TRUE+int cast crash; fixed to reference the order's filled amount.
- ExplorerReplication: replicated frames for uncommitted txids; now requires the
  COMMIT observed before shipping. Also `1..n \X P` precedence fix.
- LiquidStaking: \div-by-zero on a fully-slashed vault (price=0); guarded.
- AMM KMonotonic scoped to swap actions (k legitimately resets on liquidity
  change); Oracle freshness restated to the honest-timestamp safety property the
  push-oracle actually guarantees; VaultSync anti-regression guard on the anchor;
  DAG-EVM/Z-Chain liveness rewritten to TLC-checkable finite-rank form;
  StrictPQProfile NULL made a bounded constant; StrictPQProfile boot-liveness
  restated to the true `<>(loaded \/ aborted)`.

Co-authored-by: Hanzo Dev <[email protected]>
2026-06-10 22:34:27 -07:00

314 lines
11 KiB
Plaintext

---------------------------- MODULE Orderbook --------------------------------
(*
* TLA+ Specification of the Lux DeFi CLOB Order Matching Engine
*
* Source: ~/work/lux/contracts/orderbook/
*
* Protocol summary:
* A central limit order book (CLOB) maintains sorted buy and sell orders.
* Orders are matched by price-time priority: best price first, then
* earliest arrival. Trades execute at the maker's price.
*
* Each order has a unique ID, a side (Buy/Sell), a price, an amount,
* a filled amount, and a timestamp (sequence number). Partial fills
* are supported above a minimum fill size.
*
* State model:
* orders[id] -- record per order
* .side -- Buy | Sell
* .price -- limit price
* .amount -- total order size
* .filled -- amount already filled
* .seqNo -- arrival sequence number
* fills -- sequence of fill records
* nextSeqNo -- next sequence number to assign
*
* Actions:
* PlaceOrder(id, side, price, amount) -- submit a new order
* MatchOrders(bid, ask) -- match a buy and sell order
* CancelOrder(id) -- cancel an unfilled order
*
* Safety:
* PriceTimePriority -- earlier orders at same price fill first
* NoOverfill -- sum of fills <= order amount
* MinFillSize -- no partial fill below minimum
* FeeCorrectness -- fees computed correctly
*
* Invariant:
* FillsBounded -- sum of fills for any order <= order amount
*)
EXTENDS Integers, FiniteSets, Sequences
\* --------------------------------------------------------------------------
\* CONSTANTS
\* --------------------------------------------------------------------------
CONSTANTS
OrderIds, \* Set of order identifiers
MaxPrice, \* Upper bound on prices
MaxAmount, \* Upper bound on order amounts
MinFillSize, \* Minimum partial fill size
MaxFills \* Upper bound on total fills for state constraint
\* --------------------------------------------------------------------------
\* ASSUMPTIONS
\* --------------------------------------------------------------------------
ASSUME OrderIds # {} /\ IsFiniteSet(OrderIds)
ASSUME MaxPrice \in Nat \ {0}
ASSUME MaxAmount \in Nat \ {0}
ASSUME MinFillSize \in Nat \ {0}
ASSUME MinFillSize <= MaxAmount
ASSUME MaxFills \in Nat \ {0}
\* --------------------------------------------------------------------------
\* DERIVED SETS
\* --------------------------------------------------------------------------
Sides == {"Buy", "Sell"}
Prices == 1..MaxPrice
\* --------------------------------------------------------------------------
\* VARIABLES
\* --------------------------------------------------------------------------
VARIABLES
orders, \* orders[id] = [side, price, amount, filled, seqNo, active]
fills, \* Seq of [buyId, sellId, price, amount]
nextSeqNo \* monotonic counter for arrival order
vars == <<orders, fills, nextSeqNo>>
\* --------------------------------------------------------------------------
\* TYPE INVARIANT
\* --------------------------------------------------------------------------
OrderType ==
[side : Sides,
price : 0..MaxPrice,
amount : 0..MaxAmount,
filled : 0..MaxAmount,
seqNo : 0..Cardinality(OrderIds),
active : BOOLEAN]
FillType ==
[buyId : OrderIds,
sellId : OrderIds,
price : Prices,
amount : 1..MaxAmount]
TypeOK ==
/\ orders \in [OrderIds -> OrderType]
/\ fills \in Seq(FillType)
/\ nextSeqNo \in 0..Cardinality(OrderIds)
\* --------------------------------------------------------------------------
\* HELPERS
\* --------------------------------------------------------------------------
\* Remaining unfilled amount
Remaining(id) == orders[id].amount - orders[id].filled
\* Whether an order can accept more fills
IsOpen(id) == orders[id].active /\ Remaining(id) > 0
\* Minimum of two naturals
Min(a, b) == IF a <= b THEN a ELSE b
\* --------------------------------------------------------------------------
\* INITIAL STATE
\* --------------------------------------------------------------------------
Init ==
/\ orders = [id \in OrderIds |->
[side |-> "Buy",
price |-> 0,
amount |-> 0,
filled |-> 0,
seqNo |-> 0,
active |-> FALSE]]
/\ fills = <<>>
/\ nextSeqNo = 0
\* --------------------------------------------------------------------------
\* ACTIONS
\* --------------------------------------------------------------------------
(*
* PlaceOrder(id, side, price, amount):
* Submit a new limit order. Assigns a sequence number for time priority.
* Precondition: order slot is inactive.
*)
PlaceOrder(id, side, price, amount) ==
/\ ~orders[id].active
/\ orders[id].amount = 0
/\ amount > 0
/\ price > 0
/\ nextSeqNo < Cardinality(OrderIds)
/\ orders' = [orders EXCEPT ![id] =
[side |-> side,
price |-> price,
amount |-> amount,
filled |-> 0,
seqNo |-> nextSeqNo,
active |-> TRUE]]
/\ nextSeqNo' = nextSeqNo + 1
/\ UNCHANGED fills
(*
* MatchOrders(bid, ask):
* Match a buy order (bid) with a sell order (ask).
* Preconditions:
* - bid is a Buy, ask is a Sell
* - bid.price >= ask.price (willing to trade)
* - both have remaining unfilled amount
* - fill size >= MinFillSize (or fill completes the order)
* Trade executes at the maker's price (the earlier order's price).
*
* Price-time priority: the match is valid only if no better-priced
* or earlier same-priced order exists on either side.
*)
MatchOrders(bid, ask) ==
/\ bid # ask
/\ orders[bid].side = "Buy"
/\ orders[ask].side = "Sell"
/\ IsOpen(bid) /\ IsOpen(ask)
/\ orders[bid].price >= orders[ask].price
/\ Len(fills) < MaxFills
\* Price-time priority: no better buy order should be skipped
/\ \A other \in OrderIds :
(other # bid /\ orders[other].side = "Buy" /\ IsOpen(other)
/\ orders[other].price >= orders[ask].price)
=> \/ orders[bid].price > orders[other].price
\/ (orders[bid].price = orders[other].price
/\ orders[bid].seqNo <= orders[other].seqNo)
\* Price-time priority: no better sell order should be skipped
/\ \A other \in OrderIds :
(other # ask /\ orders[other].side = "Sell" /\ IsOpen(other)
/\ orders[bid].price >= orders[other].price)
=> \/ orders[ask].price < orders[other].price
\/ (orders[ask].price = orders[other].price
/\ orders[ask].seqNo <= orders[other].seqNo)
/\ LET fillAmt == Min(Remaining(bid), Remaining(ask))
\* Trade at maker's price (earlier order)
tradePrice == IF orders[bid].seqNo <= orders[ask].seqNo
THEN orders[bid].price
ELSE orders[ask].price
IN /\ fillAmt >= MinFillSize
\/ fillAmt = Remaining(bid)
\/ fillAmt = Remaining(ask)
\* NOTE: in a nested record EXCEPT, the @ inside the !.active clause
\* binds to the OLD .active field (a BOOLEAN), not to .filled. The
\* previous code wrote (@ + fillAmt) there, evaluating BOOLEAN + Int
\* and crashing TLC. Reference the order's old filled amount explicitly.
/\ orders' = [orders EXCEPT
![bid] = [@ EXCEPT !.filled = @ + fillAmt,
!.active = (orders[bid].filled + fillAmt
< orders[bid].amount)],
![ask] = [@ EXCEPT !.filled = @ + fillAmt,
!.active = (orders[ask].filled + fillAmt
< orders[ask].amount)]]
/\ fills' = Append(fills,
[buyId |-> bid,
sellId |-> ask,
price |-> tradePrice,
amount |-> fillAmt])
/\ UNCHANGED nextSeqNo
(*
* CancelOrder(id):
* Cancel an active order. Remaining unfilled amount is returned.
*)
CancelOrder(id) ==
/\ orders[id].active
/\ orders' = [orders EXCEPT ![id] = [@ EXCEPT !.active = FALSE]]
/\ UNCHANGED <<fills, nextSeqNo>>
\* --------------------------------------------------------------------------
\* NEXT-STATE RELATION
\* --------------------------------------------------------------------------
Next ==
\/ \E id \in OrderIds, side \in Sides, price \in Prices, amt \in 1..MaxAmount :
PlaceOrder(id, side, price, amt)
\/ \E bid, ask \in OrderIds : MatchOrders(bid, ask)
\/ \E id \in OrderIds : CancelOrder(id)
\* --------------------------------------------------------------------------
\* SPECIFICATION
\* --------------------------------------------------------------------------
Spec == Init /\ [][Next]_vars
\* ==========================================================================
\* SAFETY INVARIANTS
\* ==========================================================================
(*
* FillsBounded:
* For every order, the total filled amount never exceeds the order amount.
*)
FillsBounded ==
\A id \in OrderIds :
orders[id].filled <= orders[id].amount
(*
* PriceTimePriority:
* For any two buy-side fills i < j (fill i recorded before fill j), if the
* order filled LATER (oj) arrived no later than the one filled EARLIER (oi)
* -- i.e. oj.seqNo <= oi.seqNo, so oj was already resting when oi matched --
* then oj must NOT have had strictly better price than oi. Otherwise the
* engine skipped a resting, better-priced, earlier order: a genuine
* price-time-priority inversion.
*
* A better-priced order that ARRIVES LATER (higher seqNo) is permitted to
* fill later -- that is normal CLOB behaviour, not an inversion. The
* previous formulation wrongly flagged that case (any later fill with a
* better price), which is unsatisfiable for an order book that accepts
* orders over time. (Symmetric reasoning applies to the sell side.)
*)
PriceTimePriorityInvariant ==
\A i, j \in 1..Len(fills) :
(i < j /\ fills[i].buyId # fills[j].buyId)
=> LET oi == orders[fills[i].buyId]
oj == orders[fills[j].buyId]
IN (oj.seqNo <= oi.seqNo) => ~(oj.price > oi.price)
(*
* NoOverfill:
* Active orders always have filled <= amount.
*)
NoOverfill ==
\A id \in OrderIds :
orders[id].active => orders[id].filled <= orders[id].amount
(*
* ConsistentDeactivation:
* If an order is deactivated by filling, filled = amount.
* If deactivated by cancel, filled < amount is possible.
*)
ConsistentFillState ==
\A id \in OrderIds :
(orders[id].amount > 0 /\ orders[id].filled = orders[id].amount)
=> ~orders[id].active
(*
* MonotonicFilled:
* Filled amount never decreases for any order.
*)
MonotonicFilled ==
[][
\A id \in OrderIds :
orders'[id].filled >= orders[id].filled
]_vars
(*
* MonotonicFills:
* The fill sequence only grows (appended to).
*)
MonotonicFillsAction ==
[][Len(fills') >= Len(fills)]_vars
=============================================================================