igneum/docs/bridge/light-client-bridge.md
2026-10-08 18:55:15 +00:00

11 KiB

The light-client bridge primitive (Devnet 3 to Ethereum Sepolia), 8 October 2026

Devnet 3, test tokens, no value. This is a design plus one working contract. It moves nothing. It proves two things on Sepolia and states, below, what it does not prove.

What exists

Piece Where What it does
The verifier contract contracts/bridge/src/IgneumCertificateVerifier.sol, deployed on Sepolia (address in docs/contracts/sepolia.json) holds a Devnet 3 voter table; verifies a finality certificate against it and records the checkpoint hash as final; verifies an Ethereum account proof against a state root
BLS12-381 contracts/bridge/src/BLS12381.sol hash-to-curve for G2 (RFC 9380, the node's DST), key aggregation in G1, the two-pairing check, all through the EIP-2537 precompiles
The account proof contracts/bridge/src/MerklePatricia.sol walks an eth_getProof-shaped proof (RLP nodes, hex-prefix paths, embedded children) to the account's RLP value or to its absence
The suite contracts/bridge/test/Verifier.t.sol, vectors from test/vectors/gen.mjs 9 tests on Foundry's Prague EVM: the vote message byte for byte, RFC 9380 expand_message_xmd answers, a certificate by four of five made-up keys, one under two thirds refused, forged index and checkpoint refused, the recorded checkpoint, an account present and an account absent under one root, a wrong root refused, and one certificate the chain actually carried
The vectors gen.mjs synthetic, gen.mjs table <weights.json>, gen.mjs chain <checkpoint.json> made-up keys and a three-account trie; the Devnet 3 voter table from a node's igneum_getFinalityWeights; a real certificate as /api/checkpoint serves it, keys and signature decompressed to the precompiles' encodings

The suite ran green on build-3 (9 of 9) before the deploy; the Sepolia address and the deploy transactions are in docs/contracts/sepolia.json.

What the certificate check proves

A certificate is (index, checkpoint hash, bitmap, aggregate signature) as consensus/core/src/finality.rs writes it into a block's coinbase (Certificate::write: index_le64, checkpoint, voter_count_le32, bitmap_len_le32, bitmap, signature, aggregator, aggregator proof). The contract takes the first four fields; the signature is the 96-byte G2 point decompressed off chain to the precompiles' 256-byte form (a wrong decompression is a point the pairing precompile refuses).

The contract holds a voter table installed by its deployer: the canonical voter list at one checkpoint index (every key above dust and not stripped, sorted by key hash, as the node's igneum_getFinalityWeights reports it), each key a 128-byte uncompressed G1 point, each weight the key's blue blocks in the 30-day window. tableId is the keccak of (index, keys, weights).

verifyCertificate(index, checkpoint, bitmap, signature) holds exactly when:

  1. bit p of the bitmap names voter p of the table, and the sum of the named voters' weights is at least two thirds of the table's total weight (the rule decided 4 October 2026: lock = 2/3 of all 30-day weight; stricter than the 17/30 floor plus 2/3 of active that the node line still carries, so every certificate the node locks under the new rule passes here and some the old rule locked would not);
  2. the aggregate of the named keys (G1 additions) verifies the signature over the vote message "igneum-vote-v1/" || chain_id || 0x00 || index_le64 || checkpoint under the domain separation tag IGNEUM_VOTE_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_, with chain_id the string the contract was built with (igneum-devnet-3): e(aggregate key, H(message)) * e(-G1, signature) = 1.

So a recorded finalCheckpoint(index) means: the keys in the installed table that hold at least two thirds of that table's weight signed this checkpoint hash at this index on this chain id. That is the finality rule's own statement, checked with the node's own bytes, by a contract on another chain.

Measured on the test EVM: a 29-voter table costs about 5.3 million gas to install; a certificate with 12 signers verifies in about 3.9 million gas (the pairing and the two map-to-curve calls dominate; a G1 addition per signer is 375 gas). On Sepolia at a 1 gwei tip that is under 0.01 ETH per certificate.

Recovery locks (finality rule v4's majority-continuity recovery after an empty window, review B item F04) are not a kind the contract knows: the certificate bytes carry no lock label, so the verifier reads only weight against the installed table and accepts the final rule alone. A certificate signed by over half but under two thirds of the table returns ok = false and submitCertificate reverts: a recovery lock is never recorded as final, and a consumer that needs recovery locks must carry its own state, since nothing here tells them apart.

What the account proof proves

verifyAccount(stateRoot, account, proof) walks an Ethereum account proof (the eth_getProof shape: RLP nodes from the root, keys hashed with keccak, the hex-prefix leaf and extension paths, children by hash or embedded when under 32 bytes) and returns the account's nonce, balance, storage root and code hash, or exists = false when the trie shows the account absent. This is the trie Igneum's executor commits to: igneum/exec/src/state.rs computes stateRoot with alloy_trie::root::state_root over keccak256(address) and RLP(nonce, balance, storage_root, code_hash), EIP-161 empty accounts left out, reth's layout, the same as Ethereum's.

So a verified account proof means: under this state root, this account has these fields.

What is NOT proven, in order of weight

  1. The link from a certified checkpoint to a state root. Nothing the contract verifies ties a checkpoint hash to an EVM state root. The Kaspa-shaped header the voters sign has no execution root (its utxo_commitment is the UTXO multiset, accepted_id_merkle_root the accepted transaction ids); the executor runs behind consensus as a follower and the EVM block hash is the DAG block hash, not a hash of the EVM header. The binding that exists today is in the coinbase of later blocks: the IGNS segment record (BlockStatement.post_root for its last chain block, signed by the aggregator's vote key) and the proof records (ProofRecord.statement = keccak of a ShardOutput carrying pre_root and post_root, signed by the prover's vote key), both under the carrier block's hash_merkle_root. A carried record that fails a node's native veto is ignored rather than faulting the block, so even that binding rests on the aggregator's and prover's keys and on the nodes' native check, not on a header field. Today a caller gives the verifier a state root as a stated input beside a verified certificate; the contract does not know they belong together. The reference-apps lane's oracle verifies the coinbase path (BLAKE2b header hash, the merkle branch, the record parse) on Sepolia and shares this verifier for the certificate; the two together are the full chain once the record's signature check lands there.
  2. The voter table itself. The table is installed by the deployer from a node's igneum_getFinalityWeights read: the keys, their canonical order (sorted by BLAKE2b-256("IgneumVoteKeyHash", key), which the contract does not recompute: no BLAKE2b on chain today) and their weights are trusted. A wrong table makes a wrong verdict in both directions. A light client proper tracks the table from the chain: every weight is a count of blue blocks whose headers name the key, so the table follows from headers; and the node's FinalityWeights RPC reports the table at the certificate's own index (voters_at_index). The next step is a table update that takes a certified checkpoint plus the headers between locks, the shape /api/checkpoint already serves to the browser verifier (site/verify/core.js checks exactly that path, in JavaScript).
  3. The checkpoint index is a number the submitter gives. The contract records one hash per index and refuses a second, but it does not know the chain's current index; an old certificate at an old index verifies for ever against the table it was signed under. A consumer should read finalCheckpoint at an index it already knows from the chain, not treat the newest recorded index as the chain's tip.
  4. Equivocation and stripping. The node strips a key's weight for 30 days on equivocation evidence. The installed table carries the stripping as of its read and nothing after it.
  5. Proof of work, the DAG order, execution correctness. None of it is checked here. The certificate's claim is the voters' signature, and the voters are the miners who proved their blocks; the ZK proofs of execution (the shard proofs the records name) are verified by the nodes, not by this contract. A proof-carrying bridge (the chain's SP1 shard proofs verified on Ethereum) is the design's end state and is not today's primitive.
  6. The state root's age and the account's current balance. An account proof says what the balance was under that root; the root is one block's. Nothing here prevents a stale root from being presented.

How one balance gets proven on Sepolia today, and what each step rests on

Step Source Rests on
the voter table at index N a Devnet 3 node's igneum_getFinalityWeights the node, the installer (trusted today)
the certificate for checkpoint C at index N /api/checkpoint?source=dn3 (the observer's dn3_live_certificates, the bytes a block carried) verified on chain: the signature and the two-thirds rule
the state root R of chain block B eth_getBlockByNumber on Devnet 3 stated, not proven against C (gap 1)
the account proof for A under R eth_getProof on the reference-apps lane's reader node (fork branch light-apps-node, the Devnet 3 pin plus a read-only RPC) verified on chain against R

The test test_account_proof_present_and_absent proves the account under a made-up root; the Devnet 3 account proof against a real root goes into the suite as test/vectors/dn3-account.json the moment the reader node serves eth_getProof (the proof shape is Ethereum's, so the contract needs no change).

The design from here

  1. The header path on chain: BLAKE2b-256 through the EIP-152 precompile for the keyed header hash and the key hashes, the merkle branch to the coinbase, the segment and proof records parsed from the coinbase payload. This closes gap 1 and lets the contract recompute canonical order (gap 2's order).
  2. The table update from certified headers: weights counted from the blue blocks between two locks, so the table follows the chain instead of an installer. This closes gap 2.
  3. A tip rule: the contract keeps the highest index it has recorded and a consumer reads only at or below it; the relayer submits each lock as it lands (one transaction per 30-second checkpoint is affordable on Sepolia, not on mainnet; mainnet gets one certificate per epoch).
  4. The proof-carrying bridge: the shard proofs' aggregate verified on Ethereum (the prover network's own product), which replaces trust in the native veto with a verified execution root.