122 lines
10 KiB
Markdown
122 lines
10 KiB
Markdown
# 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.
|
|
|
|
## 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.
|