diff --git a/contracts/bridge/script/Deploy.s.sol b/contracts/bridge/script/Deploy.s.sol index 095f8a3f5..610313995 100644 --- a/contracts/bridge/script/Deploy.s.sol +++ b/contracts/bridge/script/Deploy.s.sol @@ -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" ) ); } diff --git a/contracts/bridge/test/Vm.sol b/contracts/bridge/test/Vm.sol index 57f628e6c..a49f9ae46 100644 --- a/contracts/bridge/test/Vm.sol +++ b/contracts/bridge/test/Vm.sol @@ -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; diff --git a/contracts/bridge/test/vectors/gen.mjs b/contracts/bridge/test/vectors/gen.mjs index df273fc82..b654cc70b 100644 --- a/contracts/bridge/test/vectors/gen.mjs +++ b/contracts/bridge/test/vectors/gen.mjs @@ -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 > 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 > 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 '); 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 | chain '); process.exit(2); } diff --git a/docs/bridge/light-client-bridge.md b/docs/bridge/light-client-bridge.md new file mode 100644 index 000000000..f165c57b7 --- /dev/null +++ b/docs/bridge/light-client-bridge.md @@ -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 `, `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.