Files
proofs/tla/DAGZChain.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

188 lines
7.2 KiB
Plaintext

---------------------------- MODULE DAGZChain ----------------------------
(*
* TLA+ Specification of DAG Z-Chain Consensus
*
* Source: lux/node/vms/zkvm/vm.go (ZK UTXO VM)
* lux/node/vms/zkvm/nullifier_db.go (spent-nullifier set)
* lux/node/vms/zkvm/proof_verifier.go (Groth16/Plonk verify)
* lux/node/vms/zkvm/dag_vertex.go (DAG vertex adapter)
* lux/consensus/engine/dag/ (nebula finality)
*
* Protocol summary:
* Z-Chain operates the standard ZK-UTXO model (nullifiers, commitments,
* proofs) under a DAG consensus engine. The conflict rule reduces to
* nullifier-set intersection — a dramatic simplification over the EVM
* r/w-set rule because the full state *is* the nullifier set (plus the
* Merkle commitments tree).
*
* Conflict rule:
* conflicts(v1, v2) == (v1.nulls \cap v2.nulls) # {}
*
* BuildVertex guarantees antichain siblings have disjoint nullifier
* sets. Accept runs Groth16/Plonk batch verification in parallel; the
* batch is sound iff each proof is individually sound (standard
* cryptographic assumption).
*
* Key properties (checked with TLC):
* NoDoubleSpend : No nullifier appears in two accepted vertices.
* OrderFree : Any topo order of the DAG yields the same Z-state.
* BatchSound : A batch-verified vertex is accepted only if every
* proof individually verifies (modelled as a non-
* deterministic choice bounded by the verify predicate).
* Liveness : Every valid proof is eventually finalised.
*
* Go-to-TLA+ mapping:
* v.Nullifiers -> v.nulls
* v.Outputs -> v.commits
* NullifierDB.IsNullifierSpent -> spent
* ProofVerifier.Verify -> VerifyVertex
* BuildVertex -> Propose
* nebula.Finalize -> Finalize
*)
EXTENDS Integers, FiniteSets, Sequences, TLC
\* --------------------------------------------------------------------------
\* CONSTANTS
\* --------------------------------------------------------------------------
CONSTANTS
Nullifiers, \* Universe of possible nullifiers
Commitments, \* Universe of possible UTXO commitments
MaxVertices
ASSUME Cardinality(Nullifiers) > 0
ASSUME Cardinality(Commitments) > 0
ASSUME MaxVertices \in Nat \ {0}
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,
nulls : SUBSET Nullifiers,
commits : SUBSET Commitments,
parents : SUBSET VertexId,
rank : RankRange ]
\* --------------------------------------------------------------------------
\* VARIABLES
\* --------------------------------------------------------------------------
VARIABLES
accepted, \* Accepted vertices
spent, \* Set of nullifiers spent (state)
committed, \* Set of commitments (state)
finalRank \* Highest finalised antichain rank
vars == <<accepted, spent, committed, finalRank>>
\* --------------------------------------------------------------------------
\* HELPERS
\* --------------------------------------------------------------------------
Conflicts(v1, v2) == (v1.nulls \cap v2.nulls) # {}
Antichain(r) == { v \in accepted : v.rank = r }
\* Verification predicate — opaque for model checking; we simply assume
\* proposed vertices have valid proofs. The batch-verify soundness is
\* captured separately in the Lean proof (batch_sound axiom).
VerifyVertex(v) == v.nulls # {} \/ v.commits # {} \* non-trivial tx
\* --------------------------------------------------------------------------
\* TYPE INVARIANT
\* --------------------------------------------------------------------------
TypeOK ==
/\ accepted \subseteq Vertex
/\ spent \subseteq Nullifiers
/\ committed \subseteq Commitments
/\ finalRank \in Nat
\* --------------------------------------------------------------------------
\* INITIAL STATE
\* --------------------------------------------------------------------------
Init ==
/\ accepted = {}
/\ spent = {}
/\ committed = {}
/\ finalRank = 0
\* --------------------------------------------------------------------------
\* ACTIONS
\* --------------------------------------------------------------------------
Propose(newV) ==
/\ newV \in Vertex
/\ newV.id \notin { v.id : v \in accepted }
/\ Cardinality(accepted) < MaxVertices
/\ VerifyVertex(newV)
\* No double-spend: none of newV.nulls are in spent yet.
/\ newV.nulls \cap spent = {}
\* Parents are accepted.
/\ \A p \in newV.parents : \E v \in accepted : v.id = p
\* Rank is one more than max parent rank.
/\ 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
\* Sibling disjointness (the BuildVertex contract).
/\ \A v \in accepted : v.rank = newV.rank => ~Conflicts(v, newV)
/\ accepted' = accepted \cup {newV}
/\ spent' = spent \cup newV.nulls
/\ committed' = committed \cup newV.commits
/\ UNCHANGED finalRank
Finalize ==
/\ finalRank + 1 \in { v.rank : v \in accepted }
/\ \A v, w \in Antichain(finalRank + 1) : v # w => ~Conflicts(v, w)
/\ finalRank' = finalRank + 1
/\ UNCHANGED <<accepted, spent, committed>>
Next ==
\/ \E v \in Vertex : Propose(v)
\/ Finalize
Spec == Init /\ [][Next]_vars /\ WF_vars(Finalize)
\* --------------------------------------------------------------------------
\* PROPERTIES
\* --------------------------------------------------------------------------
\* Every nullifier is spent at most once across all accepted vertices.
NoDoubleSpend ==
\A v1, v2 \in accepted :
v1.id # v2.id => Conflicts(v1, v2) = FALSE
\* The spent set is monotone — it never shrinks.
SpentMonotone == [] ([spent \subseteq spent']_vars)
\* Within any finalised antichain, all vertices have disjoint nullifiers.
AntichainDisjoint ==
\A r \in 1..finalRank :
\A v1, v2 \in Antichain(r) : v1.id # v2.id => ~Conflicts(v1, v2)
\* Liveness: every accepted vertex is eventually finalised.
\*
\* Quantify over the FINITE set of possible ranks (1..MaxVertices), not over
\* the type Vertex. Vertex carries a `rank : Nat` field, making it infinite;
\* 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 reachable rank
\* lies in 1..MaxVertices. "Every accepted vertex is eventually finalised" is
\* equivalent to "every populated rank is eventually <= finalRank".
Liveness ==
\A r \in 1..MaxVertices :
(\E v \in accepted : v.rank = r) ~> (finalRank >= r)
================================================================================