Files
zeekay 65d4e361b6 submission: Tier A documentation package
Adds the Tier A bridge endpoint submission package:

  - SUBMISSION.md            cover sheet, headline claims, scope
  - SPEC.md                  bridge state machine, custody MPC,
                             oracle attestation, relay protocol
  - PROOF-CLAIMS.md          P1-P6 proved, N1-N6 not proved
  - TRUSTED-COMPUTING-BASE.md TCB inventory (HW/OS, primitives, quorums)
  - PATENTS.md               royalty-free patent grant
  - CRYPTOGRAPHER-SIGN-OFF.md  APPROVED WITH GATES, 5 gates listed
  - DEPLOYMENT-RUNBOOK.md    operator playbook (build / deploy / MPC
                             ceremony bootstrap / profile transition /
                             LSS rotation / pause / monitoring)
  - LEGACY-CLASSICAL.md      LUX_PROFILE=classical-compat opt-in docs
  - CHANGELOG.md             per-release changes (incl. Tier A pass)

Also strips the deprecated privacy-layer section from LLM.md (the
contracts it described were removed in commit 78e2dd7). The privacy
section is replaced with a brief pointer to git history and to the
Warp 2.0 envelope path which is the canonical surface today.

No code changes in this commit. The strict-PQ profile gate, six-
scheme threshold-signer bus, and EXTCODEHASH allow-list were all
landed in prior commits (b0ff75e, 5c64d64, bdfc56c, ddc5547). This
commit closes the documentation gap.

The cryptographer sign-off is APPROVED WITH GATES (5 gates listed,
all docs / runbook / interop deliverables, none algorithmic).
2026-05-19 08:40:58 -07:00

20 KiB

SPEC — luxfi/teleport

Bridge state machine, custody MPC ceremonies, oracle attestation, and relay protocol for luxfi/teleport. Authoritative reference for the on-chain endpoint contracts (Bridge.sol, BridgeV2.sol, StrictPQProfileGate.sol) and the off-chain services (api/, mpc/).

This document describes WHAT the bridge does and WHY in those terms. Where a value is determined by the implementation, that value is canonical here and the implementation MUST match it; where the implementation is allowed to choose (e.g. choice of MPC scheme), the admissible set is enumerated here.

Companion documents:

  • WARP2_BRIDGE.md — pre-existing Warp 2.0 architecture overview and Phase 1-5 migration plan.
  • PROOF-CLAIMS.md — what is proved and what is explicitly NOT.
  • TRUSTED-COMPUTING-BASE.md — TCB inventory.

1. Trust boundaries

The bridge composes three distinct trust surfaces. Each has its own adversary model, its own primitives, and its own failure mode.

Surface Authoritative primitive Adversary Failure mode
Source-chain burn observation Source-chain client (Bitcoin Core, geth, etc.) Source-chain reorg adversary Burn rolled back after envelope was already signed → relay must wait min_confirmations per asset before signing
Validator-set signing of messageHash Three lanes — Beam BLS12-381 + ML-DSA-65 cert set + Pulsar Pulse Quantum / lattice / pairing breaks; validator-quorum takeover A single primitive break does not forge; quorum takeover forges within the v1 BLS quorum (LP-075) — same residual as Warp v1
Destination-chain claim BridgeV2.claim or claimWithProof Destination-chain reorg adversary (mitigated by destination finality); spoofed verifier (mitigated by StrictPQProfileGate + EXTCODEHASH allow-list) Spoofed verifier with policyId() matching the allow-list but verify() returning true for any input — closed by EXTCODEHASH allow-list (Section C residual, Red round 5)
External-asset custody M-Chain ThresholdVM (LP-7330), target-chain native primitive Custodian-quorum takeover Quorum takeover steals custodied assets — same residual as any threshold-custody bridge; out of scope of this contract surface

Decomplecting these four surfaces is the central design discipline: each surface has its own primitive, its own adversary, and its own proof of refusal under attack.

2. State machine — BridgeV2

The destination-chain endpoint is a stateless verifier plus replay storage. State transitions are:

       ┌──────────────────────────────────────────────────────────┐
       │                                                          │
       ▼                                                          │
┌─────────────┐    burn()             ┌────────────────────────┐  │
│   liquid    │ ───────────────────►  │ messageHash emitted    │  │
│   token     │                       │ (BridgeBurnedV2 event) │  │
└─────────────┘                       └────────────────────────┘  │
       ▲                                       │                  │
       │                                       ▼                  │
       │                                ┌─────────────┐           │
       │                                │  Lux        │           │
       │                                │  validator  │           │
       │                                │  set signs  │           │
       │                                │  envelope   │           │
       │                                └─────────────┘           │
       │                                       │                  │
       │                                       ▼                  │
┌─────────────┐    claim() / claimWithProof    │                  │
│   minted    │ ◄───────────────────────────── │                  │
│   token     │  (envelope verified)           │                  │
└─────────────┘                                                   │
       │                                                          │
       └──────────────────────────────────────────────────────────┘
                                              (next round trip)

2.1 burn(BurnRequest r) — source chain

Inputs (calldata):

BurnRequest {
  address token;     // ERC20B (mint/burn) or vault-backed token
  uint256 amount;
  uint64  destChainId;
  address recipient;
  bool    vault;     // true: lock; false: burn-and-mint
}

Algorithm:

  1. Refuse if !allowedTokens[r.token].
  2. Refuse if r.amount == 0.
  3. Refuse if r.recipient == address(0).
  4. Refuse if r.destChainId == 0 or r.destChainId == block.chainid.
  5. Allocate nonce = burnNonce++.
  6. Compute messageHash = keccak256(abi.encode( uint64(block.chainid), r.destChainId, r.token, r.amount, r.recipient, r.vault, nonce)). This MUST equal luxfi/warp.EnvelopeV2.ID() for the corresponding unsigned message.
  7. Effect: if r.vault then safeTransferFrom(msg.sender, this, amount); else IBridgeToken(r.token).bridgeBurn(msg.sender, amount).
  8. Emit BridgeBurnedV2(messageHash, token, sender, amount, destChainId, recipient, vault, nonce).

Postcondition: messageHash is uniquely determined by (sourceChainId, destChainId, token, amount, recipient, vault, nonce). Collision-resistance of keccak256 is sufficient for binding the unsigned message; ECDSA-style malleability is impossible because there is no signature in the source-chain step.

2.2 claim(env, c) — destination chain (envelope only)

Inputs:

Envelope {
  bytes32  messageHash;
  uint64   sourceChainId;
  uint64   destChainId;
  bytes    blsAggregate;     // Beam lane (BLS12-381 aggregate over messageHash)
  bytes    mldsaCertSet;     // optional — concatenated ML-DSA-65 sigs
  bytes    pulseSignature;   // optional — Pulsar Pulse threshold signature
  uint64   epoch;
}

ClaimData {
  uint64  sourceChainId;
  uint64  destChainId;
  address token;
  uint256 amount;
  address recipient;
  bool    vault;
  uint256 nonce;
}

Algorithm:

  1. Profile gate. onlyStrictPQ(activeProfile, IPolicyId(activeVerifier)) modifier from @luxfi/pq-profile-ids/src/OnlyStrictPQ.sol. Under PROFILE_CLASSICAL_COMPAT the gate is a no-op; under PROFILE_QUASAR_STRICT_PQ the verifier MUST carry a PQ policyId (POLICY_STARK_FRI_SHA3_PQ or POLICY_MLDSA65_FIPS204) AND its extcodehash MUST be in allowedVerifierCodeHashes.
  2. Lane policy. Read asset-specific minimum: want = assetMinPolicyFlags[c.token] ?? minPolicyFlags. Refuse if (verifier.policyFlags() & want) != want.
  3. Envelope verification. Refuse if !verifier.verify(env).
  4. Envelope binding. Refuse if env.destChainId != uint64(block.chainid). Refuse if env.sourceChainId != c.sourceChainId. Compute expected = keccak256(abi.encode(c.sourceChainId, c.destChainId, c.token, c.amount, c.recipient, c.vault, c.nonce)). Refuse if expected != env.messageHash.
  5. Replay. Refuse if usedMessages[env.messageHash]; set usedMessages[env.messageHash] = true.
  6. Mint preconditions. Refuse if !allowedTokens[c.token], c.amount == 0, c.recipient == address(0).
  7. Fee + release. fee = c.amount * feeRate / 1e18, net = c.amount - fee. If c.vault then safeTransfer both; else bridgeMint both.
  8. Emit BridgeMintedV2(messageHash, token, recipient, net, fee, epoch).

2.3 claimWithProof(env, c, proof) — destination chain (envelope + P3Q proof)

Algorithm:

  1. Refuse if p3qVerifier == address(0) (P3QVerifierUnset).
  2. Profile gate fires FIRST. Call _refuseUnderStrictPQ(activeProfile, IPolicyId(address(p3qVerifier))). This is the SAME canonical helper as the onlyStrictPQ modifier on claim, so both paths refuse identically. The reason it must fire first: a spoofed-policy p3qVerifier would otherwise call verify, which could return true for arbitrary inputs.
  3. P3Q proof check: refuse if !p3qVerifier.verify(proof, env.messageHash).
  4. Fall through to _processClaim(env, c) (steps 1-7 from claim).

The Solidity surface enforces invariant: a claimWithProof call under strict-PQ requires BOTH the envelope verifier AND the P3Q verifier to pass the gate. Decomplected: profile gate is admissibility, verifier is correctness.

2.4 Admin paths

Function Role Effect
setVerifier(addr, flags) ADMIN_ROLE Swap envelope verifier. No profile check (verifier is the Warp 2.0 dispatcher, not the strict-PQ slot).
setActiveProfile(profile, verifier) ADMIN_ROLE Set activeProfile and the strict-PQ-gated activeVerifier. Under strict-PQ, the verifier MUST have a PQ policyId AND its bytecode hash MUST be in allowedVerifierCodeHashes.
setP3QVerifier(addr) ADMIN_ROLE Install the P3Q verifier. address(0) clears the slot (fail-CLOSED — claimWithProof reverts). A non-zero addr MUST have policyId == POLICY_STARK_FRI_SHA3_PQ AND bytecode in the allow-list.
setAllowedVerifierCodeHash(hash, allowed) DEFAULT_ADMIN_ROLE Extend the EXTCODEHASH allow-list. Distinct from ADMIN_ROLE so a compromised operations key cannot install a "true-returning" verifier.
setTokenAllowed(token, allowed) ADMIN_ROLE Token whitelist.
setAssetPolicyFlags(token, flags) ADMIN_ROLE Per-asset minimum policy flags (default: global minPolicyFlags).
setFeeConfig(recipient, rate) ADMIN_ROLE Fee config. rate <= MAX_FEE_RATE = 1e17 (10%).
pause() / unpause() PAUSER_ROLE Emergency stop.

3. Strict-PQ profile gate

Single canonical function: refuseUnderStrictPQ(bytes32 profile, IVerifier verifier) defined in @luxfi/pq-profile-ids/src/OnlyStrictPQ.sol and re-exported from StrictPQProfileGate.sol for back-compat.

if (profile == PROFILE_CLASSICAL_COMPAT) return;     // no-op
if (profile != PROFILE_QUASAR_STRICT_PQ) revert UnknownProfile;
if (address(verifier) == address(0)) revert VerifierUnsetUnderStrictPQ;
bytes32 policy = verifier.policyId();
if (policy != POLICY_STARK_FRI_SHA3_PQ && policy != POLICY_MLDSA65_FIPS204)
    revert VerifierRefusedUnderStrictPQ(profile, policy);

This is an allow-list, not a deny-list: a verifier returning an unknown bytes32 is refused. A verifier with address(0) is refused under strict-PQ — no silent no-op.

Allowed PQ policies:

Constant Bytes32 (keccak256 of name) Backend
POLICY_STARK_FRI_SHA3_PQ keccak256("ProofPolicySTARKFRISHA3PQ") STARK / FRI over SHA-3
POLICY_MLDSA65_FIPS204 keccak256("ProofPolicyMLDSA65FIPS204") ML-DSA-65 (FIPS 204)

Refused classical policies (deny-listed because they are common options that an admin might wire by accident):

Constant Backend
POLICY_GROTH16_BN254 Groth16 over BN254
POLICY_PLONK_BN254 PLONK over BN254
POLICY_KZG_BLS12381 KZG over BLS12-381

These refusal constants live in @luxfi/pq-profile-ids/src/PQProfileIDs.sol as the single source of truth. The Go side reads the same constants from @luxfi/pq-profile-ids-ts; the TS-side resolver (mpc/src/signers/index.ts) imports them from the same package so the two sides cannot drift.

4. Off-chain pipeline

4.1 API service (api/src/bridge.ts)

Responsibilities:

  • Watch source-chain BridgeBurned events (v1) / BridgeBurnedV2 events (v2).
  • Reconstruct the canonical UnsignedMessage (or messageHash) from on-chain logs ONLY. The request body (burnTxHash, fromChainId, logIndex) is a pointer; ALL claim fields are derived from the log.
  • Compute the EIP-712 digest (v1) or Warp 2.0 envelope hash (v2).
  • Submit to the MPC service for signing.
  • Persist the signature to MongoDB keyed by claimId = keccak256(abi.encode(claim fields)).
  • Serve GET /api/v2/claim/:claimId for relayers / users.

Profile resolution (isStrictPQ in api/src/pq/pqSigner.ts):

LUX_PROFILE=quasar-strict-pq  → strict-PQ on, ML-DSA-65 envelope signing required
LUX_PROFILE=classical-compat  → strict-PQ off, legacy ECDSA EIP-712 signing
LUX_PROFILE=hybrid            → strict-PQ off (reserved; on-chain gate refuses hybrid today)
STRICT_PQ=1 (legacy)          → strict-PQ on + deprecation warning
unset                         → strict-PQ off (classical-compat default for back-compat)

The PQ signing path (signEnvelope) uses the SAME byte layout as @luxfi/wallet/pkgs/wallet/src/features/wallet/pq/pqAccount.ts:

envelope_bytes = u32be(burnChainID) || u32be(claimChainID)
              || u64be(nonce)
              || pad32(token) || pad32(recipient) || pad32(amount)
              // total: 112 bytes

digest = cSHAKE256(envelope_bytes,
                   N = "LUX_ACCOUNT_V1",
                   S = "LUX/WALLET/TX/V1",
                   dkLen = 48)

The customization strings are pinned constants (CSHAKE_CUSTOMIZATION_TX, CSHAKE_CUSTOMIZATION_ACCOUNT_ID); the KAT in api/src/pq/pqSigner.test.ts asserts byte-equality with the Go side.

4.2 MPC service (mpc/src/teleporter.ts)

Responsibilities:

  • Expose POST /api/v2/sign to the API service.
  • Validate burnTxHash, fromChainId, logIndex at the boundary (strict hex regex, type / range checks).
  • Re-parse the burn event from chain (same logic as API — defence in depth).
  • Resolve the active threshold signer ONCE at startup via resolveSchemeFromEnv from mpc/src/signers/index.ts.
  • Sign via the ThresholdSigner interface (single dispatch point).
  • Persist + return.

Profile resolution and scheme matrix:

LUX_PROFILE Default MPC_SCHEME Admissible MPC_SCHEME set
quasar-strict-pq double-lattice {pulsar, corona, double-lattice}
classical-compat ecdsa {ecdsa, cggmp21, frost}
hybrid ecdsa all six (with WARN logged)
unset THROW THROW (fail-closed, no implicit default)

The "fail-closed" semantic for unset LUX_PROFILE is the critical defence against Red HIGH #4 (missing env config silently meaning classical signing on a strict-PQ deployment). The resolver throws an explicit error with remediation text instead of falling back.

4.3 ThresholdSigner interface

Defined in mpc/src/signers/ThresholdSigner.ts. Six implementations, one file each:

Scheme File Transport
ecdsa (GG18) mpc/src/signers/ecdsa.ts spawn(gg18_sign_client) — byte-identical to legacy path
cggmp21 mpc/src/signers/cggmp21.ts JSON-RPC → mpcd
frost mpc/src/signers/frost.ts Default: in-process noble curves (secp256k1_FROST.trustedDealer); opt-in: JSON-RPC → mpcd via MPCD_URL
pulsar mpc/src/signers/pulsar.ts JSON-RPC → mpcd
corona mpc/src/signers/corona.ts JSON-RPC → mpcd
double-lattice mpc/src/signers/doubleLattice.ts Composition: PulsarSigner ⊕ CoronaSigner

Each signer exposes:

interface ThresholdSigner {
  readonly scheme: SchemeName;
  readonly policyId: string;  // single source of truth shared with on-chain gate
  sign(message: Uint8Array): Promise<Uint8Array>;
  verify(message: Uint8Array, signature: Uint8Array, pubKey?: Uint8Array): Promise<boolean>;
  keygenInit?(params): Promise<void>;
  keygenRound?(round, incoming): Promise<Uint8Array[]>;
  keygenFinish?(): Promise<KeygenOutput>;
}

The policyId is consumed by StrictPQProfileGate.refuseUnderStrictPQ on the on-chain side; the off-chain resolver checks profile / scheme admissibility before instantiation.

4.4 Double-lattice signature layout

Composition over inheritance:

sig = u32_be(len(pulsar_sig)) || pulsar_sig || corona_sig
pk  = u32_be(len(pulsar_pk))  || pulsar_pk  || corona_pk

Both sub-signatures cover the same message. Verifier splits at the length prefix and verifies both lanes; refusal if either fails. This is the canonical "belt-and-braces post-quantum" construction — a break of M-LWE alone (Pulsar) does not forge if Corona's R-LWE assumption holds, and vice versa.

The length-prefix is unambiguous: Pulsar's signature length is parameter-dependent (lattice dimension n, modulus q), Corona's is likewise; the explicit u32_be prefix lets verifiers split without out-of-band parameter knowledge.

5. Custody — M-Chain integration

External-asset custody is delegated to M-Chain (luxfi/mpc/mpcd). The protocol surface is:

mpcd JSON-RPC (default 127.0.0.1:7300):
  cggmp21.keygen  { threshold, participants } -> { publicKey, shares[] }
  cggmp21.sign    { messageHex, pubKeyHex }   -> { signatureHex }
  cggmp21.verify  { messageHex, signatureHex, pubKeyHex } -> { ok }
  frost.{keygen, sign, verify}                — Schnorr secp256k1 (RFC 9591)
  pulsar.{keygen, sign, verify}               — M-LWE threshold
  corona.{keygen, sign, verify}               — R-LWE threshold
  bls.{keygen, sign, verify}                  — BLS12-381 (consensus side)

Teleport's mpc/ service is a client of mpcd; it does not own keys. The custody key set is held by M-Chain validators via the threshold dispatcher. Teleport never sees raw key material — only signatures bound to a messageHash.

This decoupling is the central architectural decision (LP-7330): the bridge endpoint is a thin verification layer; key custody lives in a separate VM with its own resharing schedule, its own access-control, its own audit trail.

6. Wire format compatibility

6.1 v1 → v2 envelope

Per luxfi/warp/envelope.go line 88-95, Warp 1.x bytes do not parse as v2 (leading 0x02 mismatch). messageHash is preserved across v1 and v2 envelopes for the same (sourceChainId, destChainId, payload) tuple. The destination-chain usedMessages[messageHash] replay set catches cross-version reuse.

6.2 ECDSA → ML-DSA-65 envelope

The signing-bytes layout (envelope_bytes above) is fixed by the Go side (github.com/luxfi/sdk/wallet/account). The TS-side pqSigner.ts reproduces it byte-for-byte; the KAT in pqSigner.test.ts asserts equality. A KAT failure on either side blocks the wire format from diverging.

6.3 KAT inventory

The byte-level invariants the test suite locks down:

Invariant Test
serializeEnvelope produces exactly 112 bytes for any valid input api/src/pq/pqSigner.test.ts
envelopeDigest is cSHAKE256(envelope, N="LUX_ACCOUNT_V1", S="LUX/WALLET/TX/V1", 48) same
WALLET_SCHEME_MLDSA65 == 0x42 matches Go-side WalletSchemeMLDSA65 same
CSHAKE_CUSTOMIZATION_* matches @luxfi/wallet exports same
ECDSA signature `r
FROST signature round-trip via @noble/[email protected] secp256k1_FROST same
double-lattice layout: `u32be(len_pulsar)
Refusal symmetry on the on-chain gate (cases a-n) contracts/test/BridgeV2.test.ts
Profile / policy IDs match the canonical @luxfi/pq-profile-ids constants contracts/test/StrictPQProfileGate.test.ts

7. Non-goals (explicit)

This bridge endpoint does not:

  • Hold raw custody keys. (M-Chain does that.)
  • Perform consensus-finality verification of the source chain. (The oracle / relay service is responsible for waiting for finality before signing.)
  • Defend against destination-chain reorgs beyond the source-chain finality window encoded in the min_confirmations per asset.
  • Implement the underlying Pulsar / Corona / CGGMP21 / FROST protocols. Those live in their own repositories.
  • Provide light-client verification of source-chain headers. (See contracts/contracts/interfaces/IEthereumLightClient.sol and ILuxLightClient.sol for the trustless-bridge roadmap; the current bridge is MPC-oracle authorised, not light-client authorised.)

The boundary between these surfaces is encoded in the trust-model table in §1 and in TRUSTED-COMPUTING-BASE.md.