# 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 `, `gen.mjs chain ` | 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. ## 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.