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 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-08 10:40:17 +00:00
parent c64e809fdd
commit fa02dbcff2
10 changed files with 824 additions and 0 deletions

View file

@ -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"

View file

@ -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"
)
);
}
}

View file

@ -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();
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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];
}
}
}

View file

@ -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");
}
}

View file

@ -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"))));

View file

@ -0,0 +1,5 @@
node_modules
synthetic.json
chain.json
checkpoint-live.json
checkpoint-dn3.json

View file

@ -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 <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)
// 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 <checkpoint.json>'); process.exit(2); }