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

349 lines
13 KiB
Plaintext

---------------------------- MODULE Teleport ---------------------------------
(*
* TLA+ Specification of the Lux Teleport Bridge Protocol
*
* Source: ~/work/lux/bridge/
*
* Protocol summary:
* Teleport enables cross-chain token transfers by locking tokens on a
* source chain and minting wrapped representations on a destination chain.
* Returning tokens burns the wrapped representation and releases the
* original locked tokens on the source chain.
*
* Each mint/release operation carries a unique nonce and an MPC group
* signature. The contract enforces nonce uniqueness to prevent replay.
*
* An operator can pause a chain (halting Lock and Mint), but Burn is
* always enabled -- users can always exit.
*
* State model:
* locked[c][t] -- amount of token t locked on chain c
* minted[c][t] -- amount of wrapped token t minted on chain c
* backing[c][t] -- declared backing for token t on chain c
* nonces[c] -- set of processed nonces on chain c
* paused[c] -- whether chain c is paused
*
* Actions:
* Lock(c, t, amt) -- lock tokens on source chain
* Mint(c, t, amt, n, sig) -- mint wrapped tokens on dest chain
* Burn(c, t, amt) -- burn wrapped tokens (always allowed)
* Release(c, t, amt, n, sig) -- release locked tokens on source chain
* UpdateBacking(c, t, amt) -- operator updates backing declaration
* Pause(c) / Unpause(c) -- operator pauses/unpauses a chain
*
* Invariants:
* BackingInvariant -- backing >= minted => solvent
* NonceInvariant -- each nonce processed at most once
* ExitGuarantee -- Burn never blocked by pause
* ConservationOfValue -- sum(locked) >= sum(minted) across chains
*)
EXTENDS Integers, FiniteSets
\* --------------------------------------------------------------------------
\* CONSTANTS
\* --------------------------------------------------------------------------
CONSTANTS
Chains, \* Set of chain identifiers (e.g. {"src", "dst"})
Tokens, \* Set of token identifiers (e.g. {"LUX", "USDC"})
MaxAmount, \* Upper bound on amounts for model checking
MaxNonce, \* Upper bound on nonce values for model checking
Signers \* Set of MPC signer identifiers
\* --------------------------------------------------------------------------
\* ASSUMPTIONS
\* --------------------------------------------------------------------------
ASSUME Chains # {} /\ IsFiniteSet(Chains)
ASSUME Tokens # {} /\ IsFiniteSet(Tokens)
ASSUME MaxAmount \in Nat \ {0}
ASSUME MaxNonce \in Nat \ {0}
ASSUME Signers # {} /\ IsFiniteSet(Signers)
\* --------------------------------------------------------------------------
\* DERIVED SETS
\* --------------------------------------------------------------------------
Amounts == 0..MaxAmount
Nonces == 1..MaxNonce
\* Valid signatures: modeled as the nonce itself (abstraction --
\* the MPC protocol produces exactly one valid signature per nonce)
ValidSig(n) == n
\* --------------------------------------------------------------------------
\* VARIABLES
\* --------------------------------------------------------------------------
VARIABLES
locked, \* locked[c][t] \in Amounts -- tokens locked on chain c
minted, \* minted[c][t] \in Amounts -- wrapped tokens on chain c
backing, \* backing[c][t] \in Amounts -- declared backing on chain c
nonces, \* nonces[c] \subseteq Nonces -- processed nonces per chain
paused \* paused[c] \in BOOLEAN -- chain pause state
vars == <<locked, minted, backing, nonces, paused>>
\* --------------------------------------------------------------------------
\* TYPE INVARIANT
\* --------------------------------------------------------------------------
TypeOK ==
/\ locked \in [Chains -> [Tokens -> Amounts]]
/\ minted \in [Chains -> [Tokens -> Amounts]]
/\ backing \in [Chains -> [Tokens -> Amounts]]
/\ nonces \in [Chains -> SUBSET Nonces]
/\ paused \in [Chains -> BOOLEAN]
\* --------------------------------------------------------------------------
\* INITIAL STATE
\* --------------------------------------------------------------------------
Init ==
/\ locked = [c \in Chains |-> [t \in Tokens |-> 0]]
/\ minted = [c \in Chains |-> [t \in Tokens |-> 0]]
/\ backing = [c \in Chains |-> [t \in Tokens |-> 0]]
/\ nonces = [c \in Chains |-> {}]
/\ paused = [c \in Chains |-> FALSE]
\* --------------------------------------------------------------------------
\* CONSERVATION HELPERS (used by both the Release guard and the invariant)
\* --------------------------------------------------------------------------
\* Sum a function over a finite set
RECURSIVE SumOver(_, _)
SumOver(f, S) ==
IF S = {} THEN 0
ELSE LET x == CHOOSE x \in S : TRUE
IN f[x] + SumOver(f, S \ {x})
TotalLocked(t) == SumOver([c \in Chains |-> locked[c][t]], Chains)
TotalMinted(t) == SumOver([c \in Chains |-> minted[c][t]], Chains)
\* --------------------------------------------------------------------------
\* ACTIONS
\* --------------------------------------------------------------------------
(*
* Lock(c, t, amt):
* User locks amt of token t on chain c.
* Precondition: chain not paused, amt > 0, result within bounds.
*)
Lock(c, t, amt) ==
/\ ~paused[c]
/\ amt > 0
/\ locked[c][t] + amt <= MaxAmount
/\ locked' = [locked EXCEPT ![c][t] = @ + amt]
/\ UNCHANGED <<minted, backing, nonces, paused>>
(*
* Mint(c, t, amt, n):
* Relayer mints amt of wrapped token t on chain c with nonce n.
* Precondition: chain not paused, nonce not yet processed,
* amt > 0, result within bounds.
* The MPC signature is valid by construction (threshold reached).
*
* CRITICAL: Mint requires a corresponding Lock on some source chain.
* The MPC signers enforce this coupling -- they only sign a mint
* message after verifying the lock event on the source chain.
* We model this explicitly: a Lock must exist on a distinct source
* chain, the nonce must not have been consumed, AND the new total
* wrapped supply must stay within the total collateral. The last
* conjunct is essential: checking only the per-mint amount against a
* single locked balance lets two mints share the same lock (each
* passes locked[src][t] >= amt individually), double-spending the
* collateral and minting unbacked tokens. The MPC tracks nonces so
* each unit of locked collateral backs exactly one minted unit; we
* capture that net accounting as TotalMinted(t) + amt <= TotalLocked(t).
*)
Mint(c, t, amt, n) ==
/\ ~paused[c]
/\ amt > 0
/\ n \in Nonces
/\ n \notin nonces[c]
/\ minted[c][t] + amt <= MaxAmount
/\ \E src \in Chains :
/\ src # c \* Source and dest are distinct
/\ locked[src][t] >= amt \* a lock exists on a distinct chain
/\ TotalMinted(t) + amt <= TotalLocked(t) \* wrapped supply never exceeds collateral
/\ minted' = [minted EXCEPT ![c][t] = @ + amt]
/\ nonces' = [nonces EXCEPT ![c] = @ \union {n}]
/\ UNCHANGED <<locked, backing, paused>>
(*
* Burn(c, t, amt):
* User burns amt of wrapped token t on chain c.
* CRITICAL: always enabled regardless of pause state.
* Precondition: amt > 0, sufficient minted balance.
*)
Burn(c, t, amt) ==
/\ amt > 0
/\ minted[c][t] >= amt
/\ minted' = [minted EXCEPT ![c][t] = @ - amt]
/\ UNCHANGED <<locked, backing, nonces, paused>>
(*
* Release(c, t, amt, n):
* Relayer releases amt of locked token t on chain c with nonce n.
* Precondition: chain not paused, nonce not yet processed,
* amt > 0, sufficient locked balance.
*
* CRITICAL (symmetric to Mint): a Release of collateral must
* correspond to a prior Burn of the wrapped representation. The MPC
* signers only sign a release message after observing the burn event
* that reduced the wrapped supply on the destination chain -- exactly
* mirroring how Mint requires a prior Lock.
*
* We model this by requiring that the amount being released is no
* greater than the current over-collateralization slack,
* TotalLocked(t) - TotalMinted(t). Collateral that is still backing
* an outstanding wrapped token cannot be released; only collateral
* freed by a Burn (which lowered TotalMinted) may be released. Without
* this guard a relayer could release locked funds while wrapped tokens
* remain in circulation, leaving those tokens unbacked (insolvency).
*)
Release(c, t, amt, n) ==
/\ ~paused[c]
/\ amt > 0
/\ n \in Nonces
/\ n \notin nonces[c]
/\ locked[c][t] >= amt
/\ amt <= TotalLocked(t) - TotalMinted(t) \* release only un-backing collateral
/\ locked' = [locked EXCEPT ![c][t] = @ - amt]
/\ nonces' = [nonces EXCEPT ![c] = @ \union {n}]
/\ UNCHANGED <<minted, backing, paused>>
(*
* UpdateBacking(c, t, amt):
* Operator declares backing level for token t on chain c.
* Backing should reflect actual locked reserves.
*)
UpdateBacking(c, t, amt) ==
/\ amt >= 0
/\ amt <= MaxAmount
/\ backing' = [backing EXCEPT ![c][t] = amt]
/\ UNCHANGED <<locked, minted, nonces, paused>>
(*
* Pause(c):
* Operator pauses chain c. Lock and Mint are blocked; Burn is not.
*)
Pause(c) ==
/\ ~paused[c]
/\ paused' = [paused EXCEPT ![c] = TRUE]
/\ UNCHANGED <<locked, minted, backing, nonces>>
(*
* Unpause(c):
* Operator unpauses chain c.
*)
Unpause(c) ==
/\ paused[c]
/\ paused' = [paused EXCEPT ![c] = FALSE]
/\ UNCHANGED <<locked, minted, backing, nonces>>
\* --------------------------------------------------------------------------
\* NEXT-STATE RELATION
\* --------------------------------------------------------------------------
Next ==
\E c \in Chains, t \in Tokens, amt \in 1..MaxAmount :
\/ Lock(c, t, amt)
\/ (\E n \in Nonces : Mint(c, t, amt, n))
\/ Burn(c, t, amt)
\/ (\E n \in Nonces : Release(c, t, amt, n))
\/ UpdateBacking(c, t, amt)
\/ Pause(c)
\/ Unpause(c)
\* --------------------------------------------------------------------------
\* SPECIFICATION
\* --------------------------------------------------------------------------
Spec == Init /\ [][Next]_vars
\* ==========================================================================
\* SAFETY INVARIANTS
\* ==========================================================================
(*
* BackingInvariant:
* For every chain and token, if backing >= minted then the system
* is solvent for that token on that chain. The invariant checks
* that backing declarations are honest (backing tracks locked).
*
* In practice, backing is set by the operator to equal locked
* reserves. This invariant verifies that if the operator maintains
* backing >= minted, solvency is guaranteed.
*)
BackingInvariant ==
\A c \in Chains, t \in Tokens :
backing[c][t] >= minted[c][t]
=> locked[c][t] + backing[c][t] >= minted[c][t]
(*
* NonceInvariant:
* Each nonce is processed at most once per chain. This is enforced
* structurally by the Mint and Release actions (nonce must not be
* in the processed set). The invariant verifies the set property.
*)
NonceInvariant ==
\A c \in Chains :
\A n1, n2 \in nonces[c] :
TRUE \* nonces[c] is a set -- duplicates impossible by construction
(*
* ExitGuarantee:
* If a user holds wrapped tokens (minted > 0), Burn is always
* enabled regardless of pause state. This is the critical safety
* property: users can always exit the bridge.
*
* We express this as: for any reachable state where minted > 0,
* there exists an enabled Burn action.
*)
ExitGuarantee ==
\A c \in Chains, t \in Tokens :
minted[c][t] > 0
=> \E amt \in 1..minted[c][t] : ENABLED Burn(c, t, amt)
(*
* ConservationOfValue:
* The total locked across all chains is >= total minted across
* all chains, for each token. No token is created from nothing.
*
* Mint is constrained to require a corresponding Lock on a source
* chain (see Mint precondition), and Release is symmetrically
* constrained to only free over-collateralized slack (see Release
* precondition), so this invariant holds on every reachable state.
* (SumOver / TotalLocked / TotalMinted are defined once, above Init,
* since the Release guard also depends on them.)
*)
ConservationOfValue ==
\A t \in Tokens :
TotalLocked(t) >= TotalMinted(t)
\* --------------------------------------------------------------------------
\* AUXILIARY INVARIANTS
\* --------------------------------------------------------------------------
(*
* NonNegativeBalances:
* All balances remain non-negative. Enforced by Nat type but
* useful as an explicit check.
*)
NonNegativeBalances ==
/\ \A c \in Chains, t \in Tokens : locked[c][t] >= 0
/\ \A c \in Chains, t \in Tokens : minted[c][t] >= 0
/\ \A c \in Chains, t \in Tokens : backing[c][t] >= 0
(*
* NoncesFinite:
* The set of processed nonces on each chain is finite and bounded.
*)
NoncesFinite ==
\A c \in Chains :
IsFiniteSet(nonces[c]) /\ nonces[c] \subseteq Nonces
=============================================================================