Merge dex-devnet3 096f5415 into master (gate: green on 096f5415, recorded by tools/ci/pre-push.sh; landed on the build mirror)

This commit is contained in:
igneum-labs 2026-10-08 11:09:11 +00:00
commit f839b75bd0
12 changed files with 1015 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 Osaka rules, the fork Sepolia is on (EIP-7883 modexp pricing counts here).
evm_version = "osaka"
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,46 @@
// 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, 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 {
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);
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(
"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\": \"", hasCert ? vm.toString(vm.parseJsonBytes32(j, ".checkpoint")) : "none yet", "\"\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,144 @@
// 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");
}
/// One Devnet 3 account under a real Devnet 3 state root (test/vectors/dn3-account.json from a node's eth_getProof;
/// skipped when the file is absent). The root is the chain's own; the link from a certified checkpoint to that root
/// is the gap the doc names.
function test_devnet3_account_balance_proven() public {
string memory j;
try vm.readFile("test/vectors/dn3-account.json") returns (string memory s) {
j = s;
} catch {
return;
}
(bool exists, uint256 nonce, uint256 balance, bytes32 sroot, bytes32 chash) = v.verifyAccount(
vm.parseJsonBytes32(j, ".state_root"), vm.parseJsonAddress(j, ".account"), vm.parseJsonBytesArray(j, ".account_proof")
);
require(exists, "the Devnet 3 account is absent under its root");
require(nonce == vm.parseJsonUint(j, ".account_nonce") && balance == vm.parseJsonUint(j, ".account_balance"), "Devnet 3 account fields");
require(sroot == vm.parseJsonBytes32(j, ".account_storage_root") && chash == vm.parseJsonBytes32(j, ".account_code_hash"), "Devnet 3 account roots");
}
}

View file

@ -0,0 +1,34 @@
// 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 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;
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,9 @@
node_modules
synthetic.json
chain.json
checkpoint-live.json
checkpoint-dn3.json
weights-dn3.json
checkpoints-dn3.json
dn3-table.json
dn3-account.json

View file

@ -0,0 +1,116 @@
// Test vectors for the Igneum certificate verifier. Two sources:
// node gen.mjs synthetic > synthetic.json five vote keys made here (noble BLS12-381), a certificate signed by four
// of them over the Devnet 3 vote message, a small account trie with proofs
// node gen.mjs table <weights.json> > dn3-table.json the voter table alone from a node's igneum_getFinalityWeights answer
// (voters in the canonical order: sorted by key hash; weight = blocks)
// node gen.mjs account <getProof.json> <stateRoot> <blockNumber> > dn3-account.json an eth_getProof answer from a Devnet 3
// node (the reference-apps lane's reader) as the suite's real-root account vector
// 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,
};
}
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 };
}
function account(file, stateRoot, blockNumber) {
const d = JSON.parse(readFileSync(file, 'utf8'));
const r = d.result || d;
return { chain_id: CHAIN_ID, state_root: stateRoot, block_number: Number(blockNumber), account: r.address, account_nonce: BigInt(r.nonce).toString(), account_balance: BigInt(r.balance).toString(), account_storage_root: r.storageHash, account_code_hash: r.codeHash, account_proof: r.accountProof };
}
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 if (mode === 'table') console.log(JSON.stringify(table(process.argv[3]), null, 1));
else if (mode === 'account') console.log(JSON.stringify(account(process.argv[3], process.argv[4], process.argv[5]), null, 1));
else { console.error('usage: gen.mjs synthetic | table <weights.json> | account <getProof.json> <stateRoot> <blockNumber> | chain <checkpoint.json>'); process.exit(2); }

View file

@ -0,0 +1,122 @@
# The light-client bridge primitive (Devnet 3 to Ethereum Sepolia), 8 October 2026
Devnet 3, test tokens, no value. This is a design plus one working contract. It moves nothing. It proves two things on
Sepolia and states, below, what it does not prove.
## What exists
| Piece | Where | What it does |
|---|---|---|
| The verifier contract | `contracts/bridge/src/IgneumCertificateVerifier.sol`, deployed on Sepolia (address in `docs/contracts/sepolia.json`) | holds a Devnet 3 voter table; verifies a finality certificate against it and records the checkpoint hash as final; verifies an Ethereum account proof against a state root |
| BLS12-381 | `contracts/bridge/src/BLS12381.sol` | hash-to-curve for G2 (RFC 9380, the node's DST), key aggregation in G1, the two-pairing check, all through the EIP-2537 precompiles |
| The account proof | `contracts/bridge/src/MerklePatricia.sol` | walks an eth_getProof-shaped proof (RLP nodes, hex-prefix paths, embedded children) to the account's RLP value or to its absence |
| The suite | `contracts/bridge/test/Verifier.t.sol`, vectors from `test/vectors/gen.mjs` | 9 tests on Foundry's Prague EVM: the vote message byte for byte, RFC 9380 expand_message_xmd answers, a certificate by four of five made-up keys, one under two thirds refused, forged index and checkpoint refused, the recorded checkpoint, an account present and an account absent under one root, a wrong root refused, and one certificate the chain actually carried |
| The vectors | `gen.mjs synthetic`, `gen.mjs table <weights.json>`, `gen.mjs chain <checkpoint.json>` | made-up keys and a three-account trie; the Devnet 3 voter table from a node's `igneum_getFinalityWeights`; a real certificate as `/api/checkpoint` serves it, keys and signature decompressed to the precompiles' encodings |
The suite ran green on build-3 (9 of 9) before the deploy; the Sepolia address and the deploy transactions are in
`docs/contracts/sepolia.json`.
## What the certificate check proves
A certificate is `(index, checkpoint hash, bitmap, aggregate signature)` as `consensus/core/src/finality.rs` writes it
into a block's coinbase (`Certificate::write`: index_le64, checkpoint, voter_count_le32, bitmap_len_le32, bitmap,
signature, aggregator, aggregator proof). The contract takes the first four fields; the signature is the 96-byte G2
point decompressed off chain to the precompiles' 256-byte form (a wrong decompression is a point the pairing
precompile refuses).
The contract holds a voter table installed by its deployer: the canonical voter list at one checkpoint index (every
key above dust and not stripped, sorted by key hash, as the node's `igneum_getFinalityWeights` reports it), each key a
128-byte uncompressed G1 point, each weight the key's blue blocks in the 30-day window. `tableId` is the keccak of
`(index, keys, weights)`.
`verifyCertificate(index, checkpoint, bitmap, signature)` holds exactly when:
1. bit `p` of the bitmap names voter `p` of the table, and the sum of the named voters' weights is at least two thirds
of the table's total weight (the rule decided 4 October 2026: lock = 2/3 of all 30-day weight; stricter than the
17/30 floor plus 2/3 of active that the node line still carries, so every certificate the node locks under the new
rule passes here and some the old rule locked would not);
2. the aggregate of the named keys (G1 additions) verifies the signature over the vote message
`"igneum-vote-v1/" || chain_id || 0x00 || index_le64 || checkpoint` under the domain separation tag
`IGNEUM_VOTE_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_`, with `chain_id` the string the contract was built with
(`igneum-devnet-3`): e(aggregate key, H(message)) * e(-G1, signature) = 1.
So a recorded `finalCheckpoint(index)` means: the keys in the installed table that hold at least two thirds of that
table's weight signed this checkpoint hash at this index on this chain id. That is the finality rule's own statement,
checked with the node's own bytes, by a contract on another chain.
Measured on the test EVM: a 29-voter table costs about 5.3 million gas to install; a certificate with 12 signers
verifies in about 3.9 million gas (the pairing and the two map-to-curve calls dominate; a G1 addition per signer is
375 gas). On Sepolia at a 1 gwei tip that is under 0.01 ETH per certificate.
## What the account proof proves
`verifyAccount(stateRoot, account, proof)` walks an Ethereum account proof (the `eth_getProof` shape: RLP nodes from
the root, keys hashed with keccak, the hex-prefix leaf and extension paths, children by hash or embedded when under 32
bytes) and returns the account's nonce, balance, storage root and code hash, or `exists = false` when the trie shows the
account absent. This is the trie Igneum's executor commits to: `igneum/exec/src/state.rs` computes `stateRoot` with
`alloy_trie::root::state_root` over `keccak256(address)` and `RLP(nonce, balance, storage_root, code_hash)`, EIP-161
empty accounts left out, reth's layout, the same as Ethereum's.
So a verified account proof means: under this state root, this account has these fields.
## What is NOT proven, in order of weight
1. **The link from a certified checkpoint to a state root.** Nothing the contract verifies ties a checkpoint hash to an
EVM state root. The Kaspa-shaped header the voters sign has no execution root (its `utxo_commitment` is the UTXO
multiset, `accepted_id_merkle_root` the accepted transaction ids); the executor runs behind consensus as a follower
and the EVM block hash is the DAG block hash, not a hash of the EVM header. The binding that exists today is in the
coinbase of later blocks: the IGNS segment record (`BlockStatement.post_root` for its last chain block, signed by
the aggregator's vote key) and the proof records (`ProofRecord.statement` = keccak of a `ShardOutput` carrying
`pre_root` and `post_root`, signed by the prover's vote key), both under the carrier block's `hash_merkle_root`. A
carried record that fails a node's native veto is ignored rather than faulting the block, so even that binding
rests on the aggregator's and prover's keys and on the nodes' native check, not on a header field. Today a caller
gives the verifier a state root as a stated input beside a verified certificate; the contract does not know they
belong together. The reference-apps lane's oracle verifies the coinbase path (BLAKE2b header hash, the merkle
branch, the record parse) on Sepolia and shares this verifier for the certificate; the two together are the full
chain once the record's signature check lands there.
2. **The voter table itself.** The table is installed by the deployer from a node's `igneum_getFinalityWeights` read:
the keys, their canonical order (sorted by `BLAKE2b-256("IgneumVoteKeyHash", key)`, which the contract does not
recompute: no BLAKE2b on chain today) and their weights are trusted. A wrong table makes a wrong verdict in both
directions. A light client proper tracks the table from the chain: every weight is a count of blue blocks whose
headers name the key, so the table follows from headers; and the node's `FinalityWeights` RPC reports the table at
the certificate's own index (`voters_at_index`). The next step is a table update that takes a certified checkpoint
plus the headers between locks, the shape `/api/checkpoint` already serves to the browser verifier
(`site/verify/core.js` checks exactly that path, in JavaScript).
3. **The checkpoint index is a number the submitter gives.** The contract records one hash per index and refuses a
second, but it does not know the chain's current index; an old certificate at an old index verifies for ever
against the table it was signed under. A consumer should read `finalCheckpoint` at an index it already knows from
the chain, not treat the newest recorded index as the chain's tip.
4. **Equivocation and stripping.** The node strips a key's weight for 30 days on equivocation evidence. The installed
table carries the stripping as of its read and nothing after it.
5. **Proof of work, the DAG order, execution correctness.** None of it is checked here. The certificate's claim is the
voters' signature, and the voters are the miners who proved their blocks; the ZK proofs of execution (the shard
proofs the records name) are verified by the nodes, not by this contract. A proof-carrying bridge (the chain's SP1
shard proofs verified on Ethereum) is the design's end state and is not today's primitive.
6. **The state root's age and the account's current balance.** An account proof says what the balance was under that
root; the root is one block's. Nothing here prevents a stale root from being presented.
## How one balance gets proven on Sepolia today, and what each step rests on
| Step | Source | Rests on |
|---|---|---|
| the voter table at index N | a Devnet 3 node's `igneum_getFinalityWeights` | the node, the installer (trusted today) |
| the certificate for checkpoint C at index N | `/api/checkpoint?source=dn3` (the observer's `dn3_live_certificates`, the bytes a block carried) | verified on chain: the signature and the two-thirds rule |
| the state root R of chain block B | `eth_getBlockByNumber` on Devnet 3 | stated, not proven against C (gap 1) |
| the account proof for A under R | `eth_getProof` on the reference-apps lane's reader node (fork branch light-apps-node, the Devnet 3 pin plus a read-only RPC) | verified on chain against R |
The test `test_account_proof_present_and_absent` proves the account under a made-up root; the Devnet 3 account
proof against a real root goes into the suite as `test/vectors/dn3-account.json` the moment the reader node serves
`eth_getProof` (the proof shape is Ethereum's, so the contract needs no change).
## The design from here
1. The header path on chain: BLAKE2b-256 through the EIP-152 precompile for the keyed header hash and the key hashes,
the merkle branch to the coinbase, the segment and proof records parsed from the coinbase payload. This closes gap 1
and lets the contract recompute canonical order (gap 2's order).
2. The table update from certified headers: weights counted from the blue blocks between two locks, so the table
follows the chain instead of an installer. This closes gap 2.
3. A tip rule: the contract keeps the highest index it has recorded and a consumer reads only at or below it; the
relayer submits each lock as it lands (one transaction per 30-second checkpoint is affordable on Sepolia, not on
mainnet; mainnet gets one certificate per epoch).
4. The proof-carrying bridge: the shard proofs' aggregate verified on Ethereum (the prover network's own product),
which replaces trust in the native veto with a verified execution root.

View file

@ -0,0 +1,22 @@
{
"network": "ethereum-sepolia",
"chain_id": 11155111,
"note": "The Igneum light-client bridge primitive: a verifier for Devnet 3 finality certificates and Ethereum account proofs. Devnet 3, test tokens, no value; Sepolia, test ether, no value. What it proves and what it does not: docs/bridge/light-client-bridge.md.",
"rpc": "https://ethereum-sepolia-rpc.publicnode.com",
"deployed_at": "2026-10-08T12:36:00Z",
"deployer": "0x0C896A191D6b76c37d704454b35B2D61276b7471",
"source": "contracts/bridge (Foundry, solc 0.8.28, evm_version osaka, via-IR, optimizer 200 runs)",
"contracts": {
"IgneumCertificateVerifier": {
"address": "0xAf74f3F512081291D663Bb1d6b6d37E99e37D744",
"create_tx": "0x55351cbbccda0f78311c22feb1174d99c5d8d9fe3488cb60ff8656f65007e079",
"create_block": 11869731,
"igneum_chain_id": "igneum-devnet-3",
"table": { "tx": "0x6ec1045dbc0abe981944e9e15987089d3f0cdc2e8e467f688b7b0c63e907b26b", "block": 11869732, "index": 2127, "voters": 29, "total_weight": 7164, "table_id": "0xabfcc2bc54ca92ec506beee8a44dced17f15cf1e7c518a6221bf2819b0d1cbd6", "read_from": "igneum_getFinalityWeights on a Devnet 3 node at checkpoint index 2127 (hash 0xcb21b52c…), 12:1x UTC 8 October 2026" },
"final_checkpoints": "none yet: the first Devnet 3 certificate is submitted when /api/checkpoint serves dn3_live_certificates",
"interface": "IIgneumCertificateVerifier in contracts/bridge/src/IgneumCertificateVerifier.sol: chainId(), tableId(), verifyCertificate(uint64 index, bytes32 checkpoint, bytes bitmap, bytes signature) view returns (bool ok, uint256 signedWeight, uint256 totalWeight), submitCertificate(...), finalCheckpoint(uint64) view returns (bytes32), isFinal(bytes32) view returns (bool), verifyAccount(bytes32 stateRoot, address account, bytes[] proof) pure returns (bool exists, uint256 nonce, uint256 balance, bytes32 storageRoot, bytes32 codeHash)"
}
},
"first_deploy_note": "A first creation at 0xD7dd375E14F92A4CE4782040325189e6d6E394b6 (tx 0x4db10565…, block 11869633) ran out of gas at Foundry's Prague-spec estimate of 3,323,884; Sepolia runs Osaka pricing (EIP-7883 modexp), so the test EVM and the deploy now use osaka and the RPC's own estimate. That address holds no code.",
"tests": "contracts/bridge/test/Verifier.t.sol: 9 of 9 green on build-3 (Foundry, osaka), 12:3x UTC 8 October 2026"
}