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:
commit
f839b75bd0
12 changed files with 1015 additions and 0 deletions
18
contracts/bridge/foundry.toml
Normal file
18
contracts/bridge/foundry.toml
Normal 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"
|
||||
46
contracts/bridge/script/Deploy.s.sol
Normal file
46
contracts/bridge/script/Deploy.s.sol
Normal 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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
22
contracts/bridge/script/Send.s.sol
Normal file
22
contracts/bridge/script/Send.s.sol
Normal 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();
|
||||
}
|
||||
}
|
||||
113
contracts/bridge/src/BLS12381.sol
Normal file
113
contracts/bridge/src/BLS12381.sol
Normal 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;
|
||||
}
|
||||
}
|
||||
155
contracts/bridge/src/IgneumCertificateVerifier.sol
Normal file
155
contracts/bridge/src/IgneumCertificateVerifier.sol
Normal 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);
|
||||
}
|
||||
}
|
||||
214
contracts/bridge/src/MerklePatricia.sol
Normal file
214
contracts/bridge/src/MerklePatricia.sol
Normal 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
144
contracts/bridge/test/Verifier.t.sol
Normal file
144
contracts/bridge/test/Verifier.t.sol
Normal 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");
|
||||
}
|
||||
}
|
||||
34
contracts/bridge/test/Vm.sol
Normal file
34
contracts/bridge/test/Vm.sol
Normal 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"))));
|
||||
9
contracts/bridge/test/vectors/.gitignore
vendored
Normal file
9
contracts/bridge/test/vectors/.gitignore
vendored
Normal 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
|
||||
116
contracts/bridge/test/vectors/gen.mjs
Normal file
116
contracts/bridge/test/vectors/gen.mjs
Normal 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); }
|
||||
122
docs/bridge/light-client-bridge.md
Normal file
122
docs/bridge/light-client-bridge.md
Normal 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.
|
||||
22
docs/contracts/sepolia.json
Normal file
22
docs/contracts/sepolia.json
Normal 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"
|
||||
}
|
||||
Loading…
Reference in a new issue