Files
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

232 lines
9.6 KiB
Plaintext

---------------------------- MODULE DAGEVM ----------------------------
(*
* TLA+ Specification of DAG-EVM Consensus
*
* Source: lux/evmgpu/dag/vertex.go (EVMVertex)
* lux/evmgpu/dag/conflicts.go (Conflicts predicate)
* lux/evmgpu/dag/executor.go (topo-order applicator)
* lux/evm/core/parallel/blockstm.go (read/write-set tracking)
* lux/consensus/engine/dag/ (nebula finality)
* lux/consensus/protocol/horizon/ (antichain predicates)
*
* Protocol summary:
* DAG-EVM lifts Block-STM (Gelashvili et al., 2022) from a
* per-block scheduling discipline to full consensus-level ordering.
*
* Each vertex batches a set of EVM transactions whose read/write sets
* have been pre-computed by speculative parallel execution. Vertices
* reference parents in the DAG, and the nebula finality mode finalises
* antichain cuts via quasar certificates.
*
* The conflict rule is:
* conflicts(v1, v2) ==
* (v1.reads \cap v2.writes) # {} \/
* (v1.writes \cap v2.reads) # {} \/
* (v1.writes \cap v2.writes) # {}
*
* BuildVertex guarantees that any two vertices emitted into the same
* antichain are pairwise non-conflicting. Conflicts flow along DAG
* edges (ancestor/descendant), never between siblings.
*
* Key properties (checked with TLC):
* Safety : No two conflicting vertices are both accepted in the
* same antichain rank.
* Serializ. : Every reachable accepted set has a linear extension
* that produces the same final state as the DAG
* topological order.
* NoDblSpend: No storage key is written by two finalised antichain
* siblings.
* Liveness : If a non-empty mempool exists, eventually a vertex is
* proposed and finalised.
*
* Go-to-TLA+ mapping:
* Vertex -> vertex records in `accepted`
* BuildVertex -> Propose action
* Conflicts -> Conflicts operator
* horizon.ComputeFrontier -> Antichain operator
* nebula.Finalize -> Finalize action
* quasar cert -> finality flag in Finalize
*)
EXTENDS Integers, FiniteSets, Sequences, TLC
\* --------------------------------------------------------------------------
\* CONSTANTS
\* --------------------------------------------------------------------------
CONSTANTS
Keys, \* Set of storage keys (StorageKey = uint256 keccak slot)
MaxVertices, \* Bound on DAG size for model checking
MaxTxsPerVertex \* Bound on batch size
ASSUME Cardinality(Keys) > 0
ASSUME MaxVertices \in Nat \ {0}
ASSUME MaxTxsPerVertex \in Nat \ {0}
\* --------------------------------------------------------------------------
\* TYPES
\* --------------------------------------------------------------------------
\* A vertex record:
\* id \in Nat unique identifier
\* reads \subseteq Keys read set
\* writes \subseteq Keys write set
\* parents \subseteq {vertex ids} DAG parents
\* rank \in Nat topological rank
VertexId == 1..MaxVertices
\* Ranks are layered as 1 + maxParentRank starting at 1, and a chain of
\* accepted vertices is bounded by MaxVertices, so every reachable rank lies
\* in 1..MaxVertices. Bounding the field here keeps Vertex a FINITE set; with
\* `rank : Nat` it was infinite and TLC could not enumerate `\E v \in Vertex`
\* in the Next relation ("can't enumerate the value of the rank field: Nat").
RankRange == 1..MaxVertices
Vertex ==
[ id : VertexId,
reads : SUBSET Keys,
writes : SUBSET Keys,
parents : SUBSET VertexId,
rank : RankRange ]
\* --------------------------------------------------------------------------
\* VARIABLES
\* --------------------------------------------------------------------------
VARIABLES
accepted, \* Set of accepted vertex records (persistent)
finalRank, \* Highest rank for which the antichain is quasar-final
mempool \* Set of pending (reads, writes) tx batches awaiting propose
vars == <<accepted, finalRank, mempool>>
\* --------------------------------------------------------------------------
\* HELPERS
\* --------------------------------------------------------------------------
Conflicts(v1, v2) ==
(v1.reads \cap v2.writes) # {} \/
(v1.writes \cap v2.reads) # {} \/
(v1.writes \cap v2.writes) # {}
\* The set of accepted vertices at a given rank.
Antichain(r) == { v \in accepted : v.rank = r }
\* The frontier: ranks whose antichains have been proposed but not yet finalised.
\* In the Go code this is horizon.ComputeFrontier().
Frontier == { v.rank : v \in accepted } \ { r \in Nat : r <= finalRank }
\* --------------------------------------------------------------------------
\* INVARIANT (structural well-formedness)
\* --------------------------------------------------------------------------
TypeOK ==
/\ accepted \subseteq Vertex
/\ finalRank \in Nat
/\ mempool \subseteq ([reads: SUBSET Keys, writes: SUBSET Keys])
\* --------------------------------------------------------------------------
\* INITIAL STATE
\* --------------------------------------------------------------------------
Init ==
/\ accepted = {}
/\ finalRank = 0
\* mempool is an abstract placeholder for pending tx batches; the abstracted
\* Propose action draws arbitrary well-formed vertices directly and never
\* reads mempool, so it stays UNCHANGED throughout. The previous
\* `mempool \in SUBSET ([reads: SUBSET Keys, writes: SUBSET Keys])`
\* forced TLC to enumerate 2^(2^|Keys| * 2^|Keys|) initial states (2^64 for
\* 3 keys) before the cardinality filter could apply, stalling the checker.
\* Initialising it to {} keeps Init tractable without weakening any property
\* (no action depends on mempool's contents).
/\ mempool = {}
\* --------------------------------------------------------------------------
\* ACTIONS
\* --------------------------------------------------------------------------
\* Propose a new vertex from the mempool.
\* Invariant enforced by BuildVertex: the new vertex does not conflict with
\* any other accepted vertex at the same rank.
Propose(newV) ==
/\ newV \in Vertex
/\ newV.id \notin { v.id : v \in accepted }
/\ Cardinality(accepted) < MaxVertices
\* Parents must already be accepted (DAG closure).
/\ \A p \in newV.parents : \E v \in accepted : v.id = p
\* Rank is one more than the max parent rank (Kahn layering).
/\ newV.rank =
IF newV.parents = {} THEN 1
ELSE 1 + CHOOSE maxR \in { v.rank : v \in {x \in accepted : x.id \in newV.parents} } :
\A v \in accepted :
v.id \in newV.parents => v.rank <= maxR
\* CRITICAL: no conflict with accepted siblings at the same rank.
/\ \A v \in accepted : v.rank = newV.rank => ~Conflicts(v, newV)
/\ accepted' = accepted \cup {newV}
/\ UNCHANGED <<finalRank, mempool>>
\* Finalize the lowest unfinalised rank (nebula + quasar).
Finalize ==
/\ finalRank + 1 \in { v.rank : v \in accepted }
\* All sibling vertices at this rank are accepted (antichain complete).
/\ \A v \in Antichain(finalRank + 1) :
\A w \in Antichain(finalRank + 1) : v # w => ~Conflicts(v, w)
/\ finalRank' = finalRank + 1
/\ UNCHANGED <<accepted, mempool>>
Next ==
\/ \E v \in Vertex : Propose(v)
\/ Finalize
Spec == Init /\ [][Next]_vars /\ WF_vars(Finalize)
\* --------------------------------------------------------------------------
\* PROPERTIES
\* --------------------------------------------------------------------------
\* Safety: no two vertices in the same antichain conflict.
Safety ==
\A v1, v2 \in accepted :
(v1.id # v2.id /\ v1.rank = v2.rank) => ~Conflicts(v1, v2)
\* No double spend: at most one finalised vertex writes each key per rank.
NoDoubleSpend ==
\A r \in 1..finalRank :
\A k \in Keys :
Cardinality({ v \in Antichain(r) : k \in v.writes }) <= 1
\* Serializability witness: a topological order of accepted vertices that
\* respects parent -> child precedence always exists (implicit in DAG
\* acyclicity + rank layering).
Serializability ==
\A v, w \in accepted :
w.id \in v.parents => w.rank < v.rank
\* Liveness: every accepted vertex is eventually finalised, assuming its
\* antichain stops accepting new siblings.
\*
\* We quantify over the FINITE set of possible ranks (1..MaxVertices) rather
\* than over the type Vertex. Vertex has a `rank : Nat` field, so it is an
\* infinite set; TLC cannot enumerate `\A v \in Vertex` inside a temporal
\* (~>) formula ("can't enumerate the value of the rank field: Nat"). Ranks
\* are layered 1 + maxParentRank and bounded by the vertex count, so every
\* rank that can ever appear lies in 1..MaxVertices. "Every accepted vertex's
\* rank is eventually finalised" is exactly "every populated rank is
\* eventually <= finalRank", which is finite and TLC-checkable.
Liveness ==
\A r \in 1..MaxVertices :
(\E v \in accepted : v.rank = r) ~> (finalRank >= r)
\* Conflict-flow-along-edges invariant (the BuildVertex contract).
\* Only DISTINCT vertices are compared: every vertex trivially "conflicts"
\* with itself (its write set intersects itself), so without the v1.id # v2.id
\* guard the implication degenerates to TRUE => (r # r) = FALSE on the very
\* first accepted vertex. The sibling invariant Safety already guards this way.
ConflictsAlongEdges ==
\A v1, v2 \in accepted :
(v1.id # v2.id /\ Conflicts(v1, v2)) => v1.rank # v2.rank
================================================================================