Bridge: docs/bridge/light-client-bridge.md (what the Sepolia verifier proves, the six things it does not, the path from here), the Devnet 3 table mode of the vector generator, the certificate-optional deploy script

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-08 10:45:56 +00:00
parent fa02dbcff2
commit 59f82c6dbd
4 changed files with 143 additions and 5 deletions

View file

@ -5,8 +5,8 @@ import {Vm, VM_ADDRESS} from "../test/Vm.sol";
import {IgneumCertificateVerifier} from "../src/IgneumCertificateVerifier.sol";
/// Deploys the verifier on Sepolia from BRIDGE_DEPLOYER_KEY (environment, never printed), installs the voter table
/// from the vectors file named in BRIDGE_TABLE_JSON (a gen.mjs output: keys, weights, index, chain_id) and submits
/// that file's certificate, so the deployed contract carries one Devnet 3 checkpoint proven final from the start.
/// from the vectors file named in BRIDGE_TABLE_JSON (a gen.mjs output: keys, weights, index, chain_id) and, when the
/// file carries a certificate (bitmap, signature), submits it, so the contract records one checkpoint final from the start.
///
/// BRIDGE_TABLE_JSON=test/vectors/chain.json forge script script/Deploy.s.sol:Deploy --rpc-url sepolia --broadcast --sig "run()"
contract Deploy {
@ -28,7 +28,10 @@ contract Deploy {
vm.startBroadcast(key);
IgneumCertificateVerifier v = new IgneumCertificateVerifier(vm.parseJsonString(j, ".chain_id"));
v.installTable(index, packed, weights);
v.submitCertificate(index, vm.parseJsonBytes32(j, ".checkpoint"), vm.parseJsonBytes(j, ".bitmap"), vm.parseJsonBytes(j, ".signature"));
bool hasCert = vm.keyExistsJson(j, ".bitmap");
if (hasCert) {
v.submitCertificate(index, vm.parseJsonBytes32(j, ".checkpoint"), vm.parseJsonBytes(j, ".bitmap"), vm.parseJsonBytes(j, ".signature"));
}
vm.stopBroadcast();
vm.writeFile(
@ -36,7 +39,7 @@ contract Deploy {
string.concat(
"{\n \"IgneumCertificateVerifier\": \"", vm.toString(address(v)), "\",\n \"chain_id\": \"", vm.parseJsonString(j, ".chain_id"),
"\",\n \"table_index\": ", vm.toString(uint256(index)), ",\n \"voters\": ", vm.toString(keys.length), ",\n \"table_id\": \"",
vm.toString(v.tableId()), "\",\n \"final_checkpoint\": \"", vm.toString(vm.parseJsonBytes32(j, ".checkpoint")), "\"\n}\n"
vm.toString(v.tableId()), "\",\n \"final_checkpoint\": \"", hasCert ? vm.toString(vm.parseJsonBytes32(j, ".checkpoint")) : "none yet", "\"\n}\n"
)
);
}

View file

@ -21,6 +21,7 @@ interface Vm {
function parseJsonBytesArray(string calldata json, string calldata key) external pure returns (bytes[] memory);
function parseJsonUintArray(string calldata json, string calldata key) external pure returns (uint256[] memory);
function parseJsonAddress(string calldata json, string calldata key) external pure returns (address);
function keyExistsJson(string calldata json, string calldata key) external view returns (bool);
function deal(address who, uint256 newBalance) external;
function prank(address msgSender) external;
function startPrank(address msgSender) external;

View file

@ -1,6 +1,8 @@
// Test vectors for the Igneum certificate verifier. Two sources:
// node gen.mjs synthetic > synthetic.json five vote keys made here (noble BLS12-381), a certificate signed by four
// of them over the Devnet 3 vote message, a small account trie with proofs
// node gen.mjs table <weights.json> > dn3-table.json the voter table alone from a node's igneum_getFinalityWeights answer
// (voters in the canonical order: sorted by key hash; weight = blocks)
// node gen.mjs chain <checkpoint.json> > dn3.json a real certificate as igneum.network/api/checkpoint?source=dn3 serves it:
// the voter table and the aggregate signature decompressed to the
// precompiles' encodings (the verifier checks the same bytes the node signed)
@ -89,7 +91,17 @@ function chain(file) {
};
}
function table(file) {
const d = JSON.parse(readFileSync(file, 'utf8'));
const r = d.result || d;
const voters = r.keys.filter(k => k.voter === true || k.voter === 'True').map(k => ({ keyHash: String(k.keyHash).replace(/^0x/, ''), key: hex(g1Enc(G1.fromHex(String(k.pubkey).replace(/^0x/, '')))), weight: Number(BigInt(k.blocks)) }));
voters.sort((a, b) => (a.keyHash < b.keyHash ? -1 : a.keyHash > b.keyHash ? 1 : 0));
const total = voters.reduce((a, v) => a + v.weight, 0);
return { chain_id: process.env.IGNEUM_CHAIN_ID || CHAIN_ID, dst: DST, index: Number(BigInt(r.checkpointIndex)), checkpoint_at_index: '0x' + String(r.checkpointHash).replace(/^0x/, ''), keys: voters.map(v => v.key), weights: voters.map(v => v.weight), total_weight: total, total_weight_node: Number(BigInt(r.totalWeight)), voters: voters.length };
}
const mode = process.argv[2];
if (mode === 'synthetic') synthetic().then(v => console.log(JSON.stringify(v, null, 1)));
else if (mode === 'chain') console.log(JSON.stringify(chain(process.argv[3]), null, 1));
else { console.error('usage: gen.mjs synthetic | chain <checkpoint.json>'); process.exit(2); }
else if (mode === 'table') console.log(JSON.stringify(table(process.argv[3]), null, 1));
else { console.error('usage: gen.mjs synthetic | table <weights.json> | chain <checkpoint.json>'); process.exit(2); }

View file

@ -0,0 +1,122 @@
# 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.