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).
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:
- Refuse if
!allowedTokens[r.token]. - Refuse if
r.amount == 0. - Refuse if
r.recipient == address(0). - Refuse if
r.destChainId == 0orr.destChainId == block.chainid. - Allocate
nonce = burnNonce++. - Compute
messageHash = keccak256(abi.encode( uint64(block.chainid), r.destChainId, r.token, r.amount, r.recipient, r.vault, nonce)). This MUST equalluxfi/warp.EnvelopeV2.ID()for the corresponding unsigned message. - Effect: if
r.vaultthensafeTransferFrom(msg.sender, this, amount); elseIBridgeToken(r.token).bridgeBurn(msg.sender, amount). - 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:
- Profile gate.
onlyStrictPQ(activeProfile, IPolicyId(activeVerifier))modifier from@luxfi/pq-profile-ids/src/OnlyStrictPQ.sol. UnderPROFILE_CLASSICAL_COMPATthe gate is a no-op; underPROFILE_QUASAR_STRICT_PQthe verifier MUST carry a PQpolicyId(POLICY_STARK_FRI_SHA3_PQorPOLICY_MLDSA65_FIPS204) AND itsextcodehashMUST be inallowedVerifierCodeHashes. - Lane policy. Read asset-specific minimum:
want = assetMinPolicyFlags[c.token] ?? minPolicyFlags. Refuse if(verifier.policyFlags() & want) != want. - Envelope verification. Refuse if
!verifier.verify(env). - Envelope binding. Refuse if
env.destChainId != uint64(block.chainid). Refuse ifenv.sourceChainId != c.sourceChainId. Computeexpected = keccak256(abi.encode(c.sourceChainId, c.destChainId, c.token, c.amount, c.recipient, c.vault, c.nonce)). Refuse ifexpected != env.messageHash. - Replay. Refuse if
usedMessages[env.messageHash]; setusedMessages[env.messageHash] = true. - Mint preconditions. Refuse if
!allowedTokens[c.token],c.amount == 0,c.recipient == address(0). - Fee + release.
fee = c.amount * feeRate / 1e18,net = c.amount - fee. Ifc.vaultthensafeTransferboth; elsebridgeMintboth. - Emit
BridgeMintedV2(messageHash, token, recipient, net, fee, epoch).
2.3 claimWithProof(env, c, proof) — destination chain (envelope + P3Q proof)
Algorithm:
- Refuse if
p3qVerifier == address(0)(P3QVerifierUnset). - Profile gate fires FIRST. Call
_refuseUnderStrictPQ(activeProfile, IPolicyId(address(p3qVerifier))). This is the SAME canonical helper as theonlyStrictPQmodifier onclaim, so both paths refuse identically. The reason it must fire first: a spoofed-policyp3qVerifierwould otherwise callverify, which could returntruefor arbitrary inputs. - P3Q proof check: refuse if
!p3qVerifier.verify(proof, env.messageHash). - Fall through to
_processClaim(env, c)(steps 1-7 fromclaim).
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
BridgeBurnedevents (v1) /BridgeBurnedV2events (v2). - Reconstruct the canonical
UnsignedMessage(ormessageHash) 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/:claimIdfor 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/signto the API service. - Validate
burnTxHash,fromChainId,logIndexat 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
resolveSchemeFromEnvfrommpc/src/signers/index.ts. - Sign via the
ThresholdSignerinterface (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_confirmationsper 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.solandILuxLightClient.solfor 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.