Bridge: docs/bridge/light-client-bridge.md (what the Sepolia verifier proves, the six things it does not, the path from here), the Devnet 3 table mode of the vector generator, the certificate-optional deploy script
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
fa02dbcff2
commit
59f82c6dbd
4 changed files with 143 additions and 5 deletions
|
|
@ -5,8 +5,8 @@ import {Vm, VM_ADDRESS} from "../test/Vm.sol";
|
|||
import {IgneumCertificateVerifier} from "../src/IgneumCertificateVerifier.sol";
|
||||
|
||||
/// Deploys the verifier on Sepolia from BRIDGE_DEPLOYER_KEY (environment, never printed), installs the voter table
|
||||
/// from the vectors file named in BRIDGE_TABLE_JSON (a gen.mjs output: keys, weights, index, chain_id) and submits
|
||||
/// that file's certificate, so the deployed contract carries one Devnet 3 checkpoint proven final from the start.
|
||||
/// from the vectors file named in BRIDGE_TABLE_JSON (a gen.mjs output: keys, weights, index, chain_id) and, when the
|
||||
/// file carries a certificate (bitmap, signature), submits it, so the contract records one checkpoint final from the start.
|
||||
///
|
||||
/// BRIDGE_TABLE_JSON=test/vectors/chain.json forge script script/Deploy.s.sol:Deploy --rpc-url sepolia --broadcast --sig "run()"
|
||||
contract Deploy {
|
||||
|
|
@ -28,7 +28,10 @@ contract Deploy {
|
|||
vm.startBroadcast(key);
|
||||
IgneumCertificateVerifier v = new IgneumCertificateVerifier(vm.parseJsonString(j, ".chain_id"));
|
||||
v.installTable(index, packed, weights);
|
||||
v.submitCertificate(index, vm.parseJsonBytes32(j, ".checkpoint"), vm.parseJsonBytes(j, ".bitmap"), vm.parseJsonBytes(j, ".signature"));
|
||||
bool hasCert = vm.keyExistsJson(j, ".bitmap");
|
||||
if (hasCert) {
|
||||
v.submitCertificate(index, vm.parseJsonBytes32(j, ".checkpoint"), vm.parseJsonBytes(j, ".bitmap"), vm.parseJsonBytes(j, ".signature"));
|
||||
}
|
||||
vm.stopBroadcast();
|
||||
|
||||
vm.writeFile(
|
||||
|
|
@ -36,7 +39,7 @@ contract Deploy {
|
|||
string.concat(
|
||||
"{\n \"IgneumCertificateVerifier\": \"", vm.toString(address(v)), "\",\n \"chain_id\": \"", vm.parseJsonString(j, ".chain_id"),
|
||||
"\",\n \"table_index\": ", vm.toString(uint256(index)), ",\n \"voters\": ", vm.toString(keys.length), ",\n \"table_id\": \"",
|
||||
vm.toString(v.tableId()), "\",\n \"final_checkpoint\": \"", vm.toString(vm.parseJsonBytes32(j, ".checkpoint")), "\"\n}\n"
|
||||
vm.toString(v.tableId()), "\",\n \"final_checkpoint\": \"", hasCert ? vm.toString(vm.parseJsonBytes32(j, ".checkpoint")) : "none yet", "\"\n}\n"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ interface Vm {
|
|||
function parseJsonBytesArray(string calldata json, string calldata key) external pure returns (bytes[] memory);
|
||||
function parseJsonUintArray(string calldata json, string calldata key) external pure returns (uint256[] memory);
|
||||
function parseJsonAddress(string calldata json, string calldata key) external pure returns (address);
|
||||
function keyExistsJson(string calldata json, string calldata key) external view returns (bool);
|
||||
function deal(address who, uint256 newBalance) external;
|
||||
function prank(address msgSender) external;
|
||||
function startPrank(address msgSender) external;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
// Test vectors for the Igneum certificate verifier. Two sources:
|
||||
// node gen.mjs synthetic > synthetic.json five vote keys made here (noble BLS12-381), a certificate signed by four
|
||||
// of them over the Devnet 3 vote message, a small account trie with proofs
|
||||
// node gen.mjs table <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 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)
|
||||
|
|
@ -89,7 +91,17 @@ function chain(file) {
|
|||
};
|
||||
}
|
||||
|
||||
function table(file) {
|
||||
const d = JSON.parse(readFileSync(file, 'utf8'));
|
||||
const r = d.result || d;
|
||||
const voters = r.keys.filter(k => k.voter === true || k.voter === 'True').map(k => ({ keyHash: String(k.keyHash).replace(/^0x/, ''), key: hex(g1Enc(G1.fromHex(String(k.pubkey).replace(/^0x/, '')))), weight: Number(BigInt(k.blocks)) }));
|
||||
voters.sort((a, b) => (a.keyHash < b.keyHash ? -1 : a.keyHash > b.keyHash ? 1 : 0));
|
||||
const total = voters.reduce((a, v) => a + v.weight, 0);
|
||||
return { chain_id: process.env.IGNEUM_CHAIN_ID || CHAIN_ID, dst: DST, index: Number(BigInt(r.checkpointIndex)), checkpoint_at_index: '0x' + String(r.checkpointHash).replace(/^0x/, ''), keys: voters.map(v => v.key), weights: voters.map(v => v.weight), total_weight: total, total_weight_node: Number(BigInt(r.totalWeight)), voters: voters.length };
|
||||
}
|
||||
|
||||
const mode = process.argv[2];
|
||||
if (mode === 'synthetic') synthetic().then(v => console.log(JSON.stringify(v, null, 1)));
|
||||
else if (mode === 'chain') console.log(JSON.stringify(chain(process.argv[3]), null, 1));
|
||||
else { console.error('usage: gen.mjs synthetic | chain <checkpoint.json>'); process.exit(2); }
|
||||
else if (mode === 'table') console.log(JSON.stringify(table(process.argv[3]), null, 1));
|
||||
else { console.error('usage: gen.mjs synthetic | table <weights.json> | 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.
|
||||
Loading…
Reference in a new issue