From 8288172b87cdbb59bf06fad503fd3dfcd14164c4 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:40:17 +0000 Subject: [PATCH] Bridge: the Igneum certificate verifier in Solidity (BLS12-381 through the EIP-2537 precompiles, RFC 9380 hash-to-curve with the node's DST, the 2/3-of-total rule over an installed voter table, the Ethereum account proof), its Foundry suite (9 green on build-3, one certificate the old devnet carried verifies) and the vector generator Co-Authored-By: Claude Fable 5.1 --- contracts/bridge/foundry.toml | 18 ++ contracts/bridge/script/Deploy.s.sol | 43 ++++ contracts/bridge/script/Send.s.sol | 22 ++ contracts/bridge/src/BLS12381.sol | 113 +++++++++ .../bridge/src/IgneumCertificateVerifier.sol | 155 +++++++++++++ contracts/bridge/src/MerklePatricia.sol | 214 ++++++++++++++++++ contracts/bridge/test/Verifier.t.sol | 126 +++++++++++ contracts/bridge/test/Vm.sol | 33 +++ contracts/bridge/test/vectors/.gitignore | 5 + contracts/bridge/test/vectors/gen.mjs | 95 ++++++++ 10 files changed, 824 insertions(+) create mode 100644 contracts/bridge/foundry.toml create mode 100644 contracts/bridge/script/Deploy.s.sol create mode 100644 contracts/bridge/script/Send.s.sol create mode 100644 contracts/bridge/src/BLS12381.sol create mode 100644 contracts/bridge/src/IgneumCertificateVerifier.sol create mode 100644 contracts/bridge/src/MerklePatricia.sol create mode 100644 contracts/bridge/test/Verifier.t.sol create mode 100644 contracts/bridge/test/Vm.sol create mode 100644 contracts/bridge/test/vectors/.gitignore create mode 100644 contracts/bridge/test/vectors/gen.mjs diff --git a/contracts/bridge/foundry.toml b/contracts/bridge/foundry.toml new file mode 100644 index 000000000..f23f953a6 --- /dev/null +++ b/contracts/bridge/foundry.toml @@ -0,0 +1,18 @@ +[profile.default] +src = "src" +test = "test" +script = "script" +out = "out" +libs = [] +solc_version = "0.8.28" +# The verifier calls the BLS12-381 precompiles of EIP-2537 (live on Sepolia and mainnet since Pectra), so the test EVM +# runs the Prague rules. +evm_version = "prague" +optimizer = true +optimizer_runs = 200 +via_ir = true +fs_permissions = [{ access = "read", path = "./test/vectors" }, { access = "read-write", path = "./deploy-out.json" }] +auto_detect_remappings = false + +[rpc_endpoints] +sepolia = "https://ethereum-sepolia-rpc.publicnode.com" diff --git a/contracts/bridge/script/Deploy.s.sol b/contracts/bridge/script/Deploy.s.sol new file mode 100644 index 000000000..095f8a3f5 --- /dev/null +++ b/contracts/bridge/script/Deploy.s.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +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. +/// +/// BRIDGE_TABLE_JSON=test/vectors/chain.json forge script script/Deploy.s.sol:Deploy --rpc-url sepolia --broadcast --sig "run()" +contract Deploy { + Vm constant vm = Vm(VM_ADDRESS); + + function run() external { + uint256 key = vm.envUint("BRIDGE_DEPLOYER_KEY"); + string memory j = vm.readFile(vm.envOr("BRIDGE_TABLE_JSON", "test/vectors/chain.json")); + bytes[] memory keys = vm.parseJsonBytesArray(j, ".keys"); + uint256[] memory w = vm.parseJsonUintArray(j, ".weights"); + bytes memory packed; + uint64[] memory weights = new uint64[](w.length); + for (uint256 i = 0; i < keys.length; i++) { + packed = abi.encodePacked(packed, keys[i]); + weights[i] = uint64(w[i]); + } + uint64 index = uint64(vm.parseJsonUint(j, ".index")); + + 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")); + vm.stopBroadcast(); + + vm.writeFile( + "deploy-out.json", + 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" + ) + ); + } +} diff --git a/contracts/bridge/script/Send.s.sol b/contracts/bridge/script/Send.s.sol new file mode 100644 index 000000000..f64168556 --- /dev/null +++ b/contracts/bridge/script/Send.s.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Vm, VM_ADDRESS} from "../test/Vm.sol"; + +/// Sends SEND_WEI of the chain's coin from the key in BRIDGE_DEPLOYER_KEY to SEND_TO (Sepolia test ETH between the +/// lanes' throwaway deployers). The key is read from the environment and never printed. +/// +/// SEND_TO=0x.. SEND_WEI=20000000000000000 forge script script/Send.s.sol:Send --rpc-url sepolia --broadcast --sig "run()" +contract Send { + Vm constant vm = Vm(VM_ADDRESS); + + function run() external { + uint256 key = vm.envUint("BRIDGE_DEPLOYER_KEY"); + address to = vm.envAddress("SEND_TO"); + uint256 wei_ = vm.envUint("SEND_WEI"); + vm.startBroadcast(key); + (bool ok,) = to.call{value: wei_}(""); + require(ok, "send failed"); + vm.stopBroadcast(); + } +} diff --git a/contracts/bridge/src/BLS12381.sol b/contracts/bridge/src/BLS12381.sol new file mode 100644 index 000000000..69b842de8 --- /dev/null +++ b/contracts/bridge/src/BLS12381.sol @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// BLS12-381 through the EIP-2537 precompiles (Ethereum mainnet and Sepolia since Pectra): the hash-to-curve of +/// RFC 9380 (BLS12381G2_XMD:SHA-256_SSWU_RO_) with the caller's domain separation tag, public-key aggregation in G1 +/// and the two-pairing check of a "minimal public key" signature (keys in G1, signatures in G2), the scheme of +/// Igneum's finality votes (consensus/core/src/finality.rs, blst "min_pk"). +/// +/// Encodings are the precompiles' own: a field element is 64 bytes (16 zero bytes then the 48-byte big-endian +/// value), a G1 point 128 bytes (x, y), a G2 point 256 bytes (x.c0, x.c1, y.c0, y.c1). Compressed chain forms +/// (48-byte keys, 96-byte signatures) are decompressed off chain by the submitter; the pairing precompile refuses +/// a point off the curve or outside the prime-order subgroup, so a wrong decompression fails the check. +library BLS12381 { + address internal constant G1ADD = address(0x0b); + address internal constant G2ADD = address(0x0d); + address internal constant PAIRING = address(0x0f); + address internal constant MAP_FP2_TO_G2 = address(0x11); + address internal constant MODEXP = address(0x05); + + uint256 internal constant G1_LEN = 128; + uint256 internal constant G2_LEN = 256; + + /// The field modulus p, big-endian, 48 bytes (the modexp precompile's modulus). + bytes internal constant P = hex"1a0111ea397fe69a4b1ba7b6434bacd764774b84f38512bf6730d2a0f6b0f6241eabfffeb153ffffb9feffffffffaaab"; + + /// The G1 generator with its y negated (p - y), in the 128-byte encoding, for the pairing check + /// e(pk, H(m)) * e(-G1, sig) == 1. + bytes internal constant NEG_G1 = + hex"0000000000000000000000000000000017f1d3a73197d7942695638c4fa9ac0fc3688c4f9774b905a14e3a3f171bac586c55e83ff97a1aeffb3af00adb22c6bb" + hex"00000000000000000000000000000000114d1d6855d545a8aa7d76c8cf2e21f267816aef1db507c96655b9d5caac42364e6f38ba0ecb751bad54dcd6b939c2ca"; + + error PrecompileFailed(address which); + error BadLength(string what); + + // ---- hash to curve ---- + + /// expand_message_xmd with SHA-256 (RFC 9380 section 5.3.1) to `len` bytes; len at most 255 * 32. + function expandMessageXmd(bytes memory msg_, bytes memory dst, uint256 len) internal pure returns (bytes memory out) { + require(dst.length <= 255, "BLS: DST too long"); + uint256 ell = (len + 31) / 32; + require(ell <= 255 && len > 0, "BLS: bad length"); + bytes memory dstPrime = abi.encodePacked(dst, uint8(dst.length)); + bytes32 b0 = sha256(abi.encodePacked(new bytes(64), msg_, uint16(len), uint8(0), dstPrime)); + bytes32 bi = sha256(abi.encodePacked(b0, uint8(1), dstPrime)); + out = new bytes(ell * 32); + assembly { + mstore(add(out, 32), bi) + } + for (uint256 i = 2; i <= ell; i++) { + bi = sha256(abi.encodePacked(b0 ^ bi, uint8(i), dstPrime)); + assembly { + mstore(add(add(out, 32), mul(sub(i, 1), 32)), bi) + } + } + assembly { + mstore(out, len) + } + } + + /// A 64-byte big-endian integer reduced mod p and returned in the precompiles' 64-byte field encoding. + function reduce64(bytes memory chunk, uint256 offset) internal view returns (bytes memory fe) { + require(chunk.length >= offset + 64, "BLS: chunk"); + bytes memory base = new bytes(64); + for (uint256 i = 0; i < 64; i++) { + base[i] = chunk[offset + i]; + } + // modexp(base^1 mod p): lengths 64, 1, 48 + bytes memory input = abi.encodePacked(uint256(64), uint256(1), uint256(48), base, uint8(1), P); + (bool ok, bytes memory r) = MODEXP.staticcall(input); + if (!ok || r.length != 48) revert PrecompileFailed(MODEXP); + fe = abi.encodePacked(bytes16(0), r); + } + + /// hash_to_curve for G2: two field elements of Fp2 from a 256-byte expansion, each mapped by the precompile + /// (which clears the cofactor), then added. + function hashToG2(bytes memory msg_, bytes memory dst) internal view returns (bytes memory point) { + bytes memory u = expandMessageXmd(msg_, dst, 256); + bytes memory q0 = mapFp2ToG2(abi.encodePacked(reduce64(u, 0), reduce64(u, 64))); + bytes memory q1 = mapFp2ToG2(abi.encodePacked(reduce64(u, 128), reduce64(u, 192))); + point = g2Add(q0, q1); + } + + function mapFp2ToG2(bytes memory fp2) internal view returns (bytes memory point) { + if (fp2.length != 128) revert BadLength("fp2"); + (bool ok, bytes memory r) = MAP_FP2_TO_G2.staticcall(fp2); + if (!ok || r.length != G2_LEN) revert PrecompileFailed(MAP_FP2_TO_G2); + point = r; + } + + // ---- group operations ---- + + function g1Add(bytes memory a, bytes memory b) internal view returns (bytes memory c) { + if (a.length != G1_LEN || b.length != G1_LEN) revert BadLength("g1"); + (bool ok, bytes memory r) = G1ADD.staticcall(abi.encodePacked(a, b)); + if (!ok || r.length != G1_LEN) revert PrecompileFailed(G1ADD); + c = r; + } + + function g2Add(bytes memory a, bytes memory b) internal view returns (bytes memory c) { + if (a.length != G2_LEN || b.length != G2_LEN) revert BadLength("g2"); + (bool ok, bytes memory r) = G2ADD.staticcall(abi.encodePacked(a, b)); + if (!ok || r.length != G2_LEN) revert PrecompileFailed(G2ADD); + c = r; + } + + /// e(pk, hm) * e(-G1, sig) == 1, which holds exactly when sig = sk * hm for pk = sk * G1. + function verifyMinPk(bytes memory pk, bytes memory hm, bytes memory sig) internal view returns (bool) { + if (pk.length != G1_LEN || hm.length != G2_LEN || sig.length != G2_LEN) revert BadLength("pairing"); + (bool ok, bytes memory r) = PAIRING.staticcall(abi.encodePacked(pk, hm, NEG_G1, sig)); + if (!ok || r.length != 32) return false; + return abi.decode(r, (uint256)) == 1; + } +} diff --git a/contracts/bridge/src/IgneumCertificateVerifier.sol b/contracts/bridge/src/IgneumCertificateVerifier.sol new file mode 100644 index 000000000..c0154b75d --- /dev/null +++ b/contracts/bridge/src/IgneumCertificateVerifier.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {BLS12381} from "./BLS12381.sol"; +import {MerklePatricia} from "./MerklePatricia.sol"; + +interface IIgneumCertificateVerifier { + function chainId() external view returns (string memory); + function tableId() external view returns (bytes32); + function verifyCertificate(uint64 index, bytes32 checkpoint, bytes calldata bitmap, bytes calldata signature) + external + view + returns (bool ok, uint256 signedWeight, uint256 totalWeight); + function submitCertificate(uint64 index, bytes32 checkpoint, bytes calldata bitmap, bytes calldata signature) external; + function finalCheckpoint(uint64 index) external view returns (bytes32); + function isFinal(bytes32 checkpoint) external view returns (bool); + function verifyAccount(bytes32 stateRoot, address account, bytes[] calldata proof) + external + pure + returns (bool exists, uint256 nonce, uint256 balance, bytes32 storageRoot, bytes32 codeHash); +} + +/// The Igneum light-client bridge primitive on Ethereum: verifies a Devnet 3 finality certificate (the aggregate +/// BLS signature of the canonical voter list over the vote message, under the 2/3-of-total-weight rule) against an +/// installed voter table, records the checkpoint hashes it proved final, and verifies an Ethereum-shape account +/// proof against a state root. What it proves and what it does not: docs/bridge/light-client-bridge.md. +/// +/// Devnet 3, test tokens, no value. +contract IgneumCertificateVerifier is IIgneumCertificateVerifier { + using MerklePatricia for bytes32; + + string public constant VOTE_PREFIX = "igneum-vote-v1/"; + bytes public constant DST_VOTE = "IGNEUM_VOTE_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_"; + + string private _chainId; + address public owner; + + /// The canonical voter list at the installed checkpoint index: 128-byte G1 keys in the node's canonical order + /// (sorted by key hash, every key above dust and not stripped) and their weights (blue blocks in the window). + bytes[] private _keys; + uint64[] private _weights; + uint256 public totalWeight; + uint64 public tableIndex; + bytes32 public override tableId; + + mapping(uint64 => bytes32) public override finalCheckpoint; + mapping(bytes32 => bool) public override isFinal; + + event TableInstalled(uint64 indexed atIndex, uint256 voters, uint256 totalWeight, bytes32 tableId); + event CheckpointFinal(uint64 indexed index, bytes32 checkpoint, uint256 signedWeight, uint256 totalWeight, uint256 signers); + + error NotOwner(); + error NoTable(); + error BadCertificate(string why); + + constructor(string memory chainId_) { + _chainId = chainId_; + owner = msg.sender; + } + + function chainId() external view override returns (string memory) { + return _chainId; + } + + function voterCount() external view returns (uint256) { + return _keys.length; + } + + function voter(uint256 i) external view returns (bytes memory key, uint64 weight) { + return (_keys[i], _weights[i]); + } + + /// Installs the voter table read from a Devnet 3 node (igneum_getFinalityWeights at `atIndex`): `keys` is the + /// concatenation of 128-byte uncompressed G1 keys in canonical order, `weights` their weights. The table is a + /// trusted input of this first version (see the doc); only the installer may replace it. + function installTable(uint64 atIndex, bytes calldata keys, uint64[] calldata weights) external { + if (msg.sender != owner) revert NotOwner(); + if (keys.length != weights.length * BLS12381.G1_LEN || weights.length == 0) revert BadCertificate("table shape"); + delete _keys; + delete _weights; + uint256 total; + for (uint256 i = 0; i < weights.length; i++) { + _keys.push(keys[i * BLS12381.G1_LEN:(i + 1) * BLS12381.G1_LEN]); + _weights.push(weights[i]); + total += weights[i]; + } + totalWeight = total; + tableIndex = atIndex; + tableId = keccak256(abi.encodePacked(atIndex, keys, abi.encodePacked(weights))); + emit TableInstalled(atIndex, weights.length, total, tableId); + } + + /// The bytes every voter signs for (index, checkpoint): "igneum-vote-v1/" chain_id 0x00 index_le64 checkpoint. + function voteMessage(uint64 index, bytes32 checkpoint) public view returns (bytes memory) { + return abi.encodePacked(VOTE_PREFIX, _chainId, bytes1(0), le64(index), checkpoint); + } + + function verifyCertificate(uint64 index, bytes32 checkpoint, bytes calldata bitmap, bytes calldata signature) + public + view + override + returns (bool ok, uint256 signedWeight, uint256 totalWeight_) + { + uint256 n = _keys.length; + if (n == 0) revert NoTable(); + if (bitmap.length != (n + 7) / 8) revert BadCertificate("bitmap length"); + if (signature.length != BLS12381.G2_LEN) revert BadCertificate("signature length"); + bytes memory agg; + uint256 signers; + for (uint256 p = 0; p < n; p++) { + if (uint8(bitmap[p >> 3]) & uint8(1 << (p & 7)) == 0) continue; + signedWeight += _weights[p]; + signers++; + agg = agg.length == 0 ? _keys[p] : BLS12381.g1Add(agg, _keys[p]); + } + totalWeight_ = totalWeight; + if (signers == 0) return (false, 0, totalWeight_); + // the rule decided 4 October 2026: signed weight at least two thirds of the whole window's weight + if (3 * signedWeight < 2 * totalWeight_) return (false, signedWeight, totalWeight_); + bytes memory hm = BLS12381.hashToG2(voteMessage(index, checkpoint), DST_VOTE); + ok = BLS12381.verifyMinPk(agg, hm, signature); + } + + function submitCertificate(uint64 index, bytes32 checkpoint, bytes calldata bitmap, bytes calldata signature) external override { + (bool ok, uint256 signed, uint256 total) = verifyCertificate(index, checkpoint, bitmap, signature); + if (!ok) revert BadCertificate("certificate does not verify"); + bytes32 known = finalCheckpoint[index]; + if (known != bytes32(0) && known != checkpoint) revert BadCertificate("a different checkpoint is final at this index"); + finalCheckpoint[index] = checkpoint; + isFinal[checkpoint] = true; + uint256 signers; + for (uint256 p = 0; p < _keys.length; p++) { + if (uint8(bitmap[p >> 3]) & uint8(1 << (p & 7)) != 0) signers++; + } + emit CheckpointFinal(index, checkpoint, signed, total, signers); + } + + function verifyAccount(bytes32 stateRoot, address account, bytes[] calldata proof) + external + pure + override + returns (bool exists, uint256 nonce, uint256 balance, bytes32 storageRoot, bytes32 codeHash) + { + MerklePatricia.Account memory a = MerklePatricia.verifyAccount(stateRoot, account, proof); + return (a.exists, a.nonce, a.balance, a.storageRoot, a.codeHash); + } + + function le64(uint64 v) internal pure returns (bytes8 out) { + uint64 r; + for (uint256 i = 0; i < 8; i++) { + r = (r << 8) | ((v >> (8 * i)) & 0xff); + } + out = bytes8(r); + } +} diff --git a/contracts/bridge/src/MerklePatricia.sol b/contracts/bridge/src/MerklePatricia.sol new file mode 100644 index 000000000..511c9c821 --- /dev/null +++ b/contracts/bridge/src/MerklePatricia.sol @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// An Ethereum account proof (the eth_getProof shape) checked against a state root: the keccak-keyed Merkle +/// Patricia trie of reth's layout, which Igneum's executor uses for its stateRoot (igneum/exec/src/state.rs, +/// alloy_trie::root::state_root over keccak256(address) keys and RLP(nonce, balance, storageRoot, codeHash) +/// values). A proof is the list of RLP nodes from the root to the account's leaf, or to the branch or leaf that +/// shows the account absent. +library MerklePatricia { + struct Account { + bool exists; + uint256 nonce; + uint256 balance; + bytes32 storageRoot; + bytes32 codeHash; + } + + error BadProof(string why); + + /// Verifies `proof` for `account` under `stateRoot`; reverts when a node does not hash to its reference or + /// the path is malformed, returns exists=false when the trie shows no such account. + function verifyAccount(bytes32 stateRoot, address account, bytes[] memory proof) internal pure returns (Account memory out) { + bytes memory value = verifyPath(stateRoot, abi.encodePacked(keccak256(abi.encodePacked(account))), proof); + if (value.length == 0) return out; + (uint256 off, uint256 len, bool isList) = decode(value, 0); + if (!isList) revert BadProof("account value is not a list"); + uint256 end = off + len; + uint256 p = off; + (uint256 o1, uint256 l1,) = decode(value, p); + out.nonce = toUint(value, o1, l1); + p = o1 + l1; + (uint256 o2, uint256 l2,) = decode(value, p); + out.balance = toUint(value, o2, l2); + p = o2 + l2; + (uint256 o3, uint256 l3,) = decode(value, p); + if (l3 != 32) revert BadProof("storage root length"); + out.storageRoot = toBytes32(value, o3); + p = o3 + l3; + (uint256 o4, uint256 l4,) = decode(value, p); + if (l4 != 32) revert BadProof("code hash length"); + out.codeHash = toBytes32(value, o4); + if (o4 + l4 != end) revert BadProof("account value has extra fields"); + out.exists = true; + } + + /// Walks the proof for `key` (32 bytes, hashed already) from `root`; returns the value found, or empty bytes + /// when the trie proves the key absent. + function verifyPath(bytes32 root, bytes memory key, bytes[] memory proof) internal pure returns (bytes memory value) { + bytes memory nibbles = toNibbles(key); + uint256 pos = 0; + bytes32 want = root; + bytes memory embedded; + for (uint256 i = 0; i < proof.length; i++) { + bytes memory node = proof[i]; + if (embedded.length != 0) { + if (keccak256(node) != keccak256(embedded)) revert BadProof("embedded node mismatch"); + embedded = ""; + } else if (keccak256(node) != want) { + revert BadProof("node hash mismatch"); + } + (uint256 off, uint256 len, bool isList) = decode(node, 0); + if (!isList) revert BadProof("node is not a list"); + uint256 count = itemCount(node, off, len); + if (count == 17) { + if (pos == nibbles.length) { + // the branch's own value slot + (uint256 vo, uint256 vl,) = itemAt(node, off, 16); + return slice(node, vo, vl); + } + uint8 nib = uint8(nibbles[pos]); + (uint256 co, uint256 cl, bool clist) = itemAt(node, off, nib); + if (cl == 0 && !clist) return ""; // empty slot: the key is absent + pos++; + if (clist) { + embedded = slice(node, co - headerLen(node, co, cl, true), cl + headerLen(node, co, cl, true)); + } else { + if (cl != 32) revert BadProof("child reference length"); + want = toBytes32(node, co); + } + } else if (count == 2) { + (uint256 po, uint256 pl,) = itemAt(node, off, 0); + (bytes memory path, bool isLeaf) = decodePath(slice(node, po, pl)); + if (!matches(nibbles, pos, path)) return ""; // diverging path: the key is absent + pos += path.length; + (uint256 vo, uint256 vl, bool vlist) = itemAt(node, off, 1); + if (isLeaf) { + if (pos != nibbles.length) revert BadProof("leaf before the key's end"); + return slice(node, vo, vl); + } + if (vlist) { + embedded = slice(node, vo - headerLen(node, vo, vl, true), vl + headerLen(node, vo, vl, true)); + } else { + if (vl != 32) revert BadProof("extension reference length"); + want = toBytes32(node, vo); + } + } else { + revert BadProof("node arity"); + } + } + revert BadProof("proof ends before the key"); + } + + // ---- paths ---- + + function toNibbles(bytes memory key) internal pure returns (bytes memory n) { + n = new bytes(key.length * 2); + for (uint256 i = 0; i < key.length; i++) { + n[2 * i] = bytes1(uint8(key[i]) >> 4); + n[2 * i + 1] = bytes1(uint8(key[i]) & 0x0f); + } + } + + /// Hex-prefix decoding of a leaf or extension path. + function decodePath(bytes memory hp) internal pure returns (bytes memory path, bool isLeaf) { + if (hp.length == 0) revert BadProof("empty path"); + uint8 flag = uint8(hp[0]) >> 4; + isLeaf = flag >= 2; + bool odd = flag % 2 == 1; + uint256 n = (hp.length - 1) * 2 + (odd ? 1 : 0); + path = new bytes(n); + uint256 w = 0; + if (odd) path[w++] = bytes1(uint8(hp[0]) & 0x0f); + for (uint256 i = 1; i < hp.length; i++) { + path[w++] = bytes1(uint8(hp[i]) >> 4); + path[w++] = bytes1(uint8(hp[i]) & 0x0f); + } + } + + function matches(bytes memory nibbles, uint256 pos, bytes memory path) internal pure returns (bool) { + if (pos + path.length > nibbles.length) return false; + for (uint256 i = 0; i < path.length; i++) { + if (nibbles[pos + i] != path[i]) return false; + } + return true; + } + + // ---- RLP ---- + + /// The item at `p`: the offset of its payload, the payload length and whether it is a list. + function decode(bytes memory b, uint256 p) internal pure returns (uint256 off, uint256 len, bool isList) { + if (p >= b.length) revert BadProof("rlp out of range"); + uint8 first = uint8(b[p]); + if (first < 0x80) return (p, 1, false); + if (first < 0xb8) return (p + 1, first - 0x80, false); + if (first < 0xc0) { + uint256 n = first - 0xb7; + return (p + 1 + n, readLen(b, p + 1, n), false); + } + if (first < 0xf8) return (p + 1, first - 0xc0, true); + uint256 m = first - 0xf7; + return (p + 1 + m, readLen(b, p + 1, m), true); + } + + function headerLen(bytes memory b, uint256 off, uint256 len, bool isList) private pure returns (uint256) { + // the header length of an item whose payload starts at off: single bytes under 0x80 have none + if (!isList && len == 1 && uint8(b[off]) < 0x80) return 0; + if (len < 56) return 1; + uint256 n = 0; + uint256 l = len; + while (l > 0) { + n++; + l >>= 8; + } + return 1 + n; + } + + function readLen(bytes memory b, uint256 p, uint256 n) private pure returns (uint256 len) { + if (n == 0 || n > 32 || p + n > b.length) revert BadProof("rlp length"); + for (uint256 i = 0; i < n; i++) { + len = (len << 8) | uint8(b[p + i]); + } + } + + function itemCount(bytes memory b, uint256 off, uint256 len) private pure returns (uint256 n) { + uint256 p = off; + uint256 end = off + len; + while (p < end) { + (uint256 o, uint256 l,) = decode(b, p); + p = o + l; + n++; + } + if (p != end) revert BadProof("rlp list overrun"); + } + + function itemAt(bytes memory b, uint256 off, uint256 index) private pure returns (uint256 o, uint256 l, bool isList) { + uint256 p = off; + for (uint256 i = 0; ; i++) { + (o, l, isList) = decode(b, p); + if (i == index) return (o, l, isList); + p = o + l; + } + } + + function toUint(bytes memory b, uint256 off, uint256 len) private pure returns (uint256 v) { + if (len > 32) revert BadProof("integer too long"); + for (uint256 i = 0; i < len; i++) { + v = (v << 8) | uint8(b[off + i]); + } + } + + function toBytes32(bytes memory b, uint256 off) private pure returns (bytes32 v) { + assembly { + v := mload(add(add(b, 32), off)) + } + } + + function slice(bytes memory b, uint256 off, uint256 len) private pure returns (bytes memory out) { + if (off + len > b.length) revert BadProof("slice out of range"); + out = new bytes(len); + for (uint256 i = 0; i < len; i++) { + out[i] = b[off + i]; + } + } +} diff --git a/contracts/bridge/test/Verifier.t.sol b/contracts/bridge/test/Verifier.t.sol new file mode 100644 index 000000000..0018d7547 --- /dev/null +++ b/contracts/bridge/test/Verifier.t.sol @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Vm, VM_ADDRESS} from "./Vm.sol"; +import {IgneumCertificateVerifier} from "../src/IgneumCertificateVerifier.sol"; +import {BLS12381} from "../src/BLS12381.sol"; + +/// The verifier on Foundry's Prague EVM (the EIP-2537 precompiles): the synthetic vectors made by +/// test/vectors/gen.mjs (five keys, a certificate by four of them, a small account trie) and, when present, a real +/// certificate from the chain (test/vectors/chain.json, as /api/checkpoint serves it, decompressed by gen.mjs). +contract VerifierTest { + Vm constant vm = Vm(VM_ADDRESS); + + string json; + IgneumCertificateVerifier v; + + function setUp() public { + json = vm.readFile("test/vectors/synthetic.json"); + v = new IgneumCertificateVerifier(vm.parseJsonString(json, ".chain_id")); + _install(v, json); + } + + function _install(IgneumCertificateVerifier target, string memory j) internal { + bytes[] memory keys = vm.parseJsonBytesArray(j, ".keys"); + uint256[] memory w = vm.parseJsonUintArray(j, ".weights"); + bytes memory packed; + uint64[] memory weights = new uint64[](w.length); + for (uint256 i = 0; i < keys.length; i++) { + packed = abi.encodePacked(packed, keys[i]); + weights[i] = uint64(w[i]); + } + target.installTable(uint64(vm.parseJsonUint(j, ".index")), packed, weights); + } + + function test_vote_message_matches_the_node() public view { + bytes memory want = vm.parseJsonBytes(json, ".vote_message"); + bytes memory got = v.voteMessage(uint64(vm.parseJsonUint(json, ".index")), vm.parseJsonBytes32(json, ".checkpoint")); + require(keccak256(want) == keccak256(got), "vote message"); + } + + function test_expand_message_xmd_known_answer() public view { + // RFC 9380 appendix K.1 (expand_message_xmd with SHA-256, DST "QUUX-V01-CS02-with-expander-SHA256-128"): the + // empty message at 32 bytes is the appendix's own first answer; the "abc" at 128 bytes answer comes from noble + bytes memory dst = bytes(vm.parseJsonString(json, ".xmd_dst")); + bytes memory out = BLS12381.expandMessageXmd("", dst, 32); + require(keccak256(out) == keccak256(hex"68a985b87eb6b46952128911f2a4412bbc302a9d759667f87f7a21d803f07235"), "xmd 32 (RFC)"); + require(keccak256(out) == keccak256(vm.parseJsonBytes(json, ".xmd_empty_32")), "xmd 32 (noble)"); + bytes memory out2 = BLS12381.expandMessageXmd("abc", dst, 128); + require(keccak256(out2) == keccak256(vm.parseJsonBytes(json, ".xmd_abc_128")), "xmd 128 (noble)"); + } + + function test_certificate_verifies() public view { + (bool ok, uint256 signed, uint256 total) = v.verifyCertificate( + uint64(vm.parseJsonUint(json, ".index")), vm.parseJsonBytes32(json, ".checkpoint"), vm.parseJsonBytes(json, ".bitmap"), vm.parseJsonBytes(json, ".signature") + ); + require(ok, "certificate"); + require(signed == vm.parseJsonUint(json, ".signed_weight") && total == vm.parseJsonUint(json, ".total_weight"), "weights"); + } + + function test_certificate_under_two_thirds_is_refused() public view { + (bool ok, uint256 signed,) = v.verifyCertificate( + uint64(vm.parseJsonUint(json, ".index")), vm.parseJsonBytes32(json, ".checkpoint"), vm.parseJsonBytes(json, ".weak_bitmap"), vm.parseJsonBytes(json, ".weak_signature") + ); + require(!ok && signed * 3 < vm.parseJsonUint(json, ".total_weight") * 2, "weak certificate accepted"); + } + + function test_wrong_checkpoint_or_index_fails() public view { + bytes32 cp = vm.parseJsonBytes32(json, ".checkpoint"); + uint64 index = uint64(vm.parseJsonUint(json, ".index")); + bytes memory bm = vm.parseJsonBytes(json, ".bitmap"); + bytes memory sig = vm.parseJsonBytes(json, ".signature"); + (bool ok1,,) = v.verifyCertificate(index, cp ^ bytes32(uint256(1)), bm, sig); + (bool ok2,,) = v.verifyCertificate(index + 1, cp, bm, sig); + require(!ok1 && !ok2, "forged certificate accepted"); + // the right signers' weight with a bitmap naming a different signer set does not match the signature + bytes memory other = vm.parseJsonBytes(json, ".weak_bitmap"); + other[0] = bytes1(uint8(other[0]) | 0x1f); + (bool ok3,,) = v.verifyCertificate(index, cp, other, sig); + require(!ok3, "wrong signer set accepted"); + } + + function test_submit_records_the_checkpoint() public { + bytes32 cp = vm.parseJsonBytes32(json, ".checkpoint"); + uint64 index = uint64(vm.parseJsonUint(json, ".index")); + v.submitCertificate(index, cp, vm.parseJsonBytes(json, ".bitmap"), vm.parseJsonBytes(json, ".signature")); + require(v.isFinal(cp) && v.finalCheckpoint(index) == cp, "not recorded"); + vm.expectRevert(abi.encodeWithSelector(IgneumCertificateVerifier.BadCertificate.selector, "certificate does not verify")); + v.submitCertificate(index, cp, vm.parseJsonBytes(json, ".weak_bitmap"), vm.parseJsonBytes(json, ".weak_signature")); + } + + function test_account_proof_present_and_absent() public view { + bytes32 root = vm.parseJsonBytes32(json, ".state_root"); + (bool exists, uint256 nonce, uint256 balance, bytes32 sroot, bytes32 chash) = + v.verifyAccount(root, vm.parseJsonAddress(json, ".account"), vm.parseJsonBytesArray(json, ".account_proof")); + require(exists, "account absent"); + require(nonce == vm.parseJsonUint(json, ".account_nonce") && balance == vm.parseJsonUint(json, ".account_balance"), "account fields"); + require(sroot == vm.parseJsonBytes32(json, ".account_storage_root") && chash == vm.parseJsonBytes32(json, ".account_code_hash"), "account roots"); + (bool exists2,,,,) = v.verifyAccount(root, vm.parseJsonAddress(json, ".absent_account"), vm.parseJsonBytesArray(json, ".absent_proof")); + require(!exists2, "absent account present"); + } + + function test_account_proof_against_a_wrong_root_reverts() public { + bytes32 root = vm.parseJsonBytes32(json, ".state_root") ^ bytes32(uint256(1)); + bytes[] memory proof = vm.parseJsonBytesArray(json, ".account_proof"); + address a = vm.parseJsonAddress(json, ".account"); + vm.expectRevert(abi.encodeWithSelector(bytes4(keccak256("BadProof(string)")), "node hash mismatch")); + v.verifyAccount(root, a, proof); + } + + /// A certificate the chain actually carried (test/vectors/chain.json; skipped when the file is absent). + function test_chain_certificate_verifies() public { + string memory j; + try vm.readFile("test/vectors/chain.json") returns (string memory s) { + j = s; + } catch { + return; + } + IgneumCertificateVerifier c = new IgneumCertificateVerifier(vm.parseJsonString(j, ".chain_id")); + _install(c, j); + (bool ok, uint256 signed, uint256 total) = c.verifyCertificate( + uint64(vm.parseJsonUint(j, ".index")), vm.parseJsonBytes32(j, ".checkpoint"), vm.parseJsonBytes(j, ".bitmap"), vm.parseJsonBytes(j, ".signature") + ); + require(signed == vm.parseJsonUint(j, ".signed_weight") && total == vm.parseJsonUint(j, ".total_weight"), "chain weights"); + require(ok, "the chain's certificate does not verify"); + } +} diff --git a/contracts/bridge/test/Vm.sol b/contracts/bridge/test/Vm.sol new file mode 100644 index 000000000..57f628e6c --- /dev/null +++ b/contracts/bridge/test/Vm.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// The Foundry cheatcodes this project uses, declared here so the tree needs no remote dependency. +interface Vm { + function startBroadcast(uint256 privateKey) external; + function stopBroadcast() external; + function envUint(string calldata name) external view returns (uint256); + function envAddress(string calldata name) external view returns (address); + function envOr(string calldata name, string calldata defaultValue) external view returns (string memory); + function toString(address value) external pure returns (string memory); + function toString(uint256 value) external pure returns (string memory); + function toString(bytes32 value) external pure returns (string memory); + function toString(bytes calldata value) external pure returns (string memory); + function readFile(string calldata path) external view returns (string memory); + function writeFile(string calldata path, string calldata data) external; + function parseJsonBytes(string calldata json, string calldata key) external pure returns (bytes memory); + function parseJsonBytes32(string calldata json, string calldata key) external pure returns (bytes32); + function parseJsonUint(string calldata json, string calldata key) external pure returns (uint256); + function parseJsonString(string calldata json, string calldata key) external pure returns (string memory); + 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 deal(address who, uint256 newBalance) external; + function prank(address msgSender) external; + function startPrank(address msgSender) external; + function stopPrank() external; + function warp(uint256 newTimestamp) external; + function expectRevert(bytes calldata revertData) external; + function addr(uint256 privateKey) external pure returns (address); +} + +address constant VM_ADDRESS = address(uint160(uint256(keccak256("hevm cheat code")))); diff --git a/contracts/bridge/test/vectors/.gitignore b/contracts/bridge/test/vectors/.gitignore new file mode 100644 index 000000000..7741964bf --- /dev/null +++ b/contracts/bridge/test/vectors/.gitignore @@ -0,0 +1,5 @@ +node_modules +synthetic.json +chain.json +checkpoint-live.json +checkpoint-dn3.json diff --git a/contracts/bridge/test/vectors/gen.mjs b/contracts/bridge/test/vectors/gen.mjs new file mode 100644 index 000000000..df273fc82 --- /dev/null +++ b/contracts/bridge/test/vectors/gen.mjs @@ -0,0 +1,95 @@ +// 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 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) +// Encodings: field element 64 bytes (16 zero bytes then 48), G1 128 bytes, G2 256 bytes (x.c0, x.c1, y.c0, y.c1). +// The DST and the vote message follow consensus/core/src/finality.rs and site/verify/core.js. +import { bls12_381 } from '@noble/curves/bls12-381'; +import { expand_message_xmd } from '@noble/curves/abstract/hash-to-curve'; +import { sha256 } from '@noble/hashes/sha256'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); + +const DST = 'IGNEUM_VOTE_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_'; +const CHAIN_ID = 'igneum-devnet-3'; +const te = new TextEncoder(); +const hex = b => '0x' + Array.from(b, x => x.toString(16).padStart(2, '0')).join(''); +const unhex = h => Uint8Array.from(Buffer.from(h.replace(/^0x/, ''), 'hex')); +const be = (n, len) => { const out = new Uint8Array(len); let v = BigInt(n); for (let i = len - 1; i >= 0; i--) { out[i] = Number(v & 0xffn); v >>= 8n; } return out; }; +const fe = n => { const out = new Uint8Array(64); out.set(be(n, 48), 16); return out; }; +const concat = parts => { const n = parts.reduce((a, p) => a + p.length, 0); const out = new Uint8Array(n); let o = 0; for (const p of parts) { out.set(p, o); o += p.length; } return out; }; +const u64le = n => { const out = new Uint8Array(8); let v = BigInt(n); for (let i = 0; i < 8; i++) { out[i] = Number(v & 0xffn); v >>= 8n; } return out; }; + +const G1 = bls12_381.G1.ProjectivePoint, G2 = bls12_381.G2.ProjectivePoint; +function g1Enc(p) { const a = p.toAffine(); return concat([fe(a.x), fe(a.y)]); } +function g2Enc(p) { const a = p.toAffine(); return concat([fe(a.x.c0), fe(a.x.c1), fe(a.y.c0), fe(a.y.c1)]); } +function voteMessage(index, checkpointHex) { return concat([te.encode('igneum-vote-v1/' + CHAIN_ID), new Uint8Array([0]), u64le(index), unhex(checkpointHex)]); } +function bitmapOf(positions, n) { const bm = new Uint8Array(Math.ceil(n / 8)); for (const p of positions) bm[p >> 3] |= 1 << (p & 7); return bm; } + +async function synthetic() { + const sks = [1, 2, 3, 4, 5].map(i => { const s = new Uint8Array(32); s[31] = i; s[0] = 0x11 * i; return bls12_381.utils.randomPrivateKey ? bls12_381.G1.normPrivateKeyToScalar(s) : s; }); + const keys = sks.map(sk => G1.BASE.multiply(sk)); + const weights = [100, 250, 400, 300, 150]; + const index = 1234, checkpoint = '0x' + 'ab'.repeat(32); + const msg = voteMessage(index, checkpoint); + const hm = bls12_381.G2.hashToCurve(msg, { DST }); + const signers = [0, 1, 2, 3]; // 1,050 of 1,200: above two thirds + const weakSigners = [0, 2, 4]; // 650 of 1,200: under two thirds + const sign = who => who.map(i => hm.multiply(sks[i])).reduce((a, b) => a.add(b)); + const sig = sign(signers), weak = sign(weakSigners); + // the account trie: three accounts, proofs for one present and one absent + const { Trie } = require('@ethereumjs/trie'); + const { RLP } = require('@ethereumjs/rlp'); + const { keccak256 } = require('ethereum-cryptography/keccak'); + const trie = new Trie({ useKeyHashing: true }); + const accounts = [ + { address: '0x07dd4dbca5c1a66755af28bacca1d901a2d209aa', nonce: 7n, balance: 999174011168718479514n, storageRoot: '0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421', codeHash: '0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470' }, + { address: '0x9a6fa842c4e58a87aef1f3ad15233d99283002b7', nonce: 1n, balance: 0n, storageRoot: '0x' + '11'.repeat(32), codeHash: '0x' + '22'.repeat(32) }, + { address: '0x53fe98022c2ac26d5d721457fb1c374b4d56144b', nonce: 3n, balance: 2580n * 10n ** 18n, storageRoot: '0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421', codeHash: '0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470' }, + ]; + for (const a of accounts) { + const v = RLP.encode([a.nonce === 0n ? new Uint8Array() : be(a.nonce, Math.ceil(a.nonce.toString(2).length / 8)), a.balance === 0n ? new Uint8Array() : be(a.balance, Math.ceil(a.balance.toString(2).length / 8)), unhex(a.storageRoot), unhex(a.codeHash)]); + await trie.put(unhex(a.address), v); + } + const root = hex(trie.root()); + const proofFor = async addr => (await trie.createProof(unhex(addr))).map(hex); + const absent = '0x000000000000000000000000000000000000dead'; + return { + chain_id: CHAIN_ID, dst: DST, index, checkpoint, + keys: keys.map(k => hex(g1Enc(k))), weights, total_weight: weights.reduce((a, b) => a + b, 0), + bitmap: hex(bitmapOf(signers, keys.length)), signature: hex(g2Enc(sig)), signed_weight: signers.reduce((a, i) => a + weights[i], 0), + weak_bitmap: hex(bitmapOf(weakSigners, keys.length)), weak_signature: hex(g2Enc(weak)), + vote_message: hex(msg), + // RFC 9380 expand_message_xmd(SHA-256) answers, computed by noble, for the Solidity port's own check + xmd_dst: 'QUUX-V01-CS02-with-expander-SHA256-128', + xmd_abc_128: hex(expand_message_xmd(te.encode('abc'), te.encode('QUUX-V01-CS02-with-expander-SHA256-128'), 128, sha256)), + xmd_empty_32: hex(expand_message_xmd(new Uint8Array(), te.encode('QUUX-V01-CS02-with-expander-SHA256-128'), 32, sha256)), + state_root: root, + account: accounts[0].address, account_nonce: accounts[0].nonce.toString(), account_balance: accounts[0].balance.toString(), + account_storage_root: accounts[0].storageRoot, account_code_hash: accounts[0].codeHash, + account_proof: await proofFor(accounts[0].address), + absent_account: absent, absent_proof: await proofFor(absent), + }; +} + +function chain(file) { + const d = JSON.parse(readFileSync(file, 'utf8')); + const voters = d.voters.map(v => ({ key: hex(g1Enc(G1.fromHex(v.pubkey_hex.replace(/^0x/, '')))), weight: Math.round(Number(v.weight)) })); + const sig = G2.fromHex(d.certificate.aggregate_signature_hex.replace(/^0x/, '')); + const positions = []; const bm = unhex(d.certificate.bitmap_hex); + for (let p = 0; p < voters.length; p++) if (bm[p >> 3] & (1 << (p & 7))) positions.push(p); + return { + source: d.source, chain_id: d.chain_id, dst: DST, index: d.index, checkpoint: '0x' + d.hash, + keys: voters.map(v => v.key), weights: voters.map(v => v.weight), total_weight: voters.reduce((a, v) => a + v.weight, 0), + bitmap: '0x' + d.certificate.bitmap_hex, signature: hex(g2Enc(sig)), signed_weight: positions.reduce((a, p) => a + voters[p].weight, 0), + signers: positions.length, voters_at_index: d.voters_at_index, stored_at: d.stored_at, + }; +} + +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); }