From b2f94345132d79364e48bd93cfc22a65d5aca8cf Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 07:52:55 +0000 Subject: [PATCH] fin-proof: host depends on the fin crate; finproof.js, the browser half of final-at; design page refined after the build Co-Authored-By: Claude Fable 5.1 --- docs/design/finality-in-proof.md | 14 +++- proving/igneum-prove/Cargo.lock | 1 + proving/igneum-prove/host/Cargo.toml | 1 + site/verify/finproof.js | 104 +++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 site/verify/finproof.js diff --git a/docs/design/finality-in-proof.md b/docs/design/finality-in-proof.md index cb101bd81..6f07183ca 100644 --- a/docs/design/finality-in-proof.md +++ b/docs/design/finality-in-proof.md @@ -1,6 +1,6 @@ # Finality carried inside the segment proof -Design, 7 October 2026, 08:4x UK. Lane fin-proof (branch `fin-proof`, fork branch `fin-proof-node` from `release-0.3.18-node` e69e8a39). The item is `docs/analysis/horizon/frontier.md` rank 1 (section 3.3, with the Kaspa developer's attack in 4.2). Status: Designed here, Implemented as a guest prototype behind `finality_in_proof_activation_daa` (default never), Measured where section 6 says so. Nothing here touches the devnet. +Design, 7 October 2026, 08:2x UK; refined 09:3x UK after the build (section 2 and 4.5). Lane fin-proof (branch `fin-proof`, fork branch `fin-proof-node` from `release-0.3.18-node` e69e8a39). The item is `docs/analysis/horizon/frontier.md` rank 1 (section 3.3, with the Kaspa developer's attack in 4.2). Status: Designed here, Implemented as a guest prototype behind `finality_in_proof_activation_daa` (default never), Measured where section 6 says so. Nothing here touches the devnet. The sentence. Today the segment proof says "the state root after chain block n is R" (spec 7.8). With this design it also says "checkpoint i at chain block m is locked under finality rule v2 by x of y weight, counted from this same chain", and a browser tab, the wallet or the app's light-client card verifies that from one proof and asks no node for the voter set. The weight table of spec 03 W2 rides inside the recursion as a 32-byte commitment, updated once per segment by the segment's own blue blocks, and the BLS certificate is verified inside the guest against the table the proof carries. @@ -34,9 +34,11 @@ Spec 03 W2 as implemented on the node (`compute_weights`, `vendor/igneum-node/co The guest keeps the same quantity incrementally. The state has two parts, both committed under `table_root = sha256("igneum-fin-state-v1" ‖ end_daa ‖ keys_hash ‖ ring_root)`: 1. **The key table.** One entry per key ever seen in the window, sorted by key hash: `key_hash` 32, `pubkey` 48 (zero until revealed), `blocks` 8 (blue blocks in the window), `ban_until` 8, `leave_from` 8, `leave_until` 8. `keys_hash` is sha256 over the serialised table. The prototype hashes the whole table every segment (section 6 gives the cost per key); the scale form, when the measurement asks for it, is a sorted Merkle tree over the same entries with one path per touched key, and the certificate step then takes the full table as witness once per certificate. -2. **The block ring.** Leaf `d` for every DAA score in the window: `sha256` over the sorted list of `(block_hash, key_hash)` pairs of the blue blocks whose DAA score is `d`; empty leaves are the zero hash. `ring_root` is the root of a binary Merkle tree of 2^22 leaves indexed by `d mod 2^22` (4,194,304 slots over a 2,592,000 window, so a slot is reused only after it has aged out). The ring is what lets the guest age blocks out exactly, block by block, as the node does: when `end_daa` moves from `e` to `e'`, every block with DAA score in `(e - W, e' - W]` leaves the window and its key's `blocks` is decremented; the witness supplies those leaves' contents and paths. It is also the double-counting guard (section 5.2): a block hash already in its leaf is refused. +2. **The block ring.** One leaf per 16 DAA seconds: `sha256` over the sorted list of `(daa, block_hash, key_hash)` of the blue blocks whose DAA score falls in the leaf's span; empty leaves are the zero hash. `ring_root` is the root of a binary Merkle tree of 2^18 leaves indexed by `(daa / 16) mod 2^18` (4,194,304 DAA seconds of span over a 2,592,000 window, so a slot is reused only after it has aged out). The node's tracker holds the ring sparsely (at most about 2 x 2^18 nodes); the guest opens a leaf by witness. Sixteen seconds a leaf is the trade between path length (18 hashes) and leaf size (16 blocks at 1 block/s); a leaf per DAA second would cost 22 hashes a path and a 2^23-node tree on the node. The ring is what lets the guest age blocks out exactly, block by block, as the node does: when `end_daa` moves from `e` to `e'`, every block with DAA score in `(e - W, e' - W]` leaves the window and its key's `blocks` is decremented; the witness supplies those leaves' contents and paths. It is also the double-counting guard (section 5.2): a block hash already in its leaf is refused. -**One segment's update.** Input: the previous state (hash-checked against the previous proof's `table_root`), and for each chain block of the segment the list of its blue blocks (itself and its mergeset blues) as `(block_hash, key_hash, daa_score)`, which is exactly `ChainBlockRecord.mergeset` with `is_blue` (`igneum/exec/src/records.rs`). For each block: insert into its ring leaf (path witnessed, duplicate refused), `blocks += 1` for its key (a new key is appended to the table in sorted position). Then age out as above, set `end_daa = daa(last chain block)`, and recompute `keys_hash` and `ring_root`. The witness also carries, when present: key reveals (`pubkey`, proof of possession, verified in-guest under `DST_POP`; the key hash must equal `BLAKE2b("IgneumVoteKeyHash", pubkey)`), equivocation evidence (two votes by one key at one index for different blocks, both signatures verified; the ban dates from the carrier's DAA score the witness names, which the native tracker takes from the carrier block as the node does), and leaves (W7, signature verified under `DST_LEAVE`; dated the same way). +**The fold is per chain block, not per segment.** The aggregator runs once per chain block (`--mode chain` and the app's segment step aggregate block by block, the previous block's proof as the deferred proof; bench-log 5 October, "aggregation is a fixed cost per block"), so the state is folded one chain block at a time and the key table is hashed in and out at every fold. That is what keeps the certificate step exact at the checkpoint's own block (every history leaf commits that block's `keys_hash`), at the price of two table hashes per block, which section 6 measures per key. + +**One block's update.** Input: the previous state (hash-checked against the previous proof's `table_root`), and for each chain block of the segment the list of its blue blocks (itself and its mergeset blues) as `(block_hash, key_hash, daa_score)`, which is exactly `ChainBlockRecord.mergeset` with `is_blue` (`igneum/exec/src/records.rs`). For each block: insert into its ring leaf (path witnessed, duplicate refused), `blocks += 1` for its key (a new key is appended to the table in sorted position). Then age out as above, set `end_daa = daa(last chain block)`, and recompute `keys_hash` and `ring_root`. The witness also carries, when present: key reveals (`pubkey`, proof of possession, verified in-guest under `DST_POP`; the key hash must equal `BLAKE2b("IgneumVoteKeyHash", pubkey)`), equivocation evidence (two votes by one key at one index for different blocks, both signatures verified; the ban dates from the carrier's DAA score the witness names, which the native tracker takes from the carrier block as the node does), and leaves (W7, signature verified under `DST_LEAVE`; dated the same way). **The voter list at a block.** Keys with `blocks >= dust`, `ban_until <= daa`, and not `leave_from <= daa < leave_until`, in table order (the table is sorted by key hash, so the filtered table is the canonical list of spec 3.10 C3 with no sort in the guest). `total` is their sum. The guest commits `total` into every history leaf so a verifier can read the denominator at any block without the table. @@ -82,6 +84,12 @@ Final means B is on the chain through the latest lock, at or below it. For the p The departure: in the guest the frozen table of Q5 never expires. On the node `frozen_table` returns `None` once the last lock is a full window old, so a chain that paused for 30 days can lock again on the sliding table alone (spec 3.7 item 2: the price of the pause over the fork). A proof-only client cannot afford that rule. It verifies no proof of work, so a chain built from the client's root with no real blocks behind it could age every honest key out of a forged sliding table in 30 forged DAA-days and then certify itself with keys that never mined. With the frozen table standing for ever inside the proof, every certificate after the root needs two thirds of the table at the last real lock, which honest keys hold and a forger does not, whatever it forges. The cost is weak subjectivity: after a pause of a window the guest refuses certificates, sets `stale`, and the client needs a new root (as an Ethereum light client needs a fresh checkpoint after a long sleep). That is a stop, never a wrong "final". The node is untouched by this: its rule is its rule, and a stale proof chain is a light-client matter until the operator ships a new root. +### 4.5 Roots, restarts and the bridging client + +A proof chain has a root: the first block whose fold the chain of proofs verified. When the previous proof carries no extension (the activation block, or a fresh chain after an unproven segment, spec 7.8 item 7) the guest takes the witness state as given, stamps `history_first` at that block, and the attestation of that proof chain starts there. On the chain this is safe: the node's tracker holds the true state at every block and the native compare refuses a record whose root state differs (section 5.1). For a light client it is a trust break only if it accepts the root blind. The client rule: a root is accepted from the release it shipped with, or by bridging: the client holds the extension of its last verified proof (ending at block `E`), fetches the few unattested blocks' witnesses (`igneum_getFinalityWitness`, the same bytes a prover gets) and folds them itself with the same code, from the table it also fetches and checks against its held `table_root`; if the fold lands on the new root's extension, the new chain continues what the client verified, at the trust level of section 5.2 for those few blocks. A prover outage therefore costs light clients a bridge of a few blocks, never a 30-day blackout. The mandatory-proof rule (7.8 item 10), once on, makes restarts rare. + +The node's tracker keeps the table after each of the last 640 chain blocks (the record window plus a margin) and the table at the latest lock whatever its age (the frozen table), the witnesses of the same blocks, and the state at the tip; it answers `igneum_getFinalityWitness(first, last)` only for a range starting at the block after its last fold, which is where an aggregator proves. Memory at 10,000 keys: 640 x 1.1 MB, 700 MB, which is the reason the Merkle key table of section 2 is the scale step and the kept window a parameter. + ## 5. Hostile review ### 5.1 A prover lying about the table (the Monero developer's attack, frontier 3.3) diff --git a/proving/igneum-prove/Cargo.lock b/proving/igneum-prove/Cargo.lock index 83b39d497..ac0830046 100644 --- a/proving/igneum-prove/Cargo.lock +++ b/proving/igneum-prove/Cargo.lock @@ -2852,6 +2852,7 @@ dependencies = [ "bincode", "hex", "igneum-evm-types", + "igneum-fin-core", "igneum-prove-core", "serde", "serde_json", diff --git a/proving/igneum-prove/host/Cargo.toml b/proving/igneum-prove/host/Cargo.toml index 3f5f316f2..5e25c496d 100644 --- a/proving/igneum-prove/host/Cargo.toml +++ b/proving/igneum-prove/host/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] igneum-prove-core.workspace = true +igneum-fin-core.workspace = true igneum-evm-types.workspace = true sp1-sdk = { workspace = true, features = ["blocking"] } alloy-primitives.workspace = true diff --git a/site/verify/finproof.js b/site/verify/finproof.js new file mode 100644 index 000000000..91b1759ab --- /dev/null +++ b/site/verify/finproof.js @@ -0,0 +1,104 @@ +// Igneum light client, the finality-in-proof half (docs/design/finality-in-proof.md, section 4). Reads the 164-byte +// extension of a segment proof's public values and answers "final at checkpoint N" for the proof's own block or for +// any block given its history leaf and MMR proof. It asks no node for anything: the proof's bytes are the only input. +// The proof itself is verified elsewhere (today: the node's verifier; in the tab: the WASM verifier of frontier 3.4). +// Pure JavaScript, no dependencies; SHA-256 from WebCrypto for the history check. + +export const BLOCK_STATEMENT_LEN = 340; +export const FIN_EXT_LEN = 164; +export const FLAG_STALE = 1; + +function u64be(b, i) { let v = 0n; for (let k = 0; k < 8; k++) v = (v << 8n) | BigInt(b[i + k]); return v; } +function u16be(b, i) { return (b[i] << 8) | b[i + 1]; } +function hex(b) { let s = ''; for (const x of b) s += x.toString(16).padStart(2, '0'); return s; } + +/// The extension as the guest commits it (big-endian, the field order of `FinExt::to_bytes`). +export function parseExtension(publicValues) { + const b = publicValues instanceof Uint8Array ? publicValues : new Uint8Array(publicValues); + if (b.length === BLOCK_STATEMENT_LEN) return null; + if (b.length !== BLOCK_STATEMENT_LEN + FIN_EXT_LEN) throw new Error(`public values are ${b.length} bytes, expected ${BLOCK_STATEMENT_LEN} or ${BLOCK_STATEMENT_LEN + FIN_EXT_LEN}`); + const e = b.subarray(BLOCK_STATEMENT_LEN); + const number = u64be(b, 8); + const ext = { + number, + block_hash: hex(b.subarray(16, 48)), + chain_len: u64be(b, 332), + fin_version: u16be(e, 0), + table_root: hex(e.subarray(2, 34)), + history_root: hex(e.subarray(34, 66)), + history_first: u64be(e, 66), + lock: { + index: u64be(e, 74), + hash: hex(e.subarray(82, 114)), + number: u64be(e, 114), + signed: u64be(e, 122), + total: u64be(e, 130), + frozen_signed: u64be(e, 138), + frozen_total: u64be(e, 146), + daa: u64be(e, 154), + }, + flags: u16be(e, 162), + }; + ext.stale = (ext.flags & FLAG_STALE) !== 0; + ext.history_leaves = number - ext.history_first + 1n; + return ext; +} + +async function sha256(parts) { + let n = 0; for (const p of parts) n += p.length; + const buf = new Uint8Array(n); let o = 0; + for (const p of parts) { buf.set(p, o); o += p.length; } + return new Uint8Array(await crypto.subtle.digest('SHA-256', buf)); +} +function le64(v) { const b = new Uint8Array(8); let x = BigInt(v); for (let i = 0; i < 8; i++) { b[i] = Number(x & 0xffn); x >>= 8n; } return b; } +function fromHex(h) { const out = new Uint8Array(h.length / 2); for (let i = 0; i < out.length; i++) out[i] = parseInt(h.substr(i * 2, 2), 16); return out; } +function eq(a, b) { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; return true; } + +/// `leaf.hash()` of `igneum_fin_core::mmr::HistoryLeaf`: sha256(0x04 || number_le || block_hash || daa_le || table_root || keys_hash || total_le). +export async function leafHash(leaf) { + return sha256([new Uint8Array([4]), le64(leaf.number), fromHex(leaf.block_hash), le64(leaf.daa), fromHex(leaf.table_root), fromHex(leaf.keys_hash), le64(leaf.total)]); +} + +/// `MmrProof::verify`: the leaf sits at `proof.position` in a history of `leaves` leaves whose peaks bag to `rootHex`. +export async function verifyHistory(leaf, proof, rootHex, leaves) { + const peaks = proof.peaks.map(([h, p]) => [Number(h), fromHex(p)]); + if (BigInt(proof.position) >= BigInt(leaves) || proof.peak_index >= peaks.length) return false; + let count = 0n, last = null; + for (const [h] of peaks) { if (last !== null && last <= h) return false; last = h; count += 1n << BigInt(h); } + if (count !== BigInt(leaves)) return false; + let before = 0n; + for (let i = 0; i < proof.peak_index; i++) before += 1n << BigInt(peaks[i][0]); + const height = peaks[proof.peak_index][0]; + let idx = BigInt(proof.position) - before; + if (BigInt(proof.position) < before || idx >= (1n << BigInt(height)) || proof.siblings.length !== height) return false; + let node = await leafHash(leaf); + for (const [sibHex, left] of proof.siblings) { + if (left !== ((idx & 1n) === 1n)) return false; + const sib = fromHex(sibHex); + node = left ? await sha256([new Uint8Array([2]), sib, node]) : await sha256([new Uint8Array([2]), node, sib]); + idx >>= 1n; + } + if (!eq(node, peaks[proof.peak_index][1])) return false; + let root = peaks[peaks.length - 1][1]; + for (let i = peaks.length - 2; i >= 0; i--) root = await sha256([new Uint8Array([3]), peaks[i][1], root]); + return eq(root, fromHex(rootHex)); +} + +/// The question. `query` is null for the proof's own block, else `{ leaf, proof }` for an earlier chain block. +/// Returns { answer: 'final' | 'not final' | 'stale' | 'not in chain' | 'no claim', ...ext }. +export async function finalAt(publicValues, query = null, leaves = null) { + const ext = parseExtension(publicValues); + if (!ext) return { answer: 'no claim' }; + const count = leaves === null ? ext.history_leaves : BigInt(leaves); + let number = ext.number; + if (query) { + if (!(await verifyHistory(query.leaf, query.proof, ext.history_root, count))) return { answer: 'not in chain', ...ext }; + number = BigInt(query.leaf.number); + } + if (ext.stale) return { answer: 'stale', ...ext }; + const final = ext.lock.index > 0n && number <= ext.lock.number; + return { answer: final ? 'final' : 'not final', ...ext }; +} + +/// The trust row the card shows beside a proof-carried lock (design section 4.4, level 0 of 5.2). +export const TRUST_ROW = 'voter set: verified in the proof; blue set: from the prover, vetoed by full nodes (level 0)';