Compare commits
24 commits
master
...
pocket-fin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a39fcec369 | ||
|
|
8ff463a0f8 | ||
|
|
274db766c7 | ||
|
|
fa2efa2619 | ||
|
|
8f98dd9774 | ||
|
|
8b646a10d4 | ||
|
|
ee6ff4f0cb | ||
|
|
c8aef63aaf | ||
|
|
5d39642faf | ||
|
|
71503238b3 | ||
|
|
010fe74f9d | ||
|
|
6b4acfeed8 | ||
|
|
a708c7a400 | ||
|
|
dafc5871ae | ||
|
|
76fabd2d49 | ||
|
|
6157c0ec65 | ||
|
|
f9830f0385 | ||
|
|
856d5a51c9 | ||
|
|
99307ab6e4 | ||
|
|
06805e10d7 | ||
|
|
2fee09bafc | ||
|
|
3de63e1a17 | ||
|
|
8ec49ad9ab | ||
|
|
8b5e0a1ebf |
62 changed files with 5681 additions and 55 deletions
|
|
@ -2633,3 +2633,83 @@ in the same shape and reports a box behind its wanted binary.
|
|||
| Rig | the same, and a rig that leaves is itself a weight removal: at 459 MH/s on tonight's devnet it is about 20 percent of the weight, over the hour's budget by itself |
|
||||
| Pool | a pool node is one voter carrying its members' whole weight; a pool restart is the largest single removal on the network and must be sliced like the fleet's |
|
||||
| The network | finality by miner weight is only as steady as the miners' uptime; until public hash dwarfs the fleet, the fleet's supervisor is a consensus component |
|
||||
|
||||
## Finality in the proof, 7 October 2026 (branch fin-proof, fork branch fin-proof-node): the weight table inside the recursive segment proof, what it costs
|
||||
|
||||
Design `docs/design/finality-in-proof.md` (frontier rank 1, 3.3). The aggregator guest folds one chain block's blue blocks into the carried W2 table (key table inline, block ring witnessed, history MMR), verifies lock certificates against the table the proof committed at the checkpoint's own block, and commits a 164-byte extension. Everything behind `finality_in_proof_activation_daa` (never until the override file sets it); nothing on the devnet.
|
||||
|
||||
### What was built and tested (box igneum-build-1, 09:02 UK)
|
||||
|
||||
| What | Result |
|
||||
|---|---|
|
||||
| `igneum-fin-core` harness (real BLS keys through blst, the guest's curve through zkcrypto `bls12_381` under SP1's patch) | 7 of 7: known-failed first (today's `core.js` rule locks on a forged voter list from the attacker's node; the proof-carried table refuses the same certificate twice, then the honest one locks), the table equals a brute-force window count at every one of 500 blocks, double counting refused and ageing exact at the edge, two thirds inclusive (160 of 240 locks, 120 does not), the frozen table holds a 60 percent side and a leave releases it only after `leave_delay`, stale after a window with no lock, a light client answers final / not final / not in this chain from the extension and an MMR path, the two curves agree and reject a flipped bit |
|
||||
| `igneum-prove-core` 8 of 8, `igneum-prove-host` 9 of 9 (1 ignored) | the old 340-byte statement unchanged byte for byte without the finality input |
|
||||
| Node: `igneum-exec` 26 of 26 | the new veto test: a record without the extension after the switch, a tampered extension, an extension before the switch, each refused naming `fin_ext` |
|
||||
| Node: `kaspa-consensus-core` 122 of 123 | the one red, `fast_time_60x_file_is_the_devnet_at_60x`, is master's `infra/fast-time/override-60x.json` lagging `difficulty_v3_activation_daa` (predates this lane; the new switch is in the file) |
|
||||
| Guests re-pinned on the Mac (succinct toolchain, 5 min 32 s full, 2 min 59 s incremental) | aggregator ELF 319,744 to 767,424 bytes (the BLS12-381 pairing, hash-to-curve and the fold), id `0x12bff5be...`; the shard ELF's id moved too (`0x2b1a81cb...` to `0x39db9d96...`) with no source change, the per-machine id class of 5 October |
|
||||
| Public values | 340 bytes without the finality input, 504 with (the 164-byte extension); the compressed proof size is the recursion's constant |
|
||||
|
||||
### Measurements
|
||||
|
||||
Rig: the fleet lane's rz-4090 pod (RunPod, RTX 4090 24 GB, 96 threads, 251 GB, the SP1 6.8.1 CUDA prover `sp1-gpu-server`, the host built there with `--features cuda` from this branch's sources at 589703eb plus the measurement commits), 08:2x to 08:4x UTC, nothing else on the card. The box (igneum-build-1) was held by the attack lane's exclusive measure wait, so the cycle counts ran on the pod's CPU in SP1 execute mode (deterministic, the same count anywhere). Fixtures: the eight consecutive live chain blocks 81046 to 81053 of `proving/fixtures/chain/` (one empty shard each, written before the DAA field existed, so the synthetic witness takes a DAA base of 100,000). The witness is synthetic (`--mode fin-synth`): N keys with real BLS key pairs, a 2,000-block prefix so the table and ring are full, two blue blocks a chain block, every key revealed with its first block, one certificate signed by the heaviest 70 percent of the keys over the chain block four back, carried in block 81053. The cycle count is `--mode fin-execute`: the aggregator guest in execute mode (deferred proof verification off, as the existing `execute aggregator` row) once without the finality input and once with it, per block.
|
||||
|
||||
#### Cycle count per chain block, 100 keys (70 signers), aggregator guest in execute mode
|
||||
|
||||
| Block | Plain aggregator | With the finality fold | Extra | What landed | Witness bytes (bincode) |
|
||||
|---|---|---|---|---|---|
|
||||
| 81046 | 1,306,522 | 4,724,994 | 3,418,472 | the fold, no certificate | 14,563 |
|
||||
| 81047 to 81052 | 1,426,208 | 5,673,842 to 5,911,401 | 4,247,617 to 4,485,193 | the fold, no certificate | 14,884 to 16,357 |
|
||||
| 81053 | 1,426,208 | 14,133,370 | 12,707,162 | the fold and ONE certificate (70 signers of 100 voters; lock index 2702 at chain block 81049, 2,808 of 4,008 blocks signed) | 29,371 |
|
||||
|
||||
Reading, 100 keys. The fold without a certificate costs about 4.3 M cycles a block at 100 keys (two hashes of the 11.2 KB key table, two to three ring leaves at 18 hashes each, the history append; SHA-256 is software in this guest, no precompile patch yet). The certificate costs about 8.3 M cycles on top: the syscall counts of that block are the BLS12-381 precompiles at work (700 G1 adds for the 70 signers plus the hash-to-curve and the pairing: 8,820 G1 doubles, 71,234 Fp multiplications, 13,139 Fp2 multiplications, 37,588 Fp adds, 17,419 Fp subs), so the pairing and the hash-to-curve run on SP1's bls12-381 precompiles as the design intended, and the whole certificate step is under a fifth of the frontier gate (a)'s 50 M cycles.
|
||||
|
||||
#### Cycle count per chain block by table size, aggregator guest in execute mode (the pod's CPU; `fin-proof-artefacts/execute-*.json`)
|
||||
|
||||
| Keys in the table | Plain aggregator | Fold, no certificate (blocks 81047 to 81052) | Extra for the fold | Certificate block 81053 (signers) | Extra for the certificate alone | Witness bytes per block (fold / certificate block) |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 100 | 1,426,208 | 5,673,842 to 5,911,401 | 4.2 to 4.5 M | 14,133,370 (70) | 8.2 M | 15 KB / 29 KB |
|
||||
| 1,000 | 1,426,208 | 33,161,758 to 33,399,333 | 31.7 to 32.0 M | 78,157,096 (700) | 44.8 M | 123 KB / 245 KB |
|
||||
| 3,429 (10,000 keys asked, 3,429 with blocks in the 2,000-block prefix) | 1,426,208 | 107,350,350 to 107,587,905 | 105.9 to 106.2 M | 295,989,848 (3,429) | 188.4 M | 414 KB / 829 KB |
|
||||
|
||||
Reading. Two costs, both linear in the table, both with a named fix:
|
||||
|
||||
1. The fold is the key table hashed in and out at every block (112 bytes a key, SHA-256 in software: this guest has no sha2 precompile patch, only the aggregator's existing sha2 crate): about 31 K cycles per key per block (1,000 keys: 32 M). The sha2 precompile (sp1-patches RustCrypto-hashes) is the first fix, of the order of 10x on this part (approximate, from SP1's published precompile ratios; unmeasured here); the Merkle key table of the design's section 2 is the second, which makes the fold independent of the table size.
|
||||
2. The certificate is per signer: 64 K cycles a signer (700 signers: 44.8 M; 3,429: 188 M), and the syscall counts say what it is: 126 G1 doublings per signer (88,200 at 700) is the subgroup check `from_compressed` runs on every signer's key at every certificate, plus the square root of the decompression (the Fp multiplications: 465,614 at 700 signers against 71,234 at 70). Both are redundant: a key is checked in full once, at its reveal (the proof of possession), and the table is committed by hash, so the certificate step can take a revealed key unchecked, or the table can store the validated affine point (96 bytes against 48, no square root). With that the certificate falls to the aggregation's mixed additions plus the hash-to-curve and one pairing, which the 70-signer row bounds at under 8 M (the fixed part is most of that row). The frontier's gate (a), under 50 M cycles per certificate verification, holds at 1,000 voters with 700 signers today (44.8 M) and fails at 3,429 signers (188 M) until that fix lands.
|
||||
|
||||
#### Prove time on the RTX 4090 (the card to itself), a chain of 8 blocks, SP1 6.8.1 cuda (`fin-proof-artefacts/chain-*.json`)
|
||||
|
||||
| Run | Shard proof per block | Aggregation per block | Certificate block | End to end, 8 blocks | Final proof bytes | Verify (`verify-segment`) |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Plain (no finality input), 08:21:37Z | 3.1 to 3.3 s | 2.9 to 3.3 s | none | 52.2 s | 1,272,909 | 0.076 to 0.091 s |
|
||||
| With the finality fold, 1,000 keys, 08:28:53Z | 3.1 to 3.4 s | 9.8 to 11.4 s | 43.0 s (block 81053, 700 signers) | 146.2 s | 1,273,073 (+164: the extension) | 0.090 s |
|
||||
|
||||
`--mode final-at` on the finality proof: VERIFIED in 0.523 s (the light verifier's setup included), answered in under a microsecond from 504 bytes of public values: "not final (latest lock: checkpoint 2702 at chain block 81049)" for the proof's own block 81053, which is above the lock, the right answer; on the plain proof: "no finality claim in this proof", the verifier's known-failed case. The lock the proof carries: 2,808 of 4,008 blocks signed at the checkpoint's own table, frozen 0 of 0 (no earlier lock in the synthetic run).
|
||||
|
||||
Reading, per block at launch traffic (one certificate per 30 blocks): today's guest costs an aggregator 10.9 s a block plus 43 s once per 30 blocks, about 12.3 s a block against 3.1 s plain, 4x, at 1,000 keys on a 4090 by itself; with the two fixes above (sha2 precompile, validated keys stored) the fold's 32 M cycles become a few million and the certificate's 45 M about 8 M, so the aggregation lands near 4 s a block plus 8 s per certificate, about 4.3 s a block, 1.4x (approximate, from the cycle rows; the re-measure is the next item). The 12 GB-card question of 5 October is untouched: shard provers never see the fold; only the aggregator (a 24 GB card in the app's rule) pays it.
|
||||
|
||||
Per tier: home miner, any card, mining only: nothing changes. Shard prover (12 GB): nothing, the fold is the aggregator's. Aggregator (24 GB card): 4x the aggregation time today at 1,000 voters, 1.4x after the two fixes, paid from the same aggregator share (`proving_v1_aggregator_share_bps` is the parameter to revisit when the fixed guest is measured). Pool: nothing. Holder, wallet, tab: "locked, voter set verified in the proof" from 504 bytes and one verification, no node asked for the voter set. Rollup customer: the same proof carries state and finality. Node operator: the witness is 123 KB a block at 1,000 keys (the key table rides with every block in the prototype; the Merkle table takes it to a few KB). Devnet: nothing, the switch is never.
|
||||
|
||||
### After the two fixes and the Merkle key table (the same day, 08:5x UTC; the fp-2 pod, RunPod RTX 5090 32 GB, the fleet image, SP1 6.8.1 cuda, the card to itself; guest 0x4ab78fb1; `fin-proof-artefacts/fp2/`)
|
||||
|
||||
The three changes between the morning's rows and these: the sha2 precompile patch for the guest (every fold, ring and history hash), the certificate path over the signers' uncompressed points (on the curve and compressing to the committed key: no square root, no subgroup check per signer; the reveal's proof of possession did that once), and the key table as a Merkle tree (one path per touched key in the fold; the full table once per certificate, rebuilt over the dense slot prefix). Level 1 of the colouring was built in the same window but the synthetic witness carries no headers, so these rows are level 0's arithmetic; the headers' BLAKE2b (16 a block, software) is the one cost not in them.
|
||||
|
||||
| Keys in the table | Plain aggregator | Fold, no certificate (81047 to 81052) | Extra for the fold | Certificate block 81053 (signers) | Extra for the certificate alone | Witness bytes per block (fold / certificate block) |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 100 | 1,362,263 | 1,912,729 to 1,962,xxx | 0.55 to 0.60 M | 7,311,232 (70) | 5.4 M | 4.8 KB / 27 KB |
|
||||
| 1,000 | 1,362,263 | the same 1,912,729 to 1,962,xxx | 0.55 to 0.60 M | 19,905,947 (700) | 18.0 M | 4.8 KB / 208 KB |
|
||||
| 3,429 | 1,362,263 | the same | 0.55 to 0.60 M | 62,414,311 (3,429) | 60.5 M | 4.8 KB / 803 KB |
|
||||
|
||||
Reading. The fold no longer depends on the table size: 0.55 M cycles a block at 100, 1,000 and 3,429 keys (against 4.3 M, 32 M and 106 M in the morning), 427 to 463 SHA compressions on the precompile, and the witness is 4.8 KB a block whatever the table (against 123 KB at 1,000 keys). The certificate is where the table is still paid: the dense rebuild of the key table is 3N SHA compressions (4,664 at 1,000 keys, 14,384 at 3,429) and the per-signer part is now the on-curve check and the point decode (51,214 Fp multiplications at 700 signers against 465,614 in the morning; no G1 doublings at all). Per signer the certificate costs about 18 K cycles (was 64 K). Frontier gate (a), under 50 M cycles a certificate: holds at 1,000 voters (18.0 M, was 44.8 M) and misses at 3,429 (60.5 M, was 188 M). What is left in the 3,429 row is the table rebuild and the sort, not the curve; the next lever is a committed running total so the certificate step needs only the signers' leaves (a Merkle multiproof) and never the full table: the fold then maintains `total` through the dust, ban and leave transitions it already touches plus a due-list for the time crossings, and the certificate cost becomes per signer only (about 18 K each, 3,429 signers 62 M of which the rebuild is most). Not built this round.
|
||||
|
||||
#### Prove time on the RTX 5090 (fp-2, the card to itself), a chain of 8 blocks
|
||||
|
||||
| Run | Shard proof per block | Aggregation per block | Certificate block | End to end, 8 blocks | Final proof bytes |
|
||||
|---|---|---|---|---|---|
|
||||
| Plain (no finality input) | 2.1 to 2.7 s | 2.2 to 2.8 s | none | 42.4 s | 1,272,909 |
|
||||
| With the fold, 1,000 keys, after the fixes | 2.4 to 2.7 s | 2.7 to 3.0 s | 8.2 s (700 signers) | 50.3 s | 1,273,073 |
|
||||
|
||||
`final-at` on the finality proof: VERIFIED in 0.418 s, "not final (latest lock: checkpoint 2702 at chain block 81049)" for the proof's own block 81053, the right answer; the lock 2,808 of 4,008 signed.
|
||||
|
||||
The ratio, per block at launch traffic (one certificate per 30 blocks): plain 2.6 s a block; with the extension 2.85 s plus 8.2 s once in 30, 3.1 s a block, 1.2x (the morning's guest on the 4090 was 4x: 12.3 s against 3.1 s). At 3,429 signers the certificate block would be about 25 s (approximate, from the cycle ratio 60.5 to 18.0 M against the 8.2 s row), 3.6 s a block, 1.4x.
|
||||
|
||||
Per tier, revised: a home miner on any card, mining only, nothing changes; a shard prover (12 GB) never sees the fold; an aggregator (24 GB card) pays 1.2x today's aggregation time at 1,000 voters and 1.4x at 3,429, from the same aggregator share; a pool, nothing; a holder, the wallet, the tab get "locked, voter set verified in the proof" from 504 bytes and one verification, and at level 1 the blue set is pinned to headers (the remaining freedom is a colour swap inside one chain block's mergeset); a rollup customer's bridge verifies one proof for state and finality; a node operator relays 4.8 KB of witness a block plus the table once per certificate (208 KB at 1,000 keys, 803 KB at 3,429, the next lever's target); the devnet, nothing, the switch is never.
|
||||
|
|
|
|||
191
docs/design/finality-in-proof.md
Normal file
191
docs/design/finality-in-proof.md
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
# Finality carried inside the segment proof
|
||||
|
||||
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.
|
||||
|
||||
## 1. What the statement gains
|
||||
|
||||
The aggregator guest's public values (`BlockOutput`, 340 bytes, mirrored in consensus as `BlockStatement`) gain a fixed extension when the finality input is present. The layout before the activation is unchanged, byte for byte, so one pinned guest serves both sides of the switch.
|
||||
|
||||
| Field | Bytes | Meaning |
|
||||
|---|---|---|
|
||||
| `fin_version` | 2 | 1. Zero means "no finality claim" (the old layout never carries the extension) |
|
||||
| `table_root` | 32 | Commitment to the weight state after the segment's last chain block (section 2) |
|
||||
| `history_root` | 32 | Merkle mountain range over every chain block the proof chain attests: leaf `n` = `sha256(number ‖ block_hash ‖ daa_n ‖ table_root_n ‖ keys_hash_n ‖ total_n)` |
|
||||
| `history_first` | 8 | The chain block the attestation counts from (the activation block on the node's tracker); the proof chain's own root is `number - chain_len + 1` (section 4.5) |
|
||||
| `lock_index` | 8 | Highest checkpoint index the proof chain has verified a certificate for; 0 before the first |
|
||||
| `lock_hash` | 32 | That checkpoint's block hash |
|
||||
| `lock_number` | 8 | Its chain-block number |
|
||||
| `lock_signed` | 8 | Signed weight of that certificate, in blocks, at the checkpoint's own table |
|
||||
| `lock_total` | 8 | Total weight of that table |
|
||||
| `lock_frozen_signed` | 8 | Signed weight at the table of the previous lock (rule v3, Q5) |
|
||||
| `lock_frozen_total` | 8 | That frozen table's total less the keys that left (W7) |
|
||||
| `lock_daa` | 8 | DAA score of the certified checkpoint, so a verifier can judge staleness |
|
||||
| `flags` | 2 | bit 0 `stale`: the previous lock is more than one weight window older than the segment's last block and the guest refused every certificate since (section 4.4) |
|
||||
|
||||
164 bytes (`FinExt::LEN`). `BlockOutput::LEN2 = 504`. Keccak of the public values stays the record's statement, so `SegmentRecord` is unchanged (its `public_values` field already carries the bytes inline, spec 7.8 item 3).
|
||||
|
||||
What the extension says, in words: every chain block from the proof chain's first block to `number` is in `history_root`; the table at every one of those blocks is committed in its leaf; checkpoint `lock_index` at chain block `lock_number` carries a certificate whose signers hold `lock_signed` of `lock_total` at that block's own table, and `lock_frozen_signed` of `lock_frozen_total` at the previous lock's table; both pass two thirds. A verifier that trusts the aggregator program id and the chain of proofs from a trusted root learns all of that from 504 bytes and one proof verification.
|
||||
|
||||
## 2. The carried weight state
|
||||
|
||||
Spec 03 W2 as implemented on the node (`compute_weights`, `vendor/igneum-node/consensus/src/processes/finality.rs`): the weight of key k at checkpoint C is the number of blue blocks in C's past, counted along the selected chain through every chain block's mergeset blues, whose DAA score lies in `(daa(C) - W, daa(C)]`, W = 2,592,000 on mainnet, 7,200 on the devnet; a key under `dust` blocks is no voter; the canonical voter list is the keys above dust and not stripped or left, sorted by key hash; total is their sum.
|
||||
|
||||
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** (the Merkle form, built 7 October 2026 after the first measurement, section 6.1, showed the inline table at 31 K cycles a key a block). A binary Merkle tree of 2^24 leaf slots (`keys.rs`); a key takes the next free slot the first time it is seen and keeps it until its entry empties, so the non-empty leaves are the dense prefix `0..key_count` and a slot is never reused. An entry is `key_hash` 32, `pubkey` 48 (zero until revealed), `blocks` 8 (blue blocks in the window), `ban_until` 8, `leave_from` 8, `leave_until` 8; a leaf is `sha256(0x05 ‖ entry)`, an empty leaf the zero hash. The fold opens one path (24 siblings) per key it touches and writes it back; the state carries `keys_root` and `key_count`, never the table. The certificate step takes every entry with its slot once, rebuilds the root over the dense prefix (about 2N hashes at N keys), sorts by key hash for the canonical voter list of spec 3.10 C3 and refuses a key that appears twice. What a lying witness can do with the slots: insert a key a second time at a fresh slot (the guest cannot know the key has one already); the split is refused at the next certificate as a duplicate, so no certificate verifies while it stands, and the native compare vetoes the record on the chain.
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
Cost model per segment of N = 8 chain blocks at 1 block/s: about 16 blue blocks (8 chain blocks and their mergesets), 16 ring insertions and about 8 ring expiries at 22 hashes each, about 530 SHA-256 compressions; plus two hashes of the key table (in and out). Section 6 measures it.
|
||||
|
||||
## 3. The certificate inside the recursion
|
||||
|
||||
A certificate for index i names a checkpoint block `C_i` and a signer bitmap over the canonical voter list at `C_i` (spec 3.10 C3). Certificates ride in the coinbase extra data of later blocks (the `IGNF` section) and reach the prover through the exec RPC, as the node's finality state already exposes them. The guest takes any number of certificates per segment (in practice one per 30 s, so one every four segments at 1 block/s) and for each:
|
||||
|
||||
1. **The checkpoint is on this chain.** The witness gives the history leaf preimage of chain block `m` (`number`, `block_hash`, `table_root_m`, `total_m`, `daa_m`) and its MMR path against the `history_root` the guest holds at that moment; `block_hash` must equal the certificate's checkpoint. The guest cannot check blue score (the statement has no blue score), so it checks what it can: index strictly above `lock_index`, `m` strictly above `lock_number`, and `daa_m > lock_daa`. Index-to-block binding beyond that is the certificate's own job: two thirds of weight signed `(chain_id, i, hash(C_i))`.
|
||||
2. **The table at the checkpoint.** The witness gives the full key table as it stood at `m`; the guest hashes it and compares with `table_root_m` from the leaf (so the table is the one the proof chain itself committed when it passed `m`, never the prover's choice). The guest derives the voter list and `total` at `daa_m`, maps the bitmap positions to keys, sums their `blocks` into `signed`, and takes their public keys.
|
||||
3. **The signature.** Every signer must be revealed (a zero pubkey refuses the certificate, as the node does). The aggregate public key is the G1 sum of the signers; the message is `"igneum-vote-v1/" ‖ chain_id ‖ 0 ‖ index_le ‖ checkpoint` hashed to G2 under `IGNEUM_VOTE_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_`; the check is `e(agg_pk, H(m)) == e(g1, sig)`, the min-pubkey setting blst's `fast_aggregate_verify` implements. In-guest the curve is zkcrypto's `bls12_381` under SP1's patch (the Fp and Fp2 operations run on the BLS12-381 precompiles; the Miller loop and the final exponentiation run as Rust over them). The proof of possession on every reveal (item 2 of section 2) is what makes the aggregate sound against rogue keys; the guest never trusts a node's reveal check.
|
||||
4. **The tests.** Q3 floor: `3 x signed >= 2 x total_m` (inclusive, `FinalityParams::floor_met`). The active test of Q3 is implied by the floor since 4 October 2026 (O-3.15) and is not computed in the guest: participation (Q2) needs every vote carried in `C_i`'s past, 48 per block, which the proof does not carry; the node's active number is a liveness report, not a lock condition, and the guest emits none. Q5 frozen: when a previous lock exists, the witness gives the key table at `lock_number` (hash-checked against that leaf's `table_root`), the guest sums the signers' weights there, subtracts from that table's total the keys whose leave has taken effect at `daa_m`, and requires two thirds of what is left (`frozen_floor` on the node). Before the first lock there is no frozen table and Q3 alone decides, as on the node.
|
||||
5. **The first-month gate.** `daa_m >= min_daa` (C5), a parameter of the guest input bound into the statement by the native compare.
|
||||
6. On success the lock fields of section 1 are set to this certificate; the certificate's own bytes are not in the statement (the statement commits what was verified, not the input).
|
||||
|
||||
A certificate that fails any step makes the guest panic, which makes the proof impossible, so a prover that holds a bad certificate simply leaves it out. The statement then says "no new lock in this segment", which is true.
|
||||
|
||||
## 4. The verifier with no node
|
||||
|
||||
### 4.1 What a light client holds
|
||||
|
||||
A trusted root: the aggregator program id (the pinned manifest's id, shipped in the client as the verifier key is today) and one proof's public values the client accepted out of band at install (the release ships the latest wrapped proof the way spec 10.3 ships a trusted checkpoint). From then on the client holds the latest proof it verified, nothing else. No voter list, no weights, no headers.
|
||||
|
||||
### 4.2 Per update
|
||||
|
||||
1. Fetch the newest segment proof from any node (`igneum_getSegmentProofBytes`); verify it against the aggregator key (today SP1's light verifier, 0.032 s on a Mac core for the compressed form, bench-log 5 October; in a tab the Groth16 or Plonk wrapper of frontier 3.4, unmeasured).
|
||||
2. Check it extends what the client holds: `chain_id` equal; `shard_vk` and `agg_vk` the pinned ids; `chain_len` at least `number - held.number + held.chain_len` (the recursion verified every proof between); `history_root` consistent with the held one (the held `(number, history_root)` is an MMR prefix of the new one: the witness is the held peaks, bagged, and the client checks the new root re-bags them with the new leaves, log n hashes); `lock_index >= held.lock_index` and, when equal, the same `lock_hash` (a proof chain that drops or changes a lock is refused).
|
||||
3. Read `lock_index`, `lock_hash`, `lock_number`, `lock_signed / lock_total`, `lock_frozen_signed / lock_frozen_total`, and `stale`. The card says "locked at checkpoint i by x percent of 30-day weight, voter set: verified in the proof" and, when `stale`, "finality paused for more than a window; this client needs a fresh starting point".
|
||||
|
||||
### 4.3 "Is block B final?"
|
||||
|
||||
Final means B is on the chain through the latest lock, at or below it. For the proof's own last block: final iff `number <= lock_number`. For any other chain block: its history leaf and MMR path against `history_root`, then `leaf.number <= lock_number`. For a transaction: its receipt proof against the block's `receipts` commitment (spec 7.6 item 3) plus the above. The harness of section 7 answers exactly this from proof bytes alone.
|
||||
|
||||
### 4.4 What the client trusts, and the one departure from spec 03
|
||||
|
||||
| What the client trusts | Bounded by |
|
||||
|---|---|
|
||||
| The proof system and the pinned aggregator id | Spec 10.1 as today: a soundness bug is a light-client problem; full nodes keep verifying the real BLS certificate natively and veto any record whose extension differs from their own (section 5.1) |
|
||||
| The root proof it started from | The same out-of-band assumption as spec 10.3's trusted checkpoint |
|
||||
| The blue colouring of counted blocks | Section 5.2: the prototype takes it as a witness; the carried record is vetoed by full nodes; a client that takes proofs from an untrusted node is exposed to the swap of a blue block for a red one in the same chain block's past, and to nothing cheaper (level 1), or to a free lie (level 0). The trust row says which level the shipped guest is at |
|
||||
| Nothing else from any node | Headers, votes, certificates and the voter set are never fetched |
|
||||
|
||||
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, `number - chain_len + 1`. 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; `history_first` is not restamped, it stays the attestation's own start (the node's tracker counts from the activation block and never re-roots, so a restamped field would fail the native compare on every restart). 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)
|
||||
|
||||
The table is a second implementation of W2. Two defences, both required. First, the native compare: from the activation, `check_segment_record` in the exec layer computes the extension natively (`FinTracker`, the same `igneum_prove_core::fin` code fed by the node's own `ChainBlockRecord.mergeset`, its finality state's certificates, reveals, evidence and leaves) and refuses a record whose `table_root`, `history_root` or lock fields differ, the veto of spec 7.2 item 5 extended to the new fields. A lying prover's record pays nothing and is carried by no honest block. Second, the differential test: the tracker and the node's `weights_at` agree on every key's blocks and on the voter list at every checkpoint of a fast-time run (the node test in section 7); a disagreement is a bug in one of the two and fails the build. What a lie in the carried table can reach is therefore a light client that takes a proof from the liar directly, which is section 5.2.
|
||||
|
||||
### 5.2 The blue set from the prover (the Kaspa developer's attack, frontier 4.2)
|
||||
|
||||
The sharp one. The guest adds the blue blocks a witness names; it does not run GHOSTDAG. Three levels, in order of what a lie can still do:
|
||||
|
||||
| Level | What the guest checks per counted block | What a lie can still do | Cost per segment |
|
||||
|---|---|---|---|
|
||||
| 0 (the first prototype, the morning of 7 October) | Nothing beyond the arithmetic: the pair `(block_hash, key_hash, daa)` is the prover's word | Count blocks that do not exist, omit honest blocks: free to a prover whose proof a client takes directly; vetoed on the chain by every full node | none |
|
||||
| 1 (BUILT, `header.rs` and `check_headers` in `fold.rs`; the witness carries the chain block's header and every mergeset block's, blue and red) | Every header hashes to its hash (`kaspa_hashes::BlockHash`, BLAKE2b-256 keyed `BlockHash`, the field order of `consensus/core/src/hashing/header.rs`; the differential test checks the guest's hash against the chain's); the chain block's header names this block and its DAA score; the previous chain block is a direct parent and outranks every other direct parent by (blue work, hash), GHOSTDAG's selected-parent rule; every blue's key and DAA score are its header's; the blue count equals `blue_score(C) - blue_score(selected parent)`; every mergeset block is reached from the chain block by parent links through mergeset blocks (every direct parent other than the selected parent is in the mergeset, parents being an antichain, so the walk never leaves the witness); reds ride into the ring uncounted and flagged, so a block seen in any mergeset, blue or red, is refused in every later one | Swap the colours of two mergeset blocks of one chain block (the blue count holds, both are real blocks reached by parent links, neither was seen before). Nothing invented, nothing omitted without a substitute of the same block's past | the harness and the node's differential test; cycle count in section 6.1 (level 1 witnesses were not in the synthetic run; the headers are 16 BLAKE2b hashes a block, software, approximate 1 to 2 M cycles, to measure on a real export) |
|
||||
| 2 (open, the frontier's measurement) | GHOSTDAG's blue-set rule for each merged block (anticone at most k) inside the guest | Nothing about the colouring | unmeasured; the reason this round ships level 1 as the next step, not level 2 |
|
||||
|
||||
The light client's trust row names the level the shipped guest is at. "Voter set: verified" on the card (frontier gate b) is honest at level 1 with the colour-swap bound stated, and fully at level 2. Level 1 is what the pinned guest 0x4ab78fb1 carries when the witness has headers; a witness without headers folds at level 0, and the node's tracker always supplies headers, so a record carried on the chain is level 1 and a level 0 proof chain is a light-client matter only (the extension does not say which level made it: adding a level field is the next cut's one-line change, noted here so the card can read it).
|
||||
|
||||
### 5.3 A stale table
|
||||
|
||||
The witness is always the state the previous proof committed, hash-checked; a prover cannot present an old table as the current one because `end_daa` is inside the commitment and must equal the previous proof's last chain block's DAA score, and the next segment's first chain block must be that block's child (the existing `parent_hash` chain). A certificate is tested at the table of its own checkpoint block (section 3 item 2), read from the history leaf, never at the segment's current table; the 60 to 70 blocks between a checkpoint and its certificate's arrival (determination depth d plus relay) change nothing.
|
||||
|
||||
### 5.4 The LEAVE item (W7)
|
||||
|
||||
A leave carried in a block removes its key from every denominator `leave_delay` after the carrier and until the leave is a window old. In the guest the leave enters as a witness with its signature verified and its carrier DAA score named by the witness; the native tracker names the same carrier the node's `leaves_at` does (the lowest-DAA carrier in the checkpoint's past), so the compare pins it. A prover that withholds a leave makes the frozen denominator larger, which is the strict direction: it can only turn a lock into a refusal, never the reverse. The same holds for evidence (a withheld ban leaves a stripped key's weight in the total and its signature in the certificate, and the node's compare refuses the record). Leaves are what keep the never-expiring frozen table of 4.4 live: a fleet that leaves cleanly shrinks the frozen denominator within the hour and the proof chain keeps locking, which is the case the 6 October pause showed the node needs too.
|
||||
|
||||
### 5.5 A split table during a pause
|
||||
|
||||
Two sides of a partition each build their own proof chain from the common prefix. Each side's table counts only its own blocks after the split (W2 is per view, spec 3.7 item 9). Under the frozen test neither side locks until it holds two thirds of the table at the last common lock, which a side under two thirds never does, and the guest never lets that table expire; so neither side's proof chain ever claims a lock the other side could not also verify, and a light client on either side sees `stale` after a window rather than a side-only lock. At the heal the chain follows one side's certificates (3.5, the certificate-driven reorg); the losing side's proof chain is abandoned with its segments, as any reorg deeper than a segment abandons records today (spec 7.8 item 7, the unproven rule), and the winning side's chain is re-proven from the fork point by the recursion restarting at an unproven segment. A client that held a proof from the losing side sees its `history_root` is no prefix of the new chain's and must restart from its root: the new chain's proof chain still passes through the client's root, so the restart is a re-sync, not a new trust decision. This is the fast-time scenario of section 7 (C4 sweep, `docs/fud-ledger.md`).
|
||||
|
||||
### 5.6 Two certificates at one index
|
||||
|
||||
The guest accepts the first certificate it verifies for an index and refuses another for the same index in any later segment (`index > lock_index`), as the node keeps the certificate it verified first (3.11 item 4). A prover cannot replace a lock. A heavier fold certificate (Q4) over the same block, arriving later, is simply not verified: the lock stands at the weight first seen, which is at least two thirds.
|
||||
|
||||
## 6. Costs
|
||||
|
||||
Baselines on record: a chained aggregation on the RTX 5090 costs 2.5 s with the card to itself and 9.7 s while it mines (bench-log 5 October, `chain-pc2-pv1c` and the agg-cost entry); on the Mac CPU 52 to 59 s; the compressed segment proof is 1,272,909 bytes whatever `chain_len`; `verify-segment` 0.032 s. The lane brief names the CPU prover on the box at 137 s and 28 GB for the smallest shard. What this design adds, by part, with the measurement in `docs/bench-log.md` under "finality in proof" once run:
|
||||
|
||||
| Part | Where | Expected (approximate, before measurement) | Gate |
|
||||
|---|---|---|---|
|
||||
| Table update, no certificate, 1,000 keys | guest cycles | under 5 M (two table hashes of 112 KB on the SHA-256 precompile, 530 ring hashes) | measured in section 6.1 |
|
||||
| Table update, 10,000 keys | guest cycles | about 40 M (two hashes of 1.1 MB): the Merkle form of section 2 when this binds | measured |
|
||||
| One certificate, 1,000 voters | guest cycles | G1 aggregation of up to 1,000 keys on the precompile, one hash-to-G2, one pairing: tens of millions of cycles from memory of SP1's bls12-381 benchmarks, unmeasured here | frontier gate (a): under 50 M |
|
||||
| Proof bytes | public values | +164 bytes; the compressed proof size is unchanged (constant) | none |
|
||||
| Prover time per segment | 5090, box CPU | the cycle count divided by the measured cycles per second of the aggregator (the 5090 aggregation at about 2.5 s is the known cost of the existing guest) | section 6.2 |
|
||||
| Verifier, native | Mac core | unchanged, 0.032 s: the extension is parsed, not verified separately | section 6.3 |
|
||||
| Verifier, browser WASM | tab | the wrapped-proof verifier of frontier 3.4 (unmeasured); the extension parse and the MMR check are microseconds | open, frontier 3.4 |
|
||||
| Witness bytes per segment | gossip | the key table (112 bytes per key) once per segment at the prototype, plus the certificate's bitmap and the ring leaves; 1.1 MB at 10,000 keys, which is the reason the Merkle form exists | section 6.1 reports the bytes |
|
||||
|
||||
Per tier (the standing rule): a home miner on any card mines as before, since the table update is the aggregator's work and a shard prover never sees it; a 12 GB prover that aggregates pays the extension's cycles once per segment and the certificate's once per 30 s, so about one more shard in thirty at launch traffic (frontier 3.3's figure, to be replaced by section 6.1's); a rig or pool that aggregates pays the same from the same 20 percent pool, so the aggregator's share (`proving_v1_aggregator_share_bps`) is the parameter to revisit once the cycles are known; a holder or wallet user gets "locked, voter set verified in the proof" from 504 bytes and one verification; a rollup customer's bridge verifies one proof for state and finality, the proof bridge of spec 7.3 delivered early; a node operator relays the key table with the segment witness (the bytes above).
|
||||
|
||||
### 6.1 Cycle counts (measured 7 October 2026, 08:2x to 08:3x UTC, the rz-4090 pod's CPU, SP1 execute mode; `docs/bench-log.md`, "Finality in the proof")
|
||||
|
||||
| Keys | Plain aggregator | Fold, no certificate | Certificate block (signers) |
|
||||
|---|---|---|---|
|
||||
| 100 | 1.43 M | 5.7 to 5.9 M | 14.1 M (70) |
|
||||
| 1,000 | 1.43 M | 33.2 to 33.4 M | 78.2 M (700) |
|
||||
| 3,429 | 1.43 M | 107.4 to 107.6 M | 296.0 M (3,429) |
|
||||
|
||||
The fold is the key table hashed in and out with software SHA-256 (31 K cycles a key a block); the certificate is 64 K cycles a signer, of which the subgroup check and the decompression of every signer's key are the bulk (126 G1 doublings a signer in the syscall counts), both redundant once a key is validated at its reveal. Gate (a) (under 50 M a certificate) holds at 700 signers (44.8 M extra) and fails at 3,429 until the stored-key fix lands. The two fixes, in order: the sha2 precompile patch for the guest, and the table storing the validated affine point (or the certificate step taking a revealed key unchecked); then the Merkle key table for the fold at scale.
|
||||
|
||||
### 6.2 Prover time (measured, RTX 4090 to itself, SP1 6.8.1 cuda, 8 blocks 81046 to 81053)
|
||||
|
||||
| Run | Aggregation per block | Certificate block | End to end | Proof bytes |
|
||||
|---|---|---|---|---|
|
||||
| Plain | 2.9 to 3.3 s | none | 52.2 s | 1,272,909 |
|
||||
| With the fold, 1,000 keys | 9.8 to 11.4 s | 43.0 s | 146.2 s | 1,273,073 |
|
||||
|
||||
At one certificate per 30 blocks: 12.3 s a block against 3.1 s, 4x today at 1,000 keys; about 1.4x after the two fixes (approximate, from the cycle rows).
|
||||
|
||||
### 6.2a After the two fixes and the Merkle table (measured 7 October 2026, 08:5x UTC, the fp-2 pod's RTX 5090; `docs/bench-log.md`, the same entry)
|
||||
|
||||
| Keys | Plain aggregator | Fold, no certificate | Certificate block (signers) |
|
||||
|---|---|---|---|
|
||||
| 100 | 1.36 M | 1.91 to 1.96 M | 7.3 M (70) |
|
||||
| 1,000 | 1.36 M | the same | 19.9 M (700) |
|
||||
| 3,429 | 1.36 M | the same | 62.4 M (3,429) |
|
||||
|
||||
The fold is 0.55 M a block whatever the table (4.8 KB of witness); the certificate is 18 K cycles a signer plus the table rebuild (3N SHA compressions). Gate (a) holds at 1,000 voters (18.0 M extra) and misses at 3,429 (60.5 M); the next lever is a committed running total, so the certificate takes only the signers' leaves by multiproof. On the 5090: plain aggregation 2.2 to 2.8 s a block, with the fold 2.7 to 3.0 s, the certificate block 8.2 s; 42.4 against 50.3 s for 8 blocks; at one certificate per 30 blocks 1.2x at 1,000 voters, about 1.4x at 3,429.
|
||||
|
||||
### 6.3 Verifier time (measured)
|
||||
`verify-segment` 0.090 s on the finality proof (0.076 to 0.091 s on the plain one, the same light verifier); `final-at` answers from the 504 bytes of public values in under a microsecond after the verification; a browser's WASM verify of the wrapped proof stays frontier 3.4's measurement.
|
||||
|
||||
## 7. What is built in this round, and the tests
|
||||
|
||||
1. `igneum-prove-core::fin`: the state, its hashing, the ring, the MMR, the segment update, the certificate check with the BLS verify behind a trait (blst natively in tests, `bls12_381` in the guest), the voter list, the frozen test, the leave and ban rules. Unit tests: the table after a window agrees with a brute-force count; a block counted twice is refused; ageing is exact at the window edge; a certificate under two thirds is refused and one at exactly two thirds locks (inclusive); a frozen table holds a side that lost its partner; a leave shrinks the frozen denominator after the delay and not before; a second certificate at a locked index is refused; the stale flag sets after a window without a lock and no certificate passes after it.
|
||||
2. The aggregator: `AggInput.fin: Option<FinInput>`; the output extension; the same `aggregate` function on host and guest.
|
||||
3. The known-failed case first, as the standing rule asks: the harness builds a chain with an attacker key that holds 20 percent of real weight and presents a certificate over a checkpoint with a voter list of its own making in which it holds 70 percent. Today's light client (`site/verify/core.js`, which takes `voters` from the node) accepts that certificate when the node is the attacker's; the harness shows the acceptance (the JS logic ported to the test), then shows the proof-carried table refusing the same certificate. The first green test is the second half.
|
||||
3a. Level 1 of 5.2 (built the same day after the first measurement): `header.rs`, `check_headers`, reds in the ring, the tracker's `header_witness`, the harness test `level_one_pins_the_blues_to_headers_and_parent_links` and the node's differential test `the_proof_fold_agrees_with_weights_at_on_a_real_dag` (a 120-block DAG with side blocks and reds; the fold's table equals `weights_at` and `voters_at_block` at every chain block; a dropped blue and a recoloured red are refused).
|
||||
4. The harness `proving/igneum-prove/host --mode final-at`: reads a proof file, optionally a block's leaf and MMR path, and prints `final at checkpoint N` or `not final` or `stale`, from the proof bytes and the pinned aggregator key alone; a test runs it against a chain the stub proof system produced and against one tampered byte.
|
||||
5. The node: `finality_in_proof_activation_daa` in `Params` (default `u64::MAX`, the override file sets it, in the digest once set); `BlockStatement` with the extension; `FinTracker` in the exec layer; `check_segment_record` compares the extension from the activation; the exec RPC exposes a chain block's finality witness (`igneum_getFinalityWitness(first, last)`: mergeset blues, certificates carried in the range, reveals, evidence and leaves with their carriers) so the app's aggregator step can build `FinInput`. Node unit test: the tracker's table at every checkpoint of a fast-time chain equals `weights_at` (the differential test of 5.1).
|
||||
6. The wallet-side stub: `final-at` as a library function the app and the wallet call, and `site/verify/finproof.js`, the browser half, which parses the extension and answers the same question from public values a verifier handed it, with the trust row text of 4.4.
|
||||
|
||||
What is not in this round: level 2 of 5.2 (GHOSTDAG in the guest), the wrapped verifier in WASM (frontier 3.4), key succession (W5, not on the node either), the certificate's aggregator sortition proof (verified by the node, meaningless to a light client, not in the guest), and a level field in the extension.
|
||||
|
||||
## 8. What moves the digest
|
||||
|
||||
Nothing until `finality_in_proof_activation_daa` is set. Setting it is a consensus change for the statement compare only: a node without the switch accepts the old 340-byte statement and refuses the extended one as "public values are not a block statement", so every node must run this build before the height, as proving v1 did. The pinned aggregator id changes once at re-pin (every prover and verifier moves together, `proving/README.md`); the old id stays accepted for records whose `fin_version` is zero until the activation, after which `agg_vk` must be the new id. The parameter joins the digest with the proving v1 set (`params.rs`, the `field(&mut h, ...)` list), so a mismatched file splits at the handshake, as every other switch. Rollout: Devnet 2 first, the gate of `tools/fleet/devnet2-gate.sh`; the live devnet by the 95 percent signal with a floor height, never a fixed height.
|
||||
|
|
@ -58,6 +58,7 @@
|
|||
"program_class_v4_activation_daa": 18446744073709551615,
|
||||
"program_class_v4_signal_window_daa": 120,
|
||||
"proving_v1_fresh_rule_daa": 18446744073709551615,
|
||||
"finality_in_proof_activation_daa": 18446744073709551615,
|
||||
"exec_restart_number": 18446744073709551615,
|
||||
"exec_restart_hash": "",
|
||||
"exec_restart_trust_daa": 18446744073709551615,
|
||||
|
|
|
|||
2
light/.gitignore
vendored
Normal file
2
light/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
target/
|
||||
*/target-remote/
|
||||
880
light/Cargo.lock
generated
Normal file
880
light/Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,880 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "ark-bn254"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d69eab57e8d2663efa5c63135b2af4f396d66424f88954c21104125ab6b3e6bc"
|
||||
dependencies = [
|
||||
"ark-ec",
|
||||
"ark-ff",
|
||||
"ark-std",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-crypto-primitives"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e0c292754729c8a190e50414fd1a37093c786c709899f29c9f7daccecfa855e"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"ark-crypto-primitives-macros",
|
||||
"ark-ec",
|
||||
"ark-ff",
|
||||
"ark-relations",
|
||||
"ark-serialize",
|
||||
"ark-snark",
|
||||
"ark-std",
|
||||
"blake2",
|
||||
"derivative",
|
||||
"digest",
|
||||
"fnv",
|
||||
"merlin",
|
||||
"rayon",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-crypto-primitives-macros"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e89fe77d1f0f4fe5b96dfc940923d88d17b6a773808124f21e764dfb063c6a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-ec"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"ark-ff",
|
||||
"ark-poly",
|
||||
"ark-serialize",
|
||||
"ark-std",
|
||||
"educe",
|
||||
"fnv",
|
||||
"hashbrown",
|
||||
"itertools",
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"rayon",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-ff"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70"
|
||||
dependencies = [
|
||||
"ark-ff-asm",
|
||||
"ark-ff-macros",
|
||||
"ark-serialize",
|
||||
"ark-std",
|
||||
"arrayvec",
|
||||
"digest",
|
||||
"educe",
|
||||
"itertools",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"rayon",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-ff-asm"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-ff-macros"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-groth16"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88f1d0f3a534bb54188b8dcc104307db6c56cdae574ddc3212aec0625740fc7e"
|
||||
dependencies = [
|
||||
"ark-crypto-primitives",
|
||||
"ark-ec",
|
||||
"ark-ff",
|
||||
"ark-poly",
|
||||
"ark-relations",
|
||||
"ark-serialize",
|
||||
"ark-std",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-poly"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"ark-ff",
|
||||
"ark-serialize",
|
||||
"ark-std",
|
||||
"educe",
|
||||
"fnv",
|
||||
"hashbrown",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-relations"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec46ddc93e7af44bcab5230937635b06fb5744464dd6a7e7b083e80ebd274384"
|
||||
dependencies = [
|
||||
"ark-ff",
|
||||
"ark-std",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-serialize"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7"
|
||||
dependencies = [
|
||||
"ark-serialize-derive",
|
||||
"ark-std",
|
||||
"arrayvec",
|
||||
"digest",
|
||||
"num-bigint",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-serialize-derive"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-snark"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d368e2848c2d4c129ce7679a7d0d2d612b6a274d3ea6a13bad4445d61b381b88"
|
||||
dependencies = [
|
||||
"ark-ff",
|
||||
"ark-relations",
|
||||
"ark-serialize",
|
||||
"ark-std",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ark-std"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"rand",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"constant_time_eq",
|
||||
"cpufeatures 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
|
||||
dependencies = [
|
||||
"bytemuck_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck_derive"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derivative"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "educe"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417"
|
||||
dependencies = [
|
||||
"enum-ordinalize",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
||||
|
||||
[[package]]
|
||||
name = "enum-ordinalize"
|
||||
version = "4.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677"
|
||||
dependencies = [
|
||||
"enum-ordinalize-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enum-ordinalize-derive"
|
||||
version = "4.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "igneum-light"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ark-bn254",
|
||||
"ark-ec",
|
||||
"ark-ff",
|
||||
"ark-groth16",
|
||||
"sha2",
|
||||
"sp1-verifier",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "igneum-light-cli"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"igneum-light",
|
||||
"sha2",
|
||||
"substrate-bn-succinct-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "igneum-light-wasm"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"igneum-light",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keccak"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
|
||||
dependencies = [
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
||||
dependencies = [
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.190"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||
|
||||
[[package]]
|
||||
name = "merlin"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"keccak",
|
||||
"rand_core",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hex"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6"
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "sp1-verifier"
|
||||
version = "6.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aca7223cc7a77e42536c7229c8016672737aab07f6149097d9c7a26324b29814"
|
||||
dependencies = [
|
||||
"ark-bn254",
|
||||
"ark-ec",
|
||||
"ark-ff",
|
||||
"ark-groth16",
|
||||
"ark-serialize",
|
||||
"blake3",
|
||||
"cfg-if",
|
||||
"hex",
|
||||
"lazy_static",
|
||||
"sha2",
|
||||
"substrate-bn-succinct-rs",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
|
||||
|
||||
[[package]]
|
||||
name = "substrate-bn-succinct-rs"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a241fd7c1016fb8ad30fcf5a20986c0c4538e8f15a1b41a1761516299e377ec1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder",
|
||||
"cfg-if",
|
||||
"crunchy",
|
||||
"lazy_static",
|
||||
"num-bigint",
|
||||
"rand",
|
||||
"rustc-hex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.2.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e0d2eaa99c3c2e41547cfa109e910a68ea03823cccad4a0525dcbc9b01e8c71"
|
||||
dependencies = [
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.61"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879fb705ce98c32e41ebdb970fbe1204f8492423b314c6ab0354c3e7b5542866"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.61"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "708882a28301d604fa039cc7727607a98d04c4b86dc76ec9cc683f805d709759"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
25
light/Cargo.toml
Normal file
25
light/Cargo.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# The light client in the pocket (docs/design/finality-object.md, docs/design/phone-app.md "igneum-light"): one Rust
|
||||
# crate that parses the finality object, verifies its SP1 Groth16 wrap over bn254 and answers "final at checkpoint N"
|
||||
# from the object and a history path alone; a WebAssembly wrapper for the tab and a CLI for test vectors and timing.
|
||||
# Its own workspace: nothing here depends on the node or the prover, so a phone or a tab builds it alone.
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["igneum-light", "igneum-light-wasm", "igneum-light-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[workspace.dependencies]
|
||||
# The SP1 Groth16 verifier, the version the aggregator guest is proven with (proving/igneum-prove/Cargo.toml pins
|
||||
# sp1-sdk =6.8.1); `full` off: no compressed-proof verifier, no recursion machine, so it compiles to wasm32.
|
||||
sp1-verifier = { version = "=6.8.1", default-features = false }
|
||||
sha2 = { version = "0.10.8", default-features = false }
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
15
light/igneum-light-cli/Cargo.toml
Normal file
15
light/igneum-light-cli/Cargo.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
[package]
|
||||
name = "igneum-light-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Test vectors, encodings and the native timing of igneum-light"
|
||||
|
||||
[[bin]]
|
||||
name = "igneum-light"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
igneum-light = { path = "../igneum-light", features = ["std"] }
|
||||
bn = { package = "substrate-bn-succinct-rs", version = "=0.6.0" }
|
||||
sha2 = { workspace = true }
|
||||
284
light/igneum-light-cli/src/main.rs
Normal file
284
light/igneum-light-cli/src/main.rs
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
//! igneum-light on the command line: the test vectors of docs/design/finality-object.md, the encodings, and the
|
||||
//! native timing of the verifier (the phone path's proxy on a Mac until a phone build exists).
|
||||
//!
|
||||
//! igneum-light make-stub --pv <504 or 340 bytes> --key-id <hex32> --out <file>
|
||||
//! igneum-light make-timing --pv <file> --key-id <hex32> --out <file> kind 1, curve points that are on the
|
||||
//! curve and prove nothing: the pairing runs
|
||||
//! igneum-light verify --object <file> [--query <file>] --key-id <hex32> --chain-id <n> [--genesis <unix s>]
|
||||
//! igneum-light time --object <file> --key-id <hex32> --chain-id <n> [--rounds 20]
|
||||
//! igneum-light url --object <file> [--query <file>] [--base https://igneum.network/pocket/]
|
||||
//! igneum-light vectors --pv <file> --key-id <hex32> --out-dir <dir> the vector set with its manifest
|
||||
|
||||
use igneum_light::object::{base64url_encode, PV_LEN_FIN};
|
||||
use igneum_light::{json::verdict_json, mmr::Mmr, Answer, Clock, FinalityObject, HistoryLeaf, Pinned, ProofVerdict, Query, Statement, Verifier, KIND_SP1_GROTH16};
|
||||
use bn::Group;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
|
||||
fn args() -> (String, HashMap<String, String>) {
|
||||
let mut it = std::env::args().skip(1);
|
||||
let cmd = it.next().unwrap_or_default();
|
||||
let mut m = HashMap::new();
|
||||
let mut key: Option<String> = None;
|
||||
for a in it {
|
||||
if let Some(k) = a.strip_prefix("--") {
|
||||
if let Some(prev) = key.take() {
|
||||
m.insert(prev, String::from("1"));
|
||||
}
|
||||
key = Some(k.to_string());
|
||||
} else if let Some(k) = key.take() {
|
||||
m.insert(k, a);
|
||||
}
|
||||
}
|
||||
if let Some(prev) = key.take() {
|
||||
m.insert(prev, String::from("1"));
|
||||
}
|
||||
(cmd, m)
|
||||
}
|
||||
|
||||
fn hex32(s: &str) -> [u8; 32] {
|
||||
let s = s.trim().trim_start_matches("0x");
|
||||
let b = (0..32).map(|i| u8::from_str_radix(&s[2 * i..2 * i + 2], 16).expect("hex")).collect::<Vec<_>>();
|
||||
b.try_into().unwrap()
|
||||
}
|
||||
|
||||
fn hex(b: &[u8]) -> String {
|
||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
||||
fn fq_be(f: bn::Fq) -> [u8; 32] {
|
||||
let mut b = [0u8; 32];
|
||||
f.to_big_endian(&mut b).unwrap();
|
||||
b
|
||||
}
|
||||
|
||||
/// A proof whose three points are the curve generators: on the curve, so the verifier loads them and runs the
|
||||
/// pairing, and wrong, so it refuses. The accept path costs the same pairing.
|
||||
fn timing_proof() -> [u8; 256] {
|
||||
let g1 = bn::AffineG1::from_jacobian(bn::G1::one()).unwrap();
|
||||
let g2 = bn::AffineG2::from_jacobian(bn::G2::one()).unwrap();
|
||||
let mut p = [0u8; 256];
|
||||
p[0..32].copy_from_slice(&fq_be(g1.x()));
|
||||
p[32..64].copy_from_slice(&fq_be(g1.y()));
|
||||
p[64..96].copy_from_slice(&fq_be(g2.x().imaginary()));
|
||||
p[96..128].copy_from_slice(&fq_be(g2.x().real()));
|
||||
p[128..160].copy_from_slice(&fq_be(g2.y().imaginary()));
|
||||
p[160..192].copy_from_slice(&fq_be(g2.y().real()));
|
||||
p[192..224].copy_from_slice(&fq_be(g1.x()));
|
||||
p[224..256].copy_from_slice(&fq_be(g1.y()));
|
||||
p
|
||||
}
|
||||
|
||||
fn timing_object(key_id: [u8; 32], pv: Vec<u8>) -> FinalityObject {
|
||||
FinalityObject { kind: KIND_SP1_GROTH16, key_id, public_values: pv, nonce: [0u8; 32], proof: timing_proof() }
|
||||
}
|
||||
|
||||
fn read(p: &str) -> Vec<u8> {
|
||||
std::fs::read(p).unwrap_or_else(|e| panic!("read {p}: {e}"))
|
||||
}
|
||||
|
||||
fn now_s() -> i64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64
|
||||
}
|
||||
|
||||
/// A synthetic 504-byte statement over a synthetic history, for the vectors that need a history the CLI can prove
|
||||
/// paths in: `n` chain blocks from `first`, lock at `lock_number`, the tail block `first + n - 1`.
|
||||
fn synthetic(chain_id: u64, key_id: [u8; 32], first: u64, n: u64, lock_number: u64, stale: bool) -> (Vec<u8>, Vec<HistoryLeaf>, Mmr) {
|
||||
let mut leaves = Vec::new();
|
||||
let mut mmr = Mmr::default();
|
||||
for i in 0..n {
|
||||
let number = first + i;
|
||||
let leaf = HistoryLeaf {
|
||||
number,
|
||||
block_hash: Sha256::digest(format!("igneum-pocket-vector-block-{number}").as_bytes()).into(),
|
||||
daa: 100_000 + 3 * i,
|
||||
table_root: Sha256::digest(format!("table-{number}").as_bytes()).into(),
|
||||
keys_hash: Sha256::digest(format!("keys-{number}").as_bytes()).into(),
|
||||
total: 4008,
|
||||
};
|
||||
mmr.push(&leaf);
|
||||
leaves.push(leaf);
|
||||
}
|
||||
let last = &leaves[leaves.len() - 1];
|
||||
let lock = &leaves[(lock_number - first) as usize];
|
||||
let mut pv = Vec::with_capacity(PV_LEN_FIN);
|
||||
pv.extend_from_slice(&chain_id.to_be_bytes());
|
||||
pv.extend_from_slice(&last.number.to_be_bytes());
|
||||
pv.extend_from_slice(&last.block_hash);
|
||||
pv.extend_from_slice(&leaves[leaves.len() - 2].block_hash);
|
||||
pv.extend_from_slice(&1u32.to_be_bytes());
|
||||
for tag in ["tx", "pre", "post", "receipts"] {
|
||||
pv.extend_from_slice(&Sha256::digest(format!("{tag}-{}", last.number).as_bytes()));
|
||||
}
|
||||
pv.extend_from_slice(&21_000u64.to_be_bytes());
|
||||
pv.extend_from_slice(&0u64.to_be_bytes());
|
||||
pv.extend_from_slice(&1u32.to_be_bytes());
|
||||
pv.extend_from_slice(&0u32.to_be_bytes());
|
||||
pv.extend_from_slice(&[0u8; 32]);
|
||||
pv.extend_from_slice(&[0x39u8; 32]);
|
||||
pv.extend_from_slice(&key_id);
|
||||
pv.extend_from_slice(&n.to_be_bytes());
|
||||
assert_eq!(pv.len(), 340);
|
||||
pv.extend_from_slice(&1u16.to_be_bytes());
|
||||
pv.extend_from_slice(&last.table_root);
|
||||
pv.extend_from_slice(&mmr.root());
|
||||
pv.extend_from_slice(&first.to_be_bytes());
|
||||
pv.extend_from_slice(&2702u64.to_be_bytes());
|
||||
pv.extend_from_slice(&lock.block_hash);
|
||||
pv.extend_from_slice(&lock.number.to_be_bytes());
|
||||
pv.extend_from_slice(&2808u64.to_be_bytes());
|
||||
pv.extend_from_slice(&4008u64.to_be_bytes());
|
||||
pv.extend_from_slice(&2808u64.to_be_bytes());
|
||||
pv.extend_from_slice(&4008u64.to_be_bytes());
|
||||
pv.extend_from_slice(&lock.daa.to_be_bytes());
|
||||
pv.extend_from_slice(&(if stale { 1u16 } else { 0 }).to_be_bytes());
|
||||
assert_eq!(pv.len(), PV_LEN_FIN);
|
||||
(pv, leaves, mmr)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let (cmd, a) = args();
|
||||
let get = |k: &str| a.get(k).cloned();
|
||||
let need = |k: &str| a.get(k).cloned().unwrap_or_else(|| panic!("--{k} is required"));
|
||||
match cmd.as_str() {
|
||||
"make-stub" | "make-timing" => {
|
||||
let pv = read(&need("pv"));
|
||||
let key = hex32(&need("key-id"));
|
||||
let obj = if cmd == "make-stub" { FinalityObject::stub(key, pv) } else { timing_object(key, pv) };
|
||||
let b = obj.encode();
|
||||
std::fs::write(need("out"), &b).unwrap();
|
||||
println!("{} bytes, kind {}, sha256 {}", b.len(), obj.kind, hex(&Sha256::digest(&b)));
|
||||
}
|
||||
"verify" => {
|
||||
let object = read(&need("object"));
|
||||
let q = get("query").map(|p| Query::decode(&read(&p)).expect("query").0);
|
||||
let pinned = Pinned { key_id: hex32(&need("key-id")), chain_id: need("chain-id").parse().unwrap() };
|
||||
let clock = get("genesis").map(|g| Clock { now_unix_s: now_s(), genesis_unix_s: g.parse().unwrap(), seconds_per_daa: get("seconds-per-daa").map(|s| s.parse().unwrap()).unwrap_or(1.0) });
|
||||
let t = Instant::now();
|
||||
let v = igneum_light::verify(&object, q.as_ref(), &pinned, clock);
|
||||
let ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
println!("{}", verdict_json(&v));
|
||||
eprintln!("{ms:.3} ms");
|
||||
}
|
||||
"time" => {
|
||||
let object = read(&need("object"));
|
||||
let pinned = Pinned { key_id: hex32(&need("key-id")), chain_id: need("chain-id").parse().unwrap() };
|
||||
let rounds: usize = get("rounds").map(|s| s.parse().unwrap()).unwrap_or(20);
|
||||
let obj = FinalityObject::decode(&object).unwrap();
|
||||
let t = Instant::now();
|
||||
let verifier = Verifier::new();
|
||||
let prepare_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
let (mut full, mut wrap, mut reference) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for _ in 0..rounds {
|
||||
let t = Instant::now();
|
||||
let v = verifier.verify(&object, None, &pinned, None);
|
||||
full.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
assert!(matches!(v.proof, ProofVerdict::Refused(_) | ProofVerdict::Verified | ProofVerdict::Stub));
|
||||
let t = Instant::now();
|
||||
let _ = verifier.verify_wrap(&obj);
|
||||
wrap.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
let t = Instant::now();
|
||||
let _ = igneum_light::verify_sp1_groth16_reference_one_pairing(&obj);
|
||||
reference.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
let stats = |v: &mut Vec<f64>| {
|
||||
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
format!("min {:.2} median {:.2} max {:.2} ms", v[0], v[v.len() / 2], v[v.len() - 1])
|
||||
};
|
||||
println!("rounds {rounds}; prepare the key once {prepare_ms:.2} ms; verify (parse, pin, one pairing in arkworks) {}; the pairing alone {}; the reference pairing in substrate-bn {}", stats(&mut full), stats(&mut wrap), stats(&mut reference));
|
||||
}
|
||||
"url" => {
|
||||
let object = read(&need("object"));
|
||||
let base = get("base").unwrap_or_else(|| String::from("https://igneum.network/pocket/"));
|
||||
let mut u = format!("{base}#o={}", base64url_encode(&object));
|
||||
if let Some(q) = get("query") {
|
||||
u.push_str(&format!("&q={}", base64url_encode(&read(&q))));
|
||||
}
|
||||
println!("{u}");
|
||||
eprintln!("{} characters", u.len());
|
||||
}
|
||||
"vectors" => {
|
||||
let pv = read(&need("pv"));
|
||||
let key = hex32(&need("key-id"));
|
||||
let dir = need("out-dir");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let st = Statement::parse(&pv).expect("the public values parse");
|
||||
let chain_id = st.chain_id;
|
||||
let pinned = Pinned { key_id: key, chain_id };
|
||||
let verifier = Verifier::new();
|
||||
let mut manifest = Vec::new();
|
||||
let mut put = |name: &str, bytes: &[u8], query: Option<&[u8]>, expect_proof: &str, expect_answer: Option<Answer>| {
|
||||
std::fs::write(format!("{dir}/{name}.bin"), bytes).unwrap();
|
||||
if let Some(q) = query {
|
||||
std::fs::write(format!("{dir}/{name}.query.bin"), q).unwrap();
|
||||
}
|
||||
let qd = query.map(|q| Query::decode(q).unwrap().0);
|
||||
let v = verifier.verify(bytes, qd.as_ref(), &pinned, None);
|
||||
let proof = match &v.proof { ProofVerdict::Verified => "verified", ProofVerdict::Stub => "stub", ProofVerdict::Refused(_) => "refused" };
|
||||
assert_eq!(proof, expect_proof, "{name}: {}", verdict_json(&v));
|
||||
assert_eq!(v.answer, expect_answer, "{name}: {}", verdict_json(&v));
|
||||
manifest.push(format!(
|
||||
"{{\"name\":\"{name}\",\"bytes\":{},\"sha256\":\"{}\",\"query\":{},\"expect\":{{\"proof\":\"{proof}\",\"answer\":{}}},\"verdict\":{}}}",
|
||||
bytes.len(),
|
||||
hex(&Sha256::digest(bytes)),
|
||||
query.map(|q| format!("{{\"bytes\":{},\"sha256\":\"{}\"}}", q.len(), hex(&Sha256::digest(q)))).unwrap_or_else(|| String::from("null")),
|
||||
expect_answer.map(|x| format!("\"{}\"", x.as_str())).unwrap_or_else(|| String::from("null")),
|
||||
verdict_json(&v)
|
||||
));
|
||||
};
|
||||
// 1. the fixture from the chain: the fin-proof lane's proof 81053 as a stub object (the wrap pending); its
|
||||
// own block 81053 lies above the lock at 81049, so the proof's own block is not final
|
||||
let stub = FinalityObject::stub(key, pv.clone()).encode();
|
||||
put("fixture-81053-stub", &stub, None, "stub", Some(Answer::NotFinal));
|
||||
// 2. the timing object: kind 1 with generator points; the pairing runs and refuses
|
||||
let timing = timing_object(key, pv.clone()).encode();
|
||||
put("fixture-81053-timing", &timing, None, "refused", None);
|
||||
// 3. one byte changed in the public values of the stub (the lock number's low byte): a stub parses it
|
||||
// like any other, which is why a stub never says "verified"; a kind-1 object refuses the same change
|
||||
let mut flipped = stub.clone();
|
||||
flipped[40 + 461] ^= 0x01;
|
||||
put("fixture-81053-stub-flipped-lock-number", &flipped, None, "stub", Some(Answer::NotFinal));
|
||||
let mut tflipped = timing.clone();
|
||||
tflipped[40 + 461] ^= 0x01;
|
||||
put("fixture-81053-timing-flipped-lock-number", &tflipped, None, "refused", None);
|
||||
// 4. one byte changed in the proof of the timing object: refused
|
||||
let mut tf = timing.clone();
|
||||
let last = tf.len() - 1;
|
||||
tf[last] ^= 0x01;
|
||||
put("fixture-81053-timing-flipped-proof", &tf, None, "refused", None);
|
||||
// 5. the key id changed: refused before anything is read
|
||||
let mut kf = stub.clone();
|
||||
kf[8] ^= 0x01;
|
||||
put("fixture-81053-stub-wrong-key", &kf, None, "refused", None);
|
||||
// 6. the header: version 2
|
||||
let mut vf = stub.clone();
|
||||
vf[4] = 2;
|
||||
put("fixture-81053-stub-version-2", &vf, None, "refused", None);
|
||||
// 7. a synthetic history of 300 blocks from 79046 with the lock at 79300: a block below the lock is
|
||||
// final, a block above is not, a block with a wrong leaf is not in this chain, a stale flag is stale
|
||||
let (spv, leaves, mmr) = synthetic(chain_id, key, 79046, 300, 79300, false);
|
||||
let sobj = FinalityObject::stub(key, spv.clone()).encode();
|
||||
let q_final = Query { leaf: leaves[100].clone(), proof: mmr.proof(100).unwrap() }.encode();
|
||||
put("synthetic-300-block-79146-final", &sobj, Some(&q_final), "stub", Some(Answer::Final));
|
||||
let q_last = Query { leaf: leaves[299].clone(), proof: mmr.proof(299).unwrap() }.encode();
|
||||
put("synthetic-300-block-79345-not-final", &sobj, Some(&q_last), "stub", Some(Answer::NotFinal));
|
||||
let mut bad_leaf = leaves[100].clone();
|
||||
bad_leaf.daa += 1;
|
||||
let q_bad = Query { leaf: bad_leaf, proof: mmr.proof(100).unwrap() }.encode();
|
||||
put("synthetic-300-block-79146-wrong-leaf-not-in-chain", &sobj, Some(&q_bad), "stub", Some(Answer::NotInChain));
|
||||
let (stale_pv, _, _) = synthetic(chain_id, key, 79046, 300, 79300, true);
|
||||
put("synthetic-300-stale", &FinalityObject::stub(key, stale_pv).encode(), None, "stub", Some(Answer::Stale));
|
||||
let (other, _, _) = synthetic(chain_id + 1, key, 79046, 300, 79300, false);
|
||||
put("synthetic-300-other-chain", &FinalityObject::stub(key, other).encode(), None, "refused", None);
|
||||
// 8. the timing object over the synthetic statement, with the final query: proof refused, no answer
|
||||
put("synthetic-300-timing", &timing_object(key, spv).encode(), Some(&q_final), "refused", None);
|
||||
std::fs::write(format!("{dir}/manifest.json"), format!("{{\"format\":\"igneum-finality-object-vectors-1\",\"object_version\":1,\"sp1_version\":\"{}\",\"key_id\":\"{}\",\"chain_id\":{chain_id},\"vectors\":[\n{}\n]}}\n", igneum_light::SP1_VERSION, hex(&key), manifest.join(",\n"))).unwrap();
|
||||
println!("{} vectors in {dir}", manifest.len());
|
||||
}
|
||||
_ => {
|
||||
eprintln!("igneum-light make-stub|make-timing|verify|time|url|vectors (see the file head)");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
light/igneum-light-wasm/Cargo.toml
Normal file
12
light/igneum-light-wasm/Cargo.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[package]
|
||||
name = "igneum-light-wasm"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "igneum-light for the browser tab: a C ABI over WebAssembly, no bindings generator"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
igneum-light = { path = "../igneum-light" }
|
||||
98
light/igneum-light-wasm/src/lib.rs
Normal file
98
light/igneum-light-wasm/src/lib.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
//! igneum-light for the tab: a C ABI the page calls through `WebAssembly.instantiate`, no bindings generator, no
|
||||
//! JavaScript glue beyond `site/pocket/light.js`. Memory travels through `pf_alloc`/`pf_free`; the verdict comes
|
||||
//! back as JSON (`igneum_light::json::verdict_json`) in a caller-supplied buffer.
|
||||
//!
|
||||
//! pf_abi_version() -> 1
|
||||
//! pf_warm() prepares the verifying key once (one pairing); idempotent
|
||||
//! pf_alloc(len) -> ptr, pf_free(ptr, len)
|
||||
//! pf_verify(obj, obj_len, query, query_len, key_id (32 bytes), chain_id (u64), has_clock (0/1), now_unix_s,
|
||||
//! genesis_unix_s, seconds_per_daa, out, out_cap) -> bytes written, or -(bytes needed) when out_cap is short
|
||||
//!
|
||||
//! `query_len` 0 means "the proof's own block". A query that fails to decode refuses the call (written as a refused
|
||||
//! verdict), never a silent fall-through to the proof's own block.
|
||||
|
||||
use igneum_light::{json::verdict_json, Clock, Pinned, ProofVerdict, Query, Verdict, Verifier, TRUST_ROW};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static VERIFIER: OnceLock<Verifier> = OnceLock::new();
|
||||
|
||||
fn verifier() -> &'static Verifier {
|
||||
VERIFIER.get_or_init(Verifier::new)
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn pf_abi_version() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
/// Prepares the Groth16 verifying key (one pairing) ahead of the first verify; the page calls it at load so the
|
||||
/// measured verify is the per-object cost alone. Idempotent.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn pf_warm() {
|
||||
let _ = verifier();
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn pf_alloc(len: usize) -> *mut u8 {
|
||||
let mut v = Vec::<u8>::with_capacity(len.max(1));
|
||||
let p = v.as_mut_ptr();
|
||||
std::mem::forget(v);
|
||||
p
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// `ptr` came from `pf_alloc(len)` and is freed once.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn pf_free(ptr: *mut u8, len: usize) {
|
||||
if !ptr.is_null() {
|
||||
drop(Vec::from_raw_parts(ptr, 0, len.max(1)));
|
||||
}
|
||||
}
|
||||
|
||||
fn write_out(json: &str, out: *mut u8, out_cap: usize) -> i32 {
|
||||
let b = json.as_bytes();
|
||||
if b.len() > out_cap {
|
||||
return -(b.len() as i32);
|
||||
}
|
||||
// SAFETY: the caller allocated `out_cap` bytes at `out` through pf_alloc
|
||||
unsafe { core::ptr::copy_nonoverlapping(b.as_ptr(), out, b.len()) };
|
||||
b.len() as i32
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Every pointer names `len` readable bytes allocated through `pf_alloc`; `key_id` names 32; `out` names `out_cap`.
|
||||
#[no_mangle]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub unsafe extern "C" fn pf_verify(
|
||||
obj: *const u8,
|
||||
obj_len: usize,
|
||||
query: *const u8,
|
||||
query_len: usize,
|
||||
key_id: *const u8,
|
||||
chain_id: u64,
|
||||
has_clock: u32,
|
||||
now_unix_s: f64,
|
||||
genesis_unix_s: f64,
|
||||
seconds_per_daa: f64,
|
||||
out: *mut u8,
|
||||
out_cap: usize,
|
||||
) -> i32 {
|
||||
let object = core::slice::from_raw_parts(obj, obj_len);
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(core::slice::from_raw_parts(key_id, 32));
|
||||
let pinned = Pinned { key_id: key, chain_id };
|
||||
let q = if query_len == 0 {
|
||||
None
|
||||
} else {
|
||||
match Query::decode(core::slice::from_raw_parts(query, query_len)) {
|
||||
Ok((q, _)) => Some(q),
|
||||
Err(why) => {
|
||||
let v = Verdict { proof: ProofVerdict::Refused(format!("block query: {why}")), answer: None, block_number: None, statement: None, age_s: None, trust_row: TRUST_ROW };
|
||||
return write_out(&verdict_json(&v), out, out_cap);
|
||||
}
|
||||
}
|
||||
};
|
||||
let clock = (has_clock != 0).then_some(Clock { now_unix_s: now_unix_s as i64, genesis_unix_s: genesis_unix_s as i64, seconds_per_daa });
|
||||
let v = verifier().verify(object, q.as_ref(), &pinned, clock);
|
||||
write_out(&verdict_json(&v), out, out_cap)
|
||||
}
|
||||
20
light/igneum-light/Cargo.toml
Normal file
20
light/igneum-light/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[package]
|
||||
name = "igneum-light"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Igneum light client core: the finality object, its SP1 Groth16 verification and the final-at answer"
|
||||
|
||||
[dependencies]
|
||||
# `ark` gives the gnark-to-arkworks converters; the pairing itself runs in ark-groth16 (the measured path: one
|
||||
# multi-Miller loop in arkworks against sp1-verifier's own substrate-bn, kept as the reference check)
|
||||
sp1-verifier = { workspace = true, features = ["ark"] }
|
||||
sha2 = { workspace = true }
|
||||
ark-groth16 = { version = "0.5.0", default-features = false }
|
||||
ark-bn254 = { version = "0.5.0", default-features = false, features = ["curve"] }
|
||||
ark-ec = { version = "0.5.0", default-features = false }
|
||||
ark-ff = { version = "0.5.0", default-features = false }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
std = []
|
||||
87
light/igneum-light/src/json.rs
Normal file
87
light/igneum-light/src/json.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
//! The verdict as JSON for the tab and the CLI, written by hand so the wasm carries no serializer.
|
||||
|
||||
use crate::{ProofVerdict, Verdict};
|
||||
use alloc::string::String;
|
||||
|
||||
fn push_str(out: &mut String, s: &str) {
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
c if (c as u32) < 0x20 => out.push_str(&alloc::format!("\\u{:04x}", c as u32)),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
}
|
||||
|
||||
fn push_hex(out: &mut String, b: &[u8]) {
|
||||
out.push('"');
|
||||
for x in b {
|
||||
out.push_str(&alloc::format!("{x:02x}"));
|
||||
}
|
||||
out.push('"');
|
||||
}
|
||||
|
||||
pub fn verdict_json(v: &Verdict) -> String {
|
||||
let mut o = String::with_capacity(1024);
|
||||
o.push_str("{\"proof\":");
|
||||
match &v.proof {
|
||||
ProofVerdict::Verified => o.push_str("\"verified\",\"reason\":null"),
|
||||
ProofVerdict::Stub => o.push_str("\"stub\",\"reason\":null"),
|
||||
ProofVerdict::Refused(why) => {
|
||||
o.push_str("\"refused\",\"reason\":");
|
||||
push_str(&mut o, why);
|
||||
}
|
||||
}
|
||||
o.push_str(",\"answer\":");
|
||||
match v.answer {
|
||||
Some(a) => push_str(&mut o, a.as_str()),
|
||||
None => o.push_str("null"),
|
||||
}
|
||||
o.push_str(",\"block_number\":");
|
||||
match v.block_number {
|
||||
Some(n) => o.push_str(&alloc::format!("{n}")),
|
||||
None => o.push_str("null"),
|
||||
}
|
||||
o.push_str(",\"age_s\":");
|
||||
match v.age_s {
|
||||
Some(n) => o.push_str(&alloc::format!("{n}")),
|
||||
None => o.push_str("null"),
|
||||
}
|
||||
o.push_str(",\"trust_row\":");
|
||||
push_str(&mut o, v.trust_row);
|
||||
o.push_str(",\"statement\":");
|
||||
match &v.statement {
|
||||
None => o.push_str("null"),
|
||||
Some(s) => {
|
||||
o.push_str(&alloc::format!("{{\"chain_id\":{},\"number\":{},\"chain_len\":{},\"block_hash\":", s.chain_id, s.number, s.chain_len));
|
||||
push_hex(&mut o, &s.block_hash);
|
||||
o.push_str(",\"post_root\":");
|
||||
push_hex(&mut o, &s.post_root);
|
||||
o.push_str(",\"agg_vk\":");
|
||||
push_hex(&mut o, &s.agg_vk);
|
||||
o.push_str(",\"fin\":");
|
||||
match &s.fin {
|
||||
None => o.push_str("null"),
|
||||
Some(f) => {
|
||||
o.push_str(&alloc::format!("{{\"history_first\":{},\"stale\":{},\"history_root\":", f.history_first, f.stale()));
|
||||
push_hex(&mut o, &f.history_root);
|
||||
o.push_str(",\"table_root\":");
|
||||
push_hex(&mut o, &f.table_root);
|
||||
o.push_str(&alloc::format!(
|
||||
",\"lock\":{{\"index\":{},\"number\":{},\"signed\":{},\"total\":{},\"frozen_signed\":{},\"frozen_total\":{},\"daa\":{},\"hash\":",
|
||||
f.lock.index, f.lock.number, f.lock.signed, f.lock.total, f.lock.frozen_signed, f.lock.frozen_total, f.lock.daa
|
||||
));
|
||||
push_hex(&mut o, &f.lock.hash);
|
||||
o.push_str("}}");
|
||||
}
|
||||
}
|
||||
o.push('}');
|
||||
}
|
||||
}
|
||||
o.push('}');
|
||||
o
|
||||
}
|
||||
250
light/igneum-light/src/lib.rs
Normal file
250
light/igneum-light/src/lib.rs
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
//! Igneum light client, the core (docs/design/finality-object.md; docs/design/finality-in-proof.md section 4).
|
||||
//!
|
||||
//! One function answers the pocket question: `verify(object, query, pinned, now)` takes the finality object's bytes,
|
||||
//! an optional block query (a history leaf and its MMR path), the pinned aggregator id and the reader's clock, and
|
||||
//! returns a `Verdict`: whether the proof verified (the SP1 Groth16 wrap over bn254 under the pinned key), what the
|
||||
//! statement says (chain id, block, lock, weight), and the answer for the block asked about: final, not final, stale,
|
||||
//! not in this chain, or no claim. It asks nothing of any node and holds no state. `no_std` with `alloc`, so the same
|
||||
//! code runs natively, in WebAssembly in a tab (`igneum-light-wasm`) and behind a foreign-function layer on a phone.
|
||||
//!
|
||||
//! Byte offsets inside the public values are those of `BlockOutput::to_bytes` (proving/igneum-prove/core/src/agg.rs)
|
||||
//! and `FinExt::to_bytes` (proving/igneum-prove/fin/src/lib.rs), big-endian, confirmed by the fin-proof lane on
|
||||
//! 7 October 2026; the MMR and leaf hashing follow `igneum_fin_core::mmr` as `site/verify/finproof.js` does.
|
||||
|
||||
#![cfg_attr(not(any(feature = "std", test)), no_std)]
|
||||
extern crate alloc;
|
||||
|
||||
use alloc::string::String;
|
||||
use alloc::vec::Vec;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub mod json;
|
||||
pub mod mmr;
|
||||
pub mod object;
|
||||
pub mod statement;
|
||||
|
||||
pub use mmr::{HistoryLeaf, MmrProof, Query};
|
||||
pub use object::{FinalityObject, ObjectError, KIND_SP1_GROTH16, KIND_STUB};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub use statement::{FinExt, Lock, Statement};
|
||||
|
||||
/// The SP1 version whose Groth16 verifying key and recursion root are compiled in (sp1-verifier 6.8.1).
|
||||
pub const SP1_VERSION: &str = "6.8.1";
|
||||
|
||||
/// What the reader pins before it trusts an object: the aggregator program id (the SP1 verifying-key hash of the
|
||||
/// pinned aggregator guest, `vk.bytes32()`) and the chain id. An object under another key or for another chain is
|
||||
/// refused before anything else is read from it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Pinned {
|
||||
pub key_id: [u8; 32],
|
||||
pub chain_id: u64,
|
||||
}
|
||||
|
||||
/// The proof half of the verdict.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ProofVerdict {
|
||||
/// The Groth16 wrap verified under the pinned key: the statement is proven.
|
||||
Verified,
|
||||
/// The object carries the stub kind: the wrap has not landed, the statement is unverified. Never "final".
|
||||
Stub,
|
||||
/// The object was refused; the reason names the first check that failed.
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
/// The block half of the verdict.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Answer {
|
||||
/// The block is on the chain at or below the latest lock the proof carries.
|
||||
Final,
|
||||
/// The block is on the chain but above the lock, or the proof carries no lock yet.
|
||||
NotFinal,
|
||||
/// The proof chain paused for more than a weight window; the object needs a fresh root (design 4.4).
|
||||
Stale,
|
||||
/// The query's leaf and path do not reach the proof's history root.
|
||||
NotInChain,
|
||||
/// The public values carry no finality extension (a proof made before the activation).
|
||||
NoClaim,
|
||||
}
|
||||
|
||||
impl Answer {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Answer::Final => "final",
|
||||
Answer::NotFinal => "not final",
|
||||
Answer::Stale => "stale",
|
||||
Answer::NotInChain => "not in this chain",
|
||||
Answer::NoClaim => "no claim",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole verdict. `answer` is `None` when the object was refused outright (nothing in it is believed).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Verdict {
|
||||
pub proof: ProofVerdict,
|
||||
pub answer: Option<Answer>,
|
||||
/// The block the answer is about: the query's leaf, or the proof's own last block.
|
||||
pub block_number: Option<u64>,
|
||||
pub statement: Option<Statement>,
|
||||
/// The age of the lock by the reader's clock in seconds, when the reader gave a clock and a genesis time.
|
||||
pub age_s: Option<i64>,
|
||||
pub trust_row: &'static str,
|
||||
}
|
||||
|
||||
/// The trust row every surface shows beside a proof-carried lock (design section 4.4, level 0 of 5.2).
|
||||
pub const TRUST_ROW: &str = "voter set: verified in the proof; blue set: from the prover, vetoed by full nodes (level 0)";
|
||||
/// The trust row for a stub object.
|
||||
pub const TRUST_ROW_STUB: &str = "wrap pending: the public values are read, nothing is proven";
|
||||
|
||||
/// The reader's clock, for the age line: the current unix time and the chain's genesis time, both in seconds. The
|
||||
/// DAA score counts seconds of expected block time since genesis at one block a second, so the lock's age by the
|
||||
/// reader's clock is `now - (genesis + lock_daa)`; the devnet at a different block rate scales it (`seconds_per_daa`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Clock {
|
||||
pub now_unix_s: i64,
|
||||
pub genesis_unix_s: i64,
|
||||
pub seconds_per_daa: f64,
|
||||
}
|
||||
|
||||
/// The verifier: the Groth16 verifying key of the pinned SP1 version, prepared once (the preparation is itself one
|
||||
/// pairing, `alpha_g1 x beta_g2`, so a reader keeps one `Verifier` for its lifetime and the per-object cost is one
|
||||
/// multi-Miller loop and one final exponentiation).
|
||||
pub struct Verifier {
|
||||
pvk: ark_groth16::PreparedVerifyingKey<ark_bn254::Bn254>,
|
||||
}
|
||||
|
||||
impl Default for Verifier {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Verifier {
|
||||
pub fn new() -> Self {
|
||||
let vk = sp1_verifier::load_ark_groth16_verifying_key_from_bytes(*sp1_verifier::GROTH16_VK_BYTES).expect("the compiled-in Groth16 verifying key parses");
|
||||
Self { pvk: ark_groth16::prepare_verifying_key(&vk) }
|
||||
}
|
||||
|
||||
/// The pocket question. Order of checks: the container (magic, version, kind, lengths), the pinned key, the
|
||||
/// chain id, the proof (kind 1 only), then the query against the history root, then the lock. The first failure
|
||||
/// refuses the object and nothing later is read.
|
||||
pub fn verify(&self, object: &[u8], query: Option<&Query>, pinned: &Pinned, clock: Option<Clock>) -> Verdict {
|
||||
let refused = |why: String| Verdict { proof: ProofVerdict::Refused(why), answer: None, block_number: None, statement: None, age_s: None, trust_row: TRUST_ROW };
|
||||
let obj = match FinalityObject::decode(object) {
|
||||
Ok(o) => o,
|
||||
Err(e) => return refused(e.to_string()),
|
||||
};
|
||||
if obj.key_id != pinned.key_id {
|
||||
return refused(String::from("key id is not the pinned aggregator id"));
|
||||
}
|
||||
let st = match Statement::parse(&obj.public_values) {
|
||||
Some(s) => s,
|
||||
None => return refused(String::from("public values do not parse as a block statement")),
|
||||
};
|
||||
if st.chain_id != pinned.chain_id {
|
||||
return refused(alloc::format!("chain id {} is not the pinned chain {}", st.chain_id, pinned.chain_id));
|
||||
}
|
||||
if st.agg_vk != [0u8; 32] && st.agg_vk != pinned.key_id {
|
||||
// a continuing proof names the aggregator key it verified; it must be the same pinned program
|
||||
return refused(String::from("the statement's agg_vk is not the pinned aggregator id"));
|
||||
}
|
||||
let proof = match obj.kind {
|
||||
KIND_STUB => ProofVerdict::Stub,
|
||||
KIND_SP1_GROTH16 => match self.verify_wrap(&obj) {
|
||||
Ok(()) => ProofVerdict::Verified,
|
||||
Err(why) => return refused(why),
|
||||
},
|
||||
k => return refused(alloc::format!("unknown proof kind {k}")),
|
||||
};
|
||||
let trust_row = if proof == ProofVerdict::Stub { TRUST_ROW_STUB } else { TRUST_ROW };
|
||||
let Some(fin) = &st.fin else {
|
||||
return Verdict { proof, answer: Some(Answer::NoClaim), block_number: Some(st.number), statement: Some(st), age_s: None, trust_row };
|
||||
};
|
||||
let mut number = st.number;
|
||||
if let Some(q) = query {
|
||||
let leaves = st.number - fin.history_first + 1;
|
||||
if !mmr::verify_history(&q.leaf, &q.proof, &fin.history_root, leaves) {
|
||||
return Verdict { proof, answer: Some(Answer::NotInChain), block_number: Some(q.leaf.number), statement: Some(st), age_s: None, trust_row };
|
||||
}
|
||||
number = q.leaf.number;
|
||||
}
|
||||
let age_s = clock.map(|c| c.now_unix_s - c.genesis_unix_s - ((fin.lock.daa as f64) * c.seconds_per_daa) as i64);
|
||||
let answer = if fin.stale() {
|
||||
Answer::Stale
|
||||
} else if fin.lock.index > 0 && number <= fin.lock.number {
|
||||
Answer::Final
|
||||
} else {
|
||||
Answer::NotFinal
|
||||
};
|
||||
Verdict { proof, answer: Some(answer), block_number: Some(number), statement: Some(st), age_s, trust_row }
|
||||
}
|
||||
|
||||
/// The wrap check as `sp1_verifier::Groth16Verifier::verify_with_exit_code` does it at 6.8.1 (exit code zero,
|
||||
/// the recursion root of the version, the public values hashed with SHA-256 and masked to 253 bits), with the
|
||||
/// pairing in arkworks: public inputs `[key_id, sha256(public_values) & mask, 0, vk_root, nonce]`.
|
||||
pub fn verify_wrap(&self, obj: &FinalityObject) -> Result<(), String> {
|
||||
let proof = sp1_verifier::load_ark_proof_from_bytes(&obj.proof).map_err(|e| alloc::format!("the wrap's curve points do not load: {e:?}"))?;
|
||||
let inputs = sp1_verifier::load_ark_public_inputs_from_bytes(&obj.key_id, &sp1_verifier::hash_public_inputs(&obj.public_values), &[0u8; 32], &sp1_verifier::VK_ROOT_BYTES, &obj.nonce);
|
||||
match ark_groth16::Groth16::<ark_bn254::Bn254>::verify_proof(&self.pvk, &proof, &inputs) {
|
||||
Ok(true) => Ok(()),
|
||||
Ok(false) => Err(String::from("the Groth16 wrap does not verify under the pinned key")),
|
||||
Err(e) => Err(alloc::format!("the Groth16 wrap does not verify under the pinned key: {e:?}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pocket question with a fresh `Verifier` (one extra pairing to prepare the key; a long-lived reader keeps one).
|
||||
pub fn verify(object: &[u8], query: Option<&Query>, pinned: &Pinned, clock: Option<Clock>) -> Verdict {
|
||||
Verifier::new().verify(object, query, pinned, clock)
|
||||
}
|
||||
|
||||
/// The SP1 Groth16 proof as the SDK emits it at 6.8.1, rebuilt from the object's 288 carried bytes: the 4-byte
|
||||
/// prefix (sha256 of the Groth16 verifying key), the exit code (zero: the guest never exits otherwise), the recursion
|
||||
/// verifying-key root (a constant of the SP1 version), the proof nonce and the 256-byte gnark proof. The prefix and
|
||||
/// the root are not carried because the verifier pins them; carrying them would let an object name a key the
|
||||
/// verifier does not hold, which is a refusal either way.
|
||||
pub fn sp1_proof_bytes(obj: &FinalityObject) -> Vec<u8> {
|
||||
let vk_hash = Sha256::digest(*sp1_verifier::GROTH16_VK_BYTES);
|
||||
let mut v = Vec::with_capacity(4 + 96 + 256);
|
||||
v.extend_from_slice(&vk_hash[..4]);
|
||||
v.extend_from_slice(&[0u8; 32]);
|
||||
v.extend_from_slice(&*sp1_verifier::VK_ROOT_BYTES);
|
||||
v.extend_from_slice(&obj.nonce);
|
||||
v.extend_from_slice(&obj.proof);
|
||||
v
|
||||
}
|
||||
|
||||
fn hex32(b: &[u8; 32]) -> String {
|
||||
let mut s = String::with_capacity(66);
|
||||
s.push_str("0x");
|
||||
for x in b {
|
||||
s.push_str(&alloc::format!("{x:02x}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The reference check: sp1-verifier's own `Groth16Verifier::verify` (substrate-bn). It hashes the public values
|
||||
/// with SHA-256 and, on failure, once more with BLAKE3, so a refusal costs two pairings and an acceptance one. The
|
||||
/// tests hold the arkworks path to this one.
|
||||
pub fn verify_sp1_groth16_reference(obj: &FinalityObject) -> Result<(), String> {
|
||||
let full = sp1_proof_bytes(obj);
|
||||
sp1_verifier::Groth16Verifier::verify(&full, &obj.public_values, &hex32(&obj.key_id), *sp1_verifier::GROTH16_VK_BYTES)
|
||||
.map_err(|e| alloc::format!("the Groth16 wrap does not verify under the pinned key: {e:?}"))
|
||||
}
|
||||
|
||||
/// The reference check's accept-path cost alone (one pairing in substrate-bn), for timing.
|
||||
pub fn verify_sp1_groth16_reference_one_pairing(obj: &FinalityObject) -> Result<(), String> {
|
||||
let pv_hash = sp1_verifier::hash_public_inputs(&obj.public_values);
|
||||
sp1_verifier::Groth16Verifier::verify_gnark_proof(&obj.proof, &[obj.key_id, pv_hash, [0u8; 32], *sp1_verifier::VK_ROOT_BYTES, obj.nonce], *sp1_verifier::GROTH16_VK_BYTES)
|
||||
.map_err(|e| alloc::format!("{e:?}"))
|
||||
}
|
||||
|
||||
pub fn sha256(parts: &[&[u8]]) -> [u8; 32] {
|
||||
let mut h = Sha256::new();
|
||||
for p in parts {
|
||||
h.update(p);
|
||||
}
|
||||
h.finalize().into()
|
||||
}
|
||||
293
light/igneum-light/src/mmr.rs
Normal file
293
light/igneum-light/src/mmr.rs
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
//! The history check (`igneum_fin_core::mmr`, mirrored by `site/verify/finproof.js`): a chain block's leaf and its
|
||||
//! Merkle mountain range path against the `history_root` the proof carries. Leaf `sha256(0x04 ‖ number_le ‖
|
||||
//! block_hash ‖ daa_le ‖ table_root ‖ keys_hash ‖ total_le)`, node `sha256(0x02 ‖ left ‖ right)`, peaks bagged
|
||||
//! from the right with `sha256(0x03 ‖ peak ‖ acc)`.
|
||||
//!
|
||||
//! The query's byte form (section 4 of the object spec), appended to an object in a QR bundle or carried as `q=`
|
||||
//! in the URL:
|
||||
//!
|
||||
//! | offset | bytes | field |
|
||||
//! |---|---|---|
|
||||
//! | 0 | 4 | magic `IGFQ` |
|
||||
//! | 4 | 1 | version, 1 |
|
||||
//! | 5 | 8 | leaf number |
|
||||
//! | 13 | 32 | leaf block hash |
|
||||
//! | 45 | 8 | leaf daa |
|
||||
//! | 53 | 32 | leaf table root |
|
||||
//! | 85 | 32 | leaf keys hash |
|
||||
//! | 117 | 8 | leaf total |
|
||||
//! | 125 | 8 | position (leaf index in the history, zero based) |
|
||||
//! | 133 | 1 | peak index |
|
||||
//! | 134 | 1 | peak count p |
|
||||
//! | 135 | 33 p | peaks, each height (1) then hash (32), heights strictly decreasing |
|
||||
//! | 135 + 33 p | 1 | sibling count s (equals the chosen peak's height) |
|
||||
//! | 136 + 33 p | 33 s | siblings, each side (1: 1 = the sibling is on the left) then hash (32) |
|
||||
|
||||
use alloc::string::String;
|
||||
use alloc::vec::Vec;
|
||||
|
||||
pub const QUERY_MAGIC: [u8; 4] = *b"IGFQ";
|
||||
pub const QUERY_VERSION: u8 = 1;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct HistoryLeaf {
|
||||
pub number: u64,
|
||||
pub block_hash: [u8; 32],
|
||||
pub daa: u64,
|
||||
pub table_root: [u8; 32],
|
||||
pub keys_hash: [u8; 32],
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
impl HistoryLeaf {
|
||||
pub fn hash(&self) -> [u8; 32] {
|
||||
crate::sha256(&[&[4u8], &self.number.to_le_bytes(), &self.block_hash, &self.daa.to_le_bytes(), &self.table_root, &self.keys_hash, &self.total.to_le_bytes()])
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MmrProof {
|
||||
pub position: u64,
|
||||
pub peak_index: usize,
|
||||
/// (height, hash), left to right, heights strictly decreasing
|
||||
pub peaks: Vec<(u8, [u8; 32])>,
|
||||
/// (sibling is on the left, hash), leaf upwards
|
||||
pub siblings: Vec<(bool, [u8; 32])>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Query {
|
||||
pub leaf: HistoryLeaf,
|
||||
pub proof: MmrProof,
|
||||
}
|
||||
|
||||
/// `MmrProof::verify`: the leaf sits at `proof.position` in a history of `leaves` leaves whose peaks bag to `root`.
|
||||
pub fn verify_history(leaf: &HistoryLeaf, proof: &MmrProof, root: &[u8; 32], leaves: u64) -> bool {
|
||||
let peaks = &proof.peaks;
|
||||
if proof.position >= leaves || proof.peak_index >= peaks.len() {
|
||||
return false;
|
||||
}
|
||||
let mut count: u128 = 0;
|
||||
let mut last: Option<u8> = None;
|
||||
for (h, _) in peaks {
|
||||
if let Some(l) = last {
|
||||
if l <= *h {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if *h >= 64 {
|
||||
return false;
|
||||
}
|
||||
last = Some(*h);
|
||||
count += 1u128 << *h;
|
||||
}
|
||||
if count != leaves as u128 {
|
||||
return false;
|
||||
}
|
||||
let before: u128 = peaks[..proof.peak_index].iter().map(|(h, _)| 1u128 << *h).sum();
|
||||
let height = peaks[proof.peak_index].0;
|
||||
if (proof.position as u128) < before {
|
||||
return false;
|
||||
}
|
||||
let mut idx = proof.position as u128 - before;
|
||||
if idx >= (1u128 << height) || proof.siblings.len() != height as usize {
|
||||
return false;
|
||||
}
|
||||
let mut node = leaf.hash();
|
||||
for (left, sib) in &proof.siblings {
|
||||
if *left != (idx & 1 == 1) {
|
||||
return false;
|
||||
}
|
||||
node = if *left { crate::sha256(&[&[2u8], sib, &node]) } else { crate::sha256(&[&[2u8], &node, sib]) };
|
||||
idx >>= 1;
|
||||
}
|
||||
if node != peaks[proof.peak_index].1 {
|
||||
return false;
|
||||
}
|
||||
let mut acc = peaks[peaks.len() - 1].1;
|
||||
for i in (0..peaks.len() - 1).rev() {
|
||||
acc = crate::sha256(&[&[3u8], &peaks[i].1, &acc]);
|
||||
}
|
||||
acc == *root
|
||||
}
|
||||
|
||||
impl Query {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut v = Vec::with_capacity(136 + 33 * (self.proof.peaks.len() + self.proof.siblings.len()));
|
||||
v.extend_from_slice(&QUERY_MAGIC);
|
||||
v.push(QUERY_VERSION);
|
||||
v.extend_from_slice(&self.leaf.number.to_be_bytes());
|
||||
v.extend_from_slice(&self.leaf.block_hash);
|
||||
v.extend_from_slice(&self.leaf.daa.to_be_bytes());
|
||||
v.extend_from_slice(&self.leaf.table_root);
|
||||
v.extend_from_slice(&self.leaf.keys_hash);
|
||||
v.extend_from_slice(&self.leaf.total.to_be_bytes());
|
||||
v.extend_from_slice(&self.proof.position.to_be_bytes());
|
||||
v.push(self.proof.peak_index as u8);
|
||||
v.push(self.proof.peaks.len() as u8);
|
||||
for (h, p) in &self.proof.peaks {
|
||||
v.push(*h);
|
||||
v.extend_from_slice(p);
|
||||
}
|
||||
v.push(self.proof.siblings.len() as u8);
|
||||
for (left, s) in &self.proof.siblings {
|
||||
v.push(*left as u8);
|
||||
v.extend_from_slice(s);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Decodes a query from the front of `b`; returns it with the bytes it used.
|
||||
pub fn decode(b: &[u8]) -> Result<(Self, usize), String> {
|
||||
if b.len() < 135 {
|
||||
return Err(String::from("query is shorter than its fixed part"));
|
||||
}
|
||||
if b[0..4] != QUERY_MAGIC {
|
||||
return Err(String::from("not a block query (magic)"));
|
||||
}
|
||||
if b[4] != QUERY_VERSION {
|
||||
return Err(alloc::format!("query version {} is not 1", b[4]));
|
||||
}
|
||||
let u64_at = |i: usize| u64::from_be_bytes(b[i..i + 8].try_into().unwrap());
|
||||
let h_at = |i: usize| -> [u8; 32] { b[i..i + 32].try_into().unwrap() };
|
||||
let leaf = HistoryLeaf { number: u64_at(5), block_hash: h_at(13), daa: u64_at(45), table_root: h_at(53), keys_hash: h_at(85), total: u64_at(117) };
|
||||
let position = u64_at(125);
|
||||
let peak_index = b[133] as usize;
|
||||
let np = b[134] as usize;
|
||||
let mut o = 135;
|
||||
if b.len() < o + 33 * np + 1 {
|
||||
return Err(String::from("query is shorter than its peaks"));
|
||||
}
|
||||
let mut peaks = Vec::with_capacity(np);
|
||||
for _ in 0..np {
|
||||
peaks.push((b[o], h_at(o + 1)));
|
||||
o += 33;
|
||||
}
|
||||
let ns = b[o] as usize;
|
||||
o += 1;
|
||||
if b.len() < o + 33 * ns {
|
||||
return Err(String::from("query is shorter than its siblings"));
|
||||
}
|
||||
let mut siblings = Vec::with_capacity(ns);
|
||||
for _ in 0..ns {
|
||||
if b[o] > 1 {
|
||||
return Err(String::from("a sibling side byte is not 0 or 1"));
|
||||
}
|
||||
siblings.push((b[o] == 1, h_at(o + 1)));
|
||||
o += 33;
|
||||
}
|
||||
Ok((Query { leaf, proof: MmrProof { position, peak_index, peaks, siblings } }, o))
|
||||
}
|
||||
}
|
||||
|
||||
/// A small in-memory MMR, for tests and for the CLI's vectors: the same construction as `igneum_fin_core::mmr::Mmr`
|
||||
/// (append leaves; peaks are perfect binary trees by height; a proof is the path inside the leaf's peak).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Mmr {
|
||||
leaves: Vec<[u8; 32]>,
|
||||
}
|
||||
|
||||
impl Mmr {
|
||||
pub fn push(&mut self, leaf: &HistoryLeaf) {
|
||||
self.leaves.push(leaf.hash());
|
||||
}
|
||||
|
||||
pub fn leaves(&self) -> u64 {
|
||||
self.leaves.len() as u64
|
||||
}
|
||||
|
||||
/// The peaks are the set bits of the leaf count, highest first: one perfect tree per bit.
|
||||
fn peak_ranges(&self) -> Vec<(u8, usize, usize)> {
|
||||
let n = self.leaves.len();
|
||||
let mut out = Vec::new();
|
||||
let mut start = 0;
|
||||
for h in (0..63u8).rev() {
|
||||
if n & (1usize << h) != 0 {
|
||||
out.push((h, start, start + (1 << h)));
|
||||
start += 1 << h;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn subtree(&self, lo: usize, hi: usize) -> [u8; 32] {
|
||||
if hi - lo == 1 {
|
||||
return self.leaves[lo];
|
||||
}
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
crate::sha256(&[&[2u8], &self.subtree(lo, mid), &self.subtree(mid, hi)])
|
||||
}
|
||||
|
||||
pub fn peaks(&self) -> Vec<(u8, [u8; 32])> {
|
||||
self.peak_ranges().iter().map(|(h, lo, hi)| (*h, self.subtree(*lo, *hi))).collect()
|
||||
}
|
||||
|
||||
pub fn root(&self) -> [u8; 32] {
|
||||
let peaks = self.peaks();
|
||||
let mut acc = peaks[peaks.len() - 1].1;
|
||||
for i in (0..peaks.len() - 1).rev() {
|
||||
acc = crate::sha256(&[&[3u8], &peaks[i].1, &acc]);
|
||||
}
|
||||
acc
|
||||
}
|
||||
|
||||
pub fn proof(&self, position: u64) -> Option<MmrProof> {
|
||||
let ranges = self.peak_ranges();
|
||||
let pos = position as usize;
|
||||
let (peak_index, &(h, lo, hi)) = ranges.iter().enumerate().find(|(_, (_, lo, hi))| pos >= *lo && pos < *hi)?;
|
||||
let mut siblings = Vec::with_capacity(h as usize);
|
||||
let (mut l, mut r) = (lo, hi);
|
||||
let mut path = Vec::new();
|
||||
while r - l > 1 {
|
||||
let mid = l + (r - l) / 2;
|
||||
if pos < mid {
|
||||
path.push((false, mid, r));
|
||||
r = mid;
|
||||
} else {
|
||||
path.push((true, l, mid));
|
||||
l = mid;
|
||||
}
|
||||
}
|
||||
for (left, a, b) in path.into_iter().rev() {
|
||||
siblings.push((left, self.subtree(a, b)));
|
||||
}
|
||||
Some(MmrProof { position, peak_index, peaks: self.peaks(), siblings })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn leaf(n: u64) -> HistoryLeaf {
|
||||
HistoryLeaf { number: 1000 + n, block_hash: [n as u8; 32], daa: 5000 + n, table_root: [1; 32], keys_hash: [2; 32], total: 99 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_leaf_of_every_size_verifies_and_a_flip_fails() {
|
||||
for n in 1..40u64 {
|
||||
let mut m = Mmr::default();
|
||||
for i in 0..n {
|
||||
m.push(&leaf(i));
|
||||
}
|
||||
let root = m.root();
|
||||
for i in 0..n {
|
||||
let p = m.proof(i).unwrap();
|
||||
assert!(verify_history(&leaf(i), &p, &root, n), "n {n} i {i}");
|
||||
let mut bad = leaf(i);
|
||||
bad.daa += 1;
|
||||
assert!(!verify_history(&bad, &p, &root, n));
|
||||
assert!(!verify_history(&leaf(i), &p, &root, n + 1));
|
||||
let q = Query { leaf: leaf(i), proof: p.clone() };
|
||||
let (back, used) = Query::decode(&q.encode()).unwrap();
|
||||
assert_eq!(back, q);
|
||||
assert_eq!(used, q.encode().len());
|
||||
}
|
||||
let mut p = m.proof(0).unwrap();
|
||||
if let Some(s) = p.siblings.first_mut() {
|
||||
s.1[0] ^= 1;
|
||||
assert!(!verify_history(&leaf(0), &p, &root, n));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
225
light/igneum-light/src/object.rs
Normal file
225
light/igneum-light/src/object.rs
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
//! The finality object, version 1 (docs/design/finality-object.md section 2): a fixed header, the pinned key id,
|
||||
//! the aggregator's public values and the 288 carried bytes of the SP1 Groth16 wrap.
|
||||
//!
|
||||
//! | offset | bytes | field |
|
||||
//! |---|---|---|
|
||||
//! | 0 | 4 | magic `IGFO` |
|
||||
//! | 4 | 1 | version, 1 |
|
||||
//! | 5 | 1 | kind: 0 stub (no wrap), 1 SP1 6.8.1 Groth16 over bn254 |
|
||||
//! | 6 | 2 | public values length, big-endian: 340 (no finality claim) or 504 (with the extension) |
|
||||
//! | 8 | 32 | key id: the aggregator program's SP1 verifying-key hash |
|
||||
//! | 40 | n | public values, `BlockOutput::to_bytes` |
|
||||
//! | 40 + n | 32 | proof nonce (bound as a public input by the wrap circuit) |
|
||||
//! | 72 + n | 256 | gnark Groth16 proof: A (64), B (128), C (64), uncompressed big-endian |
|
||||
//!
|
||||
//! 832 bytes at n = 504, 668 at n = 340. Every byte is bound: the header by the parser, the key id and the public
|
||||
//! values as public inputs of the pairing, the nonce likewise, the proof by the pairing itself.
|
||||
|
||||
use alloc::string::String;
|
||||
use alloc::vec::Vec;
|
||||
|
||||
pub const MAGIC: [u8; 4] = *b"IGFO";
|
||||
pub const VERSION: u8 = 1;
|
||||
pub const KIND_STUB: u8 = 0;
|
||||
pub const KIND_SP1_GROTH16: u8 = 1;
|
||||
pub const HEADER_LEN: usize = 8;
|
||||
pub const KEY_ID_LEN: usize = 32;
|
||||
pub const NONCE_LEN: usize = 32;
|
||||
pub const PROOF_LEN: usize = 256;
|
||||
/// The two public-values lengths the aggregator emits (agg.rs `BlockOutput::LEN` and `LEN2`).
|
||||
pub const PV_LEN_PLAIN: usize = 340;
|
||||
pub const PV_LEN_FIN: usize = 504;
|
||||
/// The object's length with the finality extension: 832.
|
||||
pub const LEN_FIN: usize = HEADER_LEN + KEY_ID_LEN + PV_LEN_FIN + NONCE_LEN + PROOF_LEN;
|
||||
pub const LEN_PLAIN: usize = HEADER_LEN + KEY_ID_LEN + PV_LEN_PLAIN + NONCE_LEN + PROOF_LEN;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FinalityObject {
|
||||
pub kind: u8,
|
||||
pub key_id: [u8; 32],
|
||||
pub public_values: Vec<u8>,
|
||||
pub nonce: [u8; 32],
|
||||
pub proof: [u8; 256],
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ObjectError {
|
||||
TooShort(usize),
|
||||
BadMagic,
|
||||
BadVersion(u8),
|
||||
BadKind(u8),
|
||||
BadPublicValuesLength(usize),
|
||||
BadLength { got: usize, want: usize },
|
||||
}
|
||||
|
||||
impl ObjectError {
|
||||
pub fn to_string(&self) -> String {
|
||||
match self {
|
||||
ObjectError::TooShort(n) => alloc::format!("object is {n} bytes, shorter than the header"),
|
||||
ObjectError::BadMagic => String::from("not a finality object (magic)"),
|
||||
ObjectError::BadVersion(v) => alloc::format!("object version {v} is not 1"),
|
||||
ObjectError::BadKind(k) => alloc::format!("object kind {k} is not 0 (stub) or 1 (SP1 Groth16)"),
|
||||
ObjectError::BadPublicValuesLength(n) => alloc::format!("public values length {n} is not 340 or 504"),
|
||||
ObjectError::BadLength { got, want } => alloc::format!("object is {got} bytes, the header says {want}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FinalityObject {
|
||||
pub fn len(&self) -> usize {
|
||||
HEADER_LEN + KEY_ID_LEN + self.public_values.len() + NONCE_LEN + PROOF_LEN
|
||||
}
|
||||
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut v = Vec::with_capacity(self.len());
|
||||
v.extend_from_slice(&MAGIC);
|
||||
v.push(VERSION);
|
||||
v.push(self.kind);
|
||||
v.extend_from_slice(&(self.public_values.len() as u16).to_be_bytes());
|
||||
v.extend_from_slice(&self.key_id);
|
||||
v.extend_from_slice(&self.public_values);
|
||||
v.extend_from_slice(&self.nonce);
|
||||
v.extend_from_slice(&self.proof);
|
||||
v
|
||||
}
|
||||
|
||||
pub fn decode(b: &[u8]) -> Result<Self, ObjectError> {
|
||||
if b.len() < HEADER_LEN {
|
||||
return Err(ObjectError::TooShort(b.len()));
|
||||
}
|
||||
if b[0..4] != MAGIC {
|
||||
return Err(ObjectError::BadMagic);
|
||||
}
|
||||
if b[4] != VERSION {
|
||||
return Err(ObjectError::BadVersion(b[4]));
|
||||
}
|
||||
let kind = b[5];
|
||||
if kind != KIND_STUB && kind != KIND_SP1_GROTH16 {
|
||||
return Err(ObjectError::BadKind(kind));
|
||||
}
|
||||
let n = u16::from_be_bytes([b[6], b[7]]) as usize;
|
||||
if n != PV_LEN_PLAIN && n != PV_LEN_FIN {
|
||||
return Err(ObjectError::BadPublicValuesLength(n));
|
||||
}
|
||||
let want = HEADER_LEN + KEY_ID_LEN + n + NONCE_LEN + PROOF_LEN;
|
||||
if b.len() != want {
|
||||
return Err(ObjectError::BadLength { got: b.len(), want });
|
||||
}
|
||||
let mut key_id = [0u8; 32];
|
||||
key_id.copy_from_slice(&b[8..40]);
|
||||
let public_values = b[40..40 + n].to_vec();
|
||||
let mut nonce = [0u8; 32];
|
||||
nonce.copy_from_slice(&b[40 + n..72 + n]);
|
||||
let mut proof = [0u8; 256];
|
||||
proof.copy_from_slice(&b[72 + n..72 + n + 256]);
|
||||
Ok(Self { kind, key_id, public_values, nonce, proof })
|
||||
}
|
||||
|
||||
/// The stub object the node serves until the wrap lands: the public values the chain carried, the wrap bytes
|
||||
/// zero, the kind marked.
|
||||
pub fn stub(key_id: [u8; 32], public_values: Vec<u8>) -> Self {
|
||||
Self { kind: KIND_STUB, key_id, public_values, nonce: [0u8; 32], proof: [0u8; 256] }
|
||||
}
|
||||
}
|
||||
|
||||
/// The URL form: the object's bytes in base64url with no padding, in a fragment so no server sees them.
|
||||
pub fn base64url_encode(b: &[u8]) -> String {
|
||||
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
let mut s = String::with_capacity((b.len() + 2) / 3 * 4);
|
||||
for chunk in b.chunks(3) {
|
||||
let n = chunk.len();
|
||||
let v = (chunk[0] as u32) << 16 | (if n > 1 { chunk[1] as u32 } else { 0 }) << 8 | (if n > 2 { chunk[2] as u32 } else { 0 });
|
||||
s.push(T[(v >> 18) as usize & 63] as char);
|
||||
s.push(T[(v >> 12) as usize & 63] as char);
|
||||
if n > 1 {
|
||||
s.push(T[(v >> 6) as usize & 63] as char);
|
||||
}
|
||||
if n > 2 {
|
||||
s.push(T[v as usize & 63] as char);
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
pub fn base64url_decode(s: &str) -> Option<Vec<u8>> {
|
||||
let val = |c: u8| -> Option<u32> {
|
||||
Some(match c {
|
||||
b'A'..=b'Z' => (c - b'A') as u32,
|
||||
b'a'..=b'z' => (c - b'a') as u32 + 26,
|
||||
b'0'..=b'9' => (c - b'0') as u32 + 52,
|
||||
b'-' => 62,
|
||||
b'_' => 63,
|
||||
_ => return None,
|
||||
})
|
||||
};
|
||||
let bytes = s.trim_end_matches('=').as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
|
||||
for chunk in bytes.chunks(4) {
|
||||
let n = chunk.len();
|
||||
if n == 1 {
|
||||
return None;
|
||||
}
|
||||
let mut v = 0u32;
|
||||
for (i, c) in chunk.iter().enumerate() {
|
||||
v |= val(*c)? << (18 - 6 * i);
|
||||
}
|
||||
out.push((v >> 16) as u8);
|
||||
if n > 2 {
|
||||
out.push((v >> 8) as u8);
|
||||
}
|
||||
if n > 3 {
|
||||
out.push(v as u8);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trip_and_lengths() {
|
||||
let o = FinalityObject::stub([7u8; 32], alloc::vec![1u8; PV_LEN_FIN]);
|
||||
let b = o.encode();
|
||||
assert_eq!(b.len(), LEN_FIN);
|
||||
assert_eq!(LEN_FIN, 832);
|
||||
assert_eq!(LEN_PLAIN, 668);
|
||||
assert_eq!(FinalityObject::decode(&b).unwrap(), o);
|
||||
let p = FinalityObject::stub([7u8; 32], alloc::vec![1u8; PV_LEN_PLAIN]);
|
||||
assert_eq!(FinalityObject::decode(&p.encode()).unwrap(), p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_header_byte_is_checked() {
|
||||
let o = FinalityObject::stub([7u8; 32], alloc::vec![1u8; PV_LEN_FIN]).encode();
|
||||
let mut b = o.clone();
|
||||
b[0] = b'X';
|
||||
assert_eq!(FinalityObject::decode(&b), Err(ObjectError::BadMagic));
|
||||
let mut b = o.clone();
|
||||
b[4] = 2;
|
||||
assert_eq!(FinalityObject::decode(&b), Err(ObjectError::BadVersion(2)));
|
||||
let mut b = o.clone();
|
||||
b[5] = 9;
|
||||
assert_eq!(FinalityObject::decode(&b), Err(ObjectError::BadKind(9)));
|
||||
let mut b = o.clone();
|
||||
b[7] = 0xf7;
|
||||
assert_eq!(FinalityObject::decode(&b), Err(ObjectError::BadPublicValuesLength(0x01f7)));
|
||||
let mut b = o.clone();
|
||||
b.push(0);
|
||||
assert_eq!(FinalityObject::decode(&b), Err(ObjectError::BadLength { got: 833, want: 832 }));
|
||||
assert_eq!(FinalityObject::decode(&b[..5]), Err(ObjectError::TooShort(5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64url_round_trip() {
|
||||
for n in 0..70usize {
|
||||
let v: Vec<u8> = (0..n).map(|i| (i * 37 + 11) as u8).collect();
|
||||
let s = base64url_encode(&v);
|
||||
assert!(!s.contains('='));
|
||||
assert_eq!(base64url_decode(&s).unwrap(), v, "{n}");
|
||||
}
|
||||
assert_eq!(base64url_decode("A"), None);
|
||||
assert_eq!(base64url_decode("A!"), None);
|
||||
}
|
||||
}
|
||||
137
light/igneum-light/src/statement.rs
Normal file
137
light/igneum-light/src/statement.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
//! The aggregator's public values: `BlockOutput` (340 bytes) and the finality extension `FinExt` (164 bytes), the
|
||||
//! byte order of `BlockOutput::to_bytes` and `FinExt::to_bytes`, every integer big-endian.
|
||||
|
||||
use alloc::vec::Vec;
|
||||
|
||||
pub const BLOCK_STATEMENT_LEN: usize = 340;
|
||||
pub const FIN_EXT_LEN: usize = 164;
|
||||
pub const FLAG_STALE: u16 = 1;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Lock {
|
||||
pub index: u64,
|
||||
pub hash: [u8; 32],
|
||||
pub number: u64,
|
||||
pub signed: u64,
|
||||
pub total: u64,
|
||||
pub frozen_signed: u64,
|
||||
pub frozen_total: u64,
|
||||
pub daa: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FinExt {
|
||||
pub fin_version: u16,
|
||||
pub table_root: [u8; 32],
|
||||
pub history_root: [u8; 32],
|
||||
pub history_first: u64,
|
||||
pub lock: Lock,
|
||||
pub flags: u16,
|
||||
}
|
||||
|
||||
impl FinExt {
|
||||
pub fn stale(&self) -> bool {
|
||||
self.flags & FLAG_STALE != 0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Statement {
|
||||
pub chain_id: u64,
|
||||
pub number: u64,
|
||||
pub block_hash: [u8; 32],
|
||||
pub parent_hash: [u8; 32],
|
||||
pub post_root: [u8; 32],
|
||||
pub shard_vk: [u8; 32],
|
||||
pub agg_vk: [u8; 32],
|
||||
pub chain_len: u64,
|
||||
pub fin: Option<FinExt>,
|
||||
}
|
||||
|
||||
fn u64_at(b: &[u8], i: usize) -> u64 {
|
||||
u64::from_be_bytes(b[i..i + 8].try_into().unwrap())
|
||||
}
|
||||
fn u16_at(b: &[u8], i: usize) -> u16 {
|
||||
u16::from_be_bytes([b[i], b[i + 1]])
|
||||
}
|
||||
fn h_at(b: &[u8], i: usize) -> [u8; 32] {
|
||||
b[i..i + 32].try_into().unwrap()
|
||||
}
|
||||
|
||||
impl Statement {
|
||||
/// `None` unless the bytes are exactly 340 or 504 long and, at 504, carry `fin_version` 1.
|
||||
pub fn parse(b: &[u8]) -> Option<Self> {
|
||||
let fin = match b.len() {
|
||||
BLOCK_STATEMENT_LEN => None,
|
||||
n if n == BLOCK_STATEMENT_LEN + FIN_EXT_LEN => {
|
||||
let e = &b[BLOCK_STATEMENT_LEN..];
|
||||
let fin_version = u16_at(e, 0);
|
||||
if fin_version != 1 {
|
||||
return None;
|
||||
}
|
||||
Some(FinExt {
|
||||
fin_version,
|
||||
table_root: h_at(e, 2),
|
||||
history_root: h_at(e, 34),
|
||||
history_first: u64_at(e, 66),
|
||||
lock: Lock {
|
||||
index: u64_at(e, 74),
|
||||
hash: h_at(e, 82),
|
||||
number: u64_at(e, 114),
|
||||
signed: u64_at(e, 122),
|
||||
total: u64_at(e, 130),
|
||||
frozen_signed: u64_at(e, 138),
|
||||
frozen_total: u64_at(e, 146),
|
||||
daa: u64_at(e, 154),
|
||||
},
|
||||
flags: u16_at(e, 162),
|
||||
})
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let st = Self {
|
||||
chain_id: u64_at(b, 0),
|
||||
number: u64_at(b, 8),
|
||||
block_hash: h_at(b, 16),
|
||||
parent_hash: h_at(b, 48),
|
||||
post_root: h_at(b, 148),
|
||||
shard_vk: h_at(b, 268),
|
||||
agg_vk: h_at(b, 300),
|
||||
chain_len: u64_at(b, 332),
|
||||
fin,
|
||||
};
|
||||
if let Some(f) = &st.fin {
|
||||
// the history counts from history_first to this block; a first above the block is malformed
|
||||
if f.history_first > st.number {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(st)
|
||||
}
|
||||
|
||||
/// The history's leaf count: one per chain block from `history_first` to `number`.
|
||||
pub fn history_leaves(&self) -> Option<u64> {
|
||||
self.fin.as_ref().map(|f| self.number - f.history_first + 1)
|
||||
}
|
||||
|
||||
pub fn to_bytes_len(&self) -> usize {
|
||||
if self.fin.is_some() { BLOCK_STATEMENT_LEN + FIN_EXT_LEN } else { BLOCK_STATEMENT_LEN }
|
||||
}
|
||||
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
pub fn describe(&self) -> Vec<(&'static str, alloc::string::String)> {
|
||||
let mut v = alloc::vec![
|
||||
("chain_id", alloc::format!("{}", self.chain_id)),
|
||||
("number", alloc::format!("{}", self.number)),
|
||||
("chain_len", alloc::format!("{}", self.chain_len)),
|
||||
];
|
||||
if let Some(f) = &self.fin {
|
||||
v.push(("lock_index", alloc::format!("{}", f.lock.index)));
|
||||
v.push(("lock_number", alloc::format!("{}", f.lock.number)));
|
||||
v.push(("lock_signed", alloc::format!("{}", f.lock.signed)));
|
||||
v.push(("lock_total", alloc::format!("{}", f.lock.total)));
|
||||
v.push(("stale", alloc::format!("{}", f.stale())));
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
89
light/igneum-light/src/tests.rs
Normal file
89
light/igneum-light/src/tests.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
//! The two pairing paths agree on the one fixture the repository can make before the wrap lands: generator points
|
||||
//! that load and prove nothing (refused by both), and every refusal that must come before the pairing.
|
||||
|
||||
use crate::object::PV_LEN_FIN;
|
||||
use crate::*;
|
||||
use alloc::vec;
|
||||
|
||||
fn pv(chain_id: u64, key: [u8; 32]) -> Vec<u8> {
|
||||
let mut v = vec![0u8; PV_LEN_FIN];
|
||||
v[0..8].copy_from_slice(&chain_id.to_be_bytes());
|
||||
v[8..16].copy_from_slice(&81053u64.to_be_bytes());
|
||||
v[300..332].copy_from_slice(&key);
|
||||
v[332..340].copy_from_slice(&8u64.to_be_bytes());
|
||||
v[340..342].copy_from_slice(&1u16.to_be_bytes());
|
||||
v[406..414].copy_from_slice(&79046u64.to_be_bytes());
|
||||
v[414..422].copy_from_slice(&2702u64.to_be_bytes());
|
||||
v[454..462].copy_from_slice(&81049u64.to_be_bytes());
|
||||
v
|
||||
}
|
||||
|
||||
/// bn254's generators in gnark's uncompressed big-endian order (x1 ‖ x0 ‖ y1 ‖ y0 for G2), from EIP-197.
|
||||
fn generator_proof() -> [u8; 256] {
|
||||
fn be(dec: &str) -> [u8; 32] {
|
||||
// decimal to 32-byte big-endian without a bignum crate
|
||||
let mut out = [0u8; 32];
|
||||
for d in dec.bytes() {
|
||||
let mut carry = (d - b'0') as u32;
|
||||
for b in out.iter_mut().rev() {
|
||||
let v = (*b as u32) * 10 + carry;
|
||||
*b = (v & 0xff) as u8;
|
||||
carry = v >> 8;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
let mut p = [0u8; 256];
|
||||
p[0..32].copy_from_slice(&be("1"));
|
||||
p[32..64].copy_from_slice(&be("2"));
|
||||
p[64..96].copy_from_slice(&be("11559732032986387107991004021392285783925812861821192530917403151452391805634"));
|
||||
p[96..128].copy_from_slice(&be("10857046999023057135944570762232829481370756359578518086990519993285655852781"));
|
||||
p[128..160].copy_from_slice(&be("4082367875863433681332203403145435568316851327593401208105741076214120093531"));
|
||||
p[160..192].copy_from_slice(&be("8495653923123431417604973247489272438418190587263600148770280649306958101930"));
|
||||
p[192..224].copy_from_slice(&be("1"));
|
||||
p[224..256].copy_from_slice(&be("2"));
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_pairing_paths_refuse_the_generator_proof_and_agree() {
|
||||
let key = [0x12u8; 32];
|
||||
let obj = FinalityObject { kind: KIND_SP1_GROTH16, key_id: key, public_values: pv(4463, key), nonce: [0u8; 32], proof: generator_proof() };
|
||||
let v = Verifier::new();
|
||||
assert!(v.verify_wrap(&obj).is_err(), "arkworks must refuse generator points");
|
||||
assert!(verify_sp1_groth16_reference(&obj).is_err(), "substrate-bn must refuse generator points");
|
||||
assert!(verify_sp1_groth16_reference_one_pairing(&obj).is_err());
|
||||
let verdict = v.verify(&obj.encode(), None, &Pinned { key_id: key, chain_id: 4463 }, None);
|
||||
assert!(matches!(verdict.proof, ProofVerdict::Refused(_)), "{verdict:?}");
|
||||
assert_eq!(verdict.answer, None);
|
||||
// a point off the curve is refused before any pairing
|
||||
let mut off = obj.clone();
|
||||
off.proof[1] ^= 1;
|
||||
let why = v.verify_wrap(&off).unwrap_err();
|
||||
assert!(why.contains("curve points"), "{why}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refusals_before_the_pairing_and_the_stub_answers() {
|
||||
let key = [0x12u8; 32];
|
||||
let pinned = Pinned { key_id: key, chain_id: 4463 };
|
||||
let v = Verifier::new();
|
||||
let stub = FinalityObject::stub(key, pv(4463, key));
|
||||
let r = v.verify(&stub.encode(), None, &pinned, None);
|
||||
assert_eq!(r.proof, ProofVerdict::Stub);
|
||||
assert_eq!(r.answer, Some(Answer::NotFinal), "81053 lies above the lock at 81049");
|
||||
assert_eq!(r.trust_row, TRUST_ROW_STUB);
|
||||
let other = FinalityObject::stub(key, pv(4464, key));
|
||||
assert!(matches!(v.verify(&other.encode(), None, &pinned, None).proof, ProofVerdict::Refused(ref w) if w.contains("chain id")));
|
||||
let wrong_key = Pinned { key_id: [9u8; 32], chain_id: 4463 };
|
||||
assert!(matches!(v.verify(&stub.encode(), None, &wrong_key, None).proof, ProofVerdict::Refused(ref w) if w.contains("pinned aggregator")));
|
||||
let mut plain = FinalityObject::stub(key, pv(4463, key));
|
||||
plain.public_values.truncate(340);
|
||||
assert_eq!(v.verify(&plain.encode(), None, &pinned, None).answer, Some(Answer::NoClaim));
|
||||
let mut stale = FinalityObject::stub(key, pv(4463, key));
|
||||
stale.public_values[503] = 1;
|
||||
assert_eq!(v.verify(&stale.encode(), None, &pinned, None).answer, Some(Answer::Stale));
|
||||
let clock = Clock { now_unix_s: 1_000_000, genesis_unix_s: 800_000, seconds_per_daa: 1.0 };
|
||||
let aged = v.verify(&stub.encode(), None, &pinned, Some(clock));
|
||||
assert_eq!(aged.age_s, Some(200_000), "lock daa 0 in this synthetic statement");
|
||||
}
|
||||
BIN
light/vectors/fixture-81053-stub-flipped-lock-number.bin
Normal file
BIN
light/vectors/fixture-81053-stub-flipped-lock-number.bin
Normal file
Binary file not shown.
BIN
light/vectors/fixture-81053-stub-version-2.bin
Normal file
BIN
light/vectors/fixture-81053-stub-version-2.bin
Normal file
Binary file not shown.
BIN
light/vectors/fixture-81053-stub-wrong-key.bin
Normal file
BIN
light/vectors/fixture-81053-stub-wrong-key.bin
Normal file
Binary file not shown.
BIN
light/vectors/fixture-81053-stub.bin
Normal file
BIN
light/vectors/fixture-81053-stub.bin
Normal file
Binary file not shown.
BIN
light/vectors/fixture-81053-timing-flipped-lock-number.bin
Normal file
BIN
light/vectors/fixture-81053-timing-flipped-lock-number.bin
Normal file
Binary file not shown.
BIN
light/vectors/fixture-81053-timing-flipped-proof.bin
Normal file
BIN
light/vectors/fixture-81053-timing-flipped-proof.bin
Normal file
Binary file not shown.
BIN
light/vectors/fixture-81053-timing.bin
Normal file
BIN
light/vectors/fixture-81053-timing.bin
Normal file
Binary file not shown.
15
light/vectors/manifest.json
Normal file
15
light/vectors/manifest.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{"format":"igneum-finality-object-vectors-1","object_version":1,"sp1_version":"6.8.1","key_id":"12bff5be5699cc5e2b677d837559517033870903248c2761726523096d5f3822","chain_id":4463,"vectors":[
|
||||
{"name":"fixture-81053-stub","bytes":832,"sha256":"5174955671d86375ae7f96417673ca55dbc77498a4e825a585f03b72a0eab371","query":null,"expect":{"proof":"stub","answer":"not final"},"verdict":{"proof":"stub","reason":null,"answer":"not final","block_number":81053,"age_s":null,"trust_row":"wrap pending: the public values are read, nothing is proven","statement":{"chain_id":4463,"number":81053,"chain_len":8,"block_hash":"040acfe3dc3ca820cfe2565433d200cf9b2399ed3bcd1e04598fc05bcb1379c5","post_root":"be1ac38932436b515a6ef939e28dc50b4299c26094563fffb3e3fc710439c874","agg_vk":"12bff5be5699cc5e2b677d837559517033870903248c2761726523096d5f3822","fin":{"history_first":79046,"stale":false,"history_root":"71ea9db64404391ea74cd595c0395d0cc82b36620140f0b5b5c41bf4e39d2d33","table_root":"10c2458b19e20cbbbf68c4bfc01d7576201f7cd26ee916f393a720812e1b577d","lock":{"index":2702,"number":81049,"signed":2808,"total":4008,"frozen_signed":0,"frozen_total":0,"daa":100003,"hash":"dba699a61f7e4b3b8356a7494afbb73237f00385d7ad279a77002bfd451b2643"}}}}},
|
||||
{"name":"fixture-81053-timing","bytes":832,"sha256":"40ee84e88112a25ef796dbc693495896a9e92ccad50ebda4c17ac248db9b0854","query":null,"expect":{"proof":"refused","answer":null},"verdict":{"proof":"refused","reason":"the Groth16 wrap does not verify under the pinned key","answer":null,"block_number":null,"age_s":null,"trust_row":"voter set: verified in the proof; blue set: from the prover, vetoed by full nodes (level 0)","statement":null}},
|
||||
{"name":"fixture-81053-stub-flipped-lock-number","bytes":832,"sha256":"d7adc96d66690fcf8b8eaf311ce3b7929fda50e1c122b5a035e6c6f005234f2a","query":null,"expect":{"proof":"stub","answer":"not final"},"verdict":{"proof":"stub","reason":null,"answer":"not final","block_number":81053,"age_s":null,"trust_row":"wrap pending: the public values are read, nothing is proven","statement":{"chain_id":4463,"number":81053,"chain_len":8,"block_hash":"040acfe3dc3ca820cfe2565433d200cf9b2399ed3bcd1e04598fc05bcb1379c5","post_root":"be1ac38932436b515a6ef939e28dc50b4299c26094563fffb3e3fc710439c874","agg_vk":"12bff5be5699cc5e2b677d837559517033870903248c2761726523096d5f3822","fin":{"history_first":79046,"stale":false,"history_root":"71ea9db64404391ea74cd595c0395d0cc82b36620140f0b5b5c41bf4e39d2d33","table_root":"10c2458b19e20cbbbf68c4bfc01d7576201f7cd26ee916f393a720812e1b577d","lock":{"index":2702,"number":81048,"signed":2808,"total":4008,"frozen_signed":0,"frozen_total":0,"daa":100003,"hash":"dba699a61f7e4b3b8356a7494afbb73237f00385d7ad279a77002bfd451b2643"}}}}},
|
||||
{"name":"fixture-81053-timing-flipped-lock-number","bytes":832,"sha256":"0a8242bddd23f2bf8834aa175bcff8e3906612c558a3b8357dd1b3658f88c0e8","query":null,"expect":{"proof":"refused","answer":null},"verdict":{"proof":"refused","reason":"the Groth16 wrap does not verify under the pinned key","answer":null,"block_number":null,"age_s":null,"trust_row":"voter set: verified in the proof; blue set: from the prover, vetoed by full nodes (level 0)","statement":null}},
|
||||
{"name":"fixture-81053-timing-flipped-proof","bytes":832,"sha256":"439c74ffe46ac8285c8916d91ba026f7a460a15fc1b3624be3b1cff6c27ca176","query":null,"expect":{"proof":"refused","answer":null},"verdict":{"proof":"refused","reason":"the wrap's curve points do not load: G1CompressionError","answer":null,"block_number":null,"age_s":null,"trust_row":"voter set: verified in the proof; blue set: from the prover, vetoed by full nodes (level 0)","statement":null}},
|
||||
{"name":"fixture-81053-stub-wrong-key","bytes":832,"sha256":"8def8ed165ce36e377bbdde684710433ea98444748b4de0e452e4374345e8ffd","query":null,"expect":{"proof":"refused","answer":null},"verdict":{"proof":"refused","reason":"key id is not the pinned aggregator id","answer":null,"block_number":null,"age_s":null,"trust_row":"voter set: verified in the proof; blue set: from the prover, vetoed by full nodes (level 0)","statement":null}},
|
||||
{"name":"fixture-81053-stub-version-2","bytes":832,"sha256":"8d297ff5fdbe60249062a72fe456e1be70745fe01a70e2298b54867fe517fd52","query":null,"expect":{"proof":"refused","answer":null},"verdict":{"proof":"refused","reason":"object version 2 is not 1","answer":null,"block_number":null,"age_s":null,"trust_row":"voter set: verified in the proof; blue set: from the prover, vetoed by full nodes (level 0)","statement":null}},
|
||||
{"name":"synthetic-300-block-79146-final","bytes":832,"sha256":"0bc7a808c5a2447e679c2d26b048f18d646911c8c57e75d256058f59ea81b19b","query":{"bytes":532,"sha256":"56a131b1756b004e5db771753960d71afc5a29317236c803aa9af6f366c1e4f7"},"expect":{"proof":"stub","answer":"final"},"verdict":{"proof":"stub","reason":null,"answer":"final","block_number":79146,"age_s":null,"trust_row":"wrap pending: the public values are read, nothing is proven","statement":{"chain_id":4463,"number":79345,"chain_len":300,"block_hash":"e69504449c14a7d1927d745631b42ef667a083bc1d9b1748b616f7e36c52ee56","post_root":"5ad709f448c45ab7524f09206de6a2fbc35fbcacd5044516e773069debc81648","agg_vk":"12bff5be5699cc5e2b677d837559517033870903248c2761726523096d5f3822","fin":{"history_first":79046,"stale":false,"history_root":"8ae98c7320e592c33ae8658197836eed62bcc50d8e99f1bf3f8b2a5ac2327af5","table_root":"a23728c84a7d1cb5593d16f40c301752db2032ca6b62f797ec31d943a4c3cc45","lock":{"index":2702,"number":79300,"signed":2808,"total":4008,"frozen_signed":2808,"frozen_total":4008,"daa":100762,"hash":"97f6c6d7e0d0890710a027fb994397582409b61559753cb46adfab318b448b18"}}}}},
|
||||
{"name":"synthetic-300-block-79345-not-final","bytes":832,"sha256":"0bc7a808c5a2447e679c2d26b048f18d646911c8c57e75d256058f59ea81b19b","query":{"bytes":334,"sha256":"f89d1b1f0713cb2c741292281dfa4866c239f9d226b42571c0eda6fc8407e266"},"expect":{"proof":"stub","answer":"not final"},"verdict":{"proof":"stub","reason":null,"answer":"not final","block_number":79345,"age_s":null,"trust_row":"wrap pending: the public values are read, nothing is proven","statement":{"chain_id":4463,"number":79345,"chain_len":300,"block_hash":"e69504449c14a7d1927d745631b42ef667a083bc1d9b1748b616f7e36c52ee56","post_root":"5ad709f448c45ab7524f09206de6a2fbc35fbcacd5044516e773069debc81648","agg_vk":"12bff5be5699cc5e2b677d837559517033870903248c2761726523096d5f3822","fin":{"history_first":79046,"stale":false,"history_root":"8ae98c7320e592c33ae8658197836eed62bcc50d8e99f1bf3f8b2a5ac2327af5","table_root":"a23728c84a7d1cb5593d16f40c301752db2032ca6b62f797ec31d943a4c3cc45","lock":{"index":2702,"number":79300,"signed":2808,"total":4008,"frozen_signed":2808,"frozen_total":4008,"daa":100762,"hash":"97f6c6d7e0d0890710a027fb994397582409b61559753cb46adfab318b448b18"}}}}},
|
||||
{"name":"synthetic-300-block-79146-wrong-leaf-not-in-chain","bytes":832,"sha256":"0bc7a808c5a2447e679c2d26b048f18d646911c8c57e75d256058f59ea81b19b","query":{"bytes":532,"sha256":"fded005f2fc69c85217fd389ea1e4252c6bff9fc097e647e1ce28940a3c175a0"},"expect":{"proof":"stub","answer":"not in this chain"},"verdict":{"proof":"stub","reason":null,"answer":"not in this chain","block_number":79146,"age_s":null,"trust_row":"wrap pending: the public values are read, nothing is proven","statement":{"chain_id":4463,"number":79345,"chain_len":300,"block_hash":"e69504449c14a7d1927d745631b42ef667a083bc1d9b1748b616f7e36c52ee56","post_root":"5ad709f448c45ab7524f09206de6a2fbc35fbcacd5044516e773069debc81648","agg_vk":"12bff5be5699cc5e2b677d837559517033870903248c2761726523096d5f3822","fin":{"history_first":79046,"stale":false,"history_root":"8ae98c7320e592c33ae8658197836eed62bcc50d8e99f1bf3f8b2a5ac2327af5","table_root":"a23728c84a7d1cb5593d16f40c301752db2032ca6b62f797ec31d943a4c3cc45","lock":{"index":2702,"number":79300,"signed":2808,"total":4008,"frozen_signed":2808,"frozen_total":4008,"daa":100762,"hash":"97f6c6d7e0d0890710a027fb994397582409b61559753cb46adfab318b448b18"}}}}},
|
||||
{"name":"synthetic-300-stale","bytes":832,"sha256":"2887b2b7dc2f21f5d780c8747eaae495f7af4a8507a76eff5c4d0cf2284ad468","query":null,"expect":{"proof":"stub","answer":"stale"},"verdict":{"proof":"stub","reason":null,"answer":"stale","block_number":79345,"age_s":null,"trust_row":"wrap pending: the public values are read, nothing is proven","statement":{"chain_id":4463,"number":79345,"chain_len":300,"block_hash":"e69504449c14a7d1927d745631b42ef667a083bc1d9b1748b616f7e36c52ee56","post_root":"5ad709f448c45ab7524f09206de6a2fbc35fbcacd5044516e773069debc81648","agg_vk":"12bff5be5699cc5e2b677d837559517033870903248c2761726523096d5f3822","fin":{"history_first":79046,"stale":true,"history_root":"8ae98c7320e592c33ae8658197836eed62bcc50d8e99f1bf3f8b2a5ac2327af5","table_root":"a23728c84a7d1cb5593d16f40c301752db2032ca6b62f797ec31d943a4c3cc45","lock":{"index":2702,"number":79300,"signed":2808,"total":4008,"frozen_signed":2808,"frozen_total":4008,"daa":100762,"hash":"97f6c6d7e0d0890710a027fb994397582409b61559753cb46adfab318b448b18"}}}}},
|
||||
{"name":"synthetic-300-other-chain","bytes":832,"sha256":"70d94f4573ae9e8d186c6adb18e93df44718e42f0d2dba1c46f848f3f070a9d4","query":null,"expect":{"proof":"refused","answer":null},"verdict":{"proof":"refused","reason":"chain id 4464 is not the pinned chain 4463","answer":null,"block_number":null,"age_s":null,"trust_row":"voter set: verified in the proof; blue set: from the prover, vetoed by full nodes (level 0)","statement":null}},
|
||||
{"name":"synthetic-300-timing","bytes":832,"sha256":"65fce1a7c0113208806e9543c4a1ca0d40c8d31380a4d589564adc658d0d71f6","query":{"bytes":532,"sha256":"56a131b1756b004e5db771753960d71afc5a29317236c803aa9af6f366c1e4f7"},"expect":{"proof":"refused","answer":null},"verdict":{"proof":"refused","reason":"the Groth16 wrap does not verify under the pinned key","answer":null,"block_number":null,"age_s":null,"trust_row":"voter set: verified in the proof; blue set: from the prover, vetoed by full nodes (level 0)","statement":null}}
|
||||
]}
|
||||
BIN
light/vectors/public-values-81053.bin
Normal file
BIN
light/vectors/public-values-81053.bin
Normal file
Binary file not shown.
BIN
light/vectors/synthetic-300-block-79146-final.bin
Normal file
BIN
light/vectors/synthetic-300-block-79146-final.bin
Normal file
Binary file not shown.
BIN
light/vectors/synthetic-300-block-79146-final.query.bin
Normal file
BIN
light/vectors/synthetic-300-block-79146-final.query.bin
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
light/vectors/synthetic-300-block-79345-not-final.bin
Normal file
BIN
light/vectors/synthetic-300-block-79345-not-final.bin
Normal file
Binary file not shown.
BIN
light/vectors/synthetic-300-block-79345-not-final.query.bin
Normal file
BIN
light/vectors/synthetic-300-block-79345-not-final.query.bin
Normal file
Binary file not shown.
BIN
light/vectors/synthetic-300-other-chain.bin
Normal file
BIN
light/vectors/synthetic-300-other-chain.bin
Normal file
Binary file not shown.
BIN
light/vectors/synthetic-300-stale.bin
Normal file
BIN
light/vectors/synthetic-300-stale.bin
Normal file
Binary file not shown.
BIN
light/vectors/synthetic-300-timing.bin
Normal file
BIN
light/vectors/synthetic-300-timing.bin
Normal file
Binary file not shown.
BIN
light/vectors/synthetic-300-timing.query.bin
Normal file
BIN
light/vectors/synthetic-300-timing.query.bin
Normal file
Binary file not shown.
55
proving/igneum-prove/Cargo.lock
generated
55
proving/igneum-prove/Cargo.lock
generated
|
|
@ -950,6 +950,16 @@ dependencies = [
|
|||
"wyz",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2b_simd"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3560a7b1951efe814fcd721938313adc56753ca39f4b23847d7e9a2402f5dbff"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"constant_time_eq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.8.7"
|
||||
|
|
@ -981,6 +991,22 @@ dependencies = [
|
|||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bls12_381"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/sp1-patches/bls12_381?tag=patch-0.8.0-sp1-6.2.0#9e4e2ae4780d3d69cecbec000f5e814df2392468"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest 0.10.7",
|
||||
"ff",
|
||||
"group",
|
||||
"hex",
|
||||
"pairing",
|
||||
"rand_core 0.6.4",
|
||||
"sp1-lib",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blst"
|
||||
version = "0.3.17"
|
||||
|
|
@ -2762,6 +2788,20 @@ dependencies = [
|
|||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "igneum-fin-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bincode",
|
||||
"blake2b_simd",
|
||||
"bls12_381",
|
||||
"blst",
|
||||
"group",
|
||||
"hex",
|
||||
"serde",
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "igneum-prove-aggregator"
|
||||
version = "0.1.0"
|
||||
|
|
@ -2782,6 +2822,7 @@ dependencies = [
|
|||
"alloy-rlp",
|
||||
"alloy-trie",
|
||||
"igneum-evm-types",
|
||||
"igneum-fin-core",
|
||||
"revm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -2809,8 +2850,10 @@ dependencies = [
|
|||
"alloy-trie",
|
||||
"anyhow",
|
||||
"bincode",
|
||||
"blst",
|
||||
"hex",
|
||||
"igneum-evm-types",
|
||||
"igneum-fin-core",
|
||||
"igneum-prove-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -3778,6 +3821,15 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pairing"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81fec4625e73cf41ef4bb6846cafa6d44736525f442ba45e407c4a000a13996f"
|
||||
dependencies = [
|
||||
"group",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parity-scale-codec"
|
||||
version = "3.7.5"
|
||||
|
|
@ -5178,8 +5230,7 @@ checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
|
|||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
source = "git+https://github.com/sp1-patches/RustCrypto-hashes?tag=patch-sha2-0.10.9-sp1-6.2.0#e48b656ebc806117554bb33c2f8687e4637e37ff"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
# commit b7fca5a0, merged into devnet-v4) and SP1 v6.8.1 (24 Sep 2026) use. Change one, re-run the export and the native check.
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["core", "program", "aggregator", "host", "export"]
|
||||
members = ["fin", "core", "program", "aggregator", "host", "export"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
|
|
@ -12,9 +12,10 @@ license = "ISC"
|
|||
|
||||
[workspace.dependencies]
|
||||
igneum-prove-core = { path = "core" }
|
||||
igneum-fin-core = { path = "fin" }
|
||||
# The execution layer's own transaction decoder (alloy only, no kaspa dependency), so the guest and the node
|
||||
# cannot disagree about what a well-formed transaction is.
|
||||
igneum-evm-types = { path = "../../vendor/igneum-node-exec/igneum/evm-types" }
|
||||
igneum-evm-types = { path = "../../vendor/igneum-node/igneum/evm-types" }
|
||||
|
||||
revm = { version = "=43.0.3", default-features = false, features = ["std", "serde", "optional_balance_check", "optional_no_base_fee", "optional_block_gas_limit", "optional_eip3607", "optional_priority_fee_check"] }
|
||||
alloy-primitives = { version = "=1.7.3", default-features = false, features = ["std", "rlp", "serde", "k256"] }
|
||||
|
|
@ -38,3 +39,9 @@ sp1-build = "=6.8.1"
|
|||
[patch.crates-io]
|
||||
sha3 = { git = "https://github.com/sp1-patches/RustCrypto-hashes", package = "sha3", tag = "patch-sha3-0.11.0-sp1-6.0.0" }
|
||||
k256 = { git = "https://github.com/sp1-patches/elliptic-curves", tag = "patch-k256-13.4-sp1-6.2.0" }
|
||||
# Finality in the proof (7 October 2026): BLS12-381 field operations on the precompiles for the certificate check
|
||||
# in the aggregator guest; natively the same crate's own arithmetic.
|
||||
bls12_381 = { git = "https://github.com/sp1-patches/bls12_381", tag = "patch-0.8.0-sp1-6.2.0" }
|
||||
# The SHA-256 precompile for the key table, ring and history hashes of the finality fold (and the hash-to-curve's
|
||||
# expand_message inside bls12_381); natively the upstream code, the same bytes.
|
||||
sha2 = { git = "https://github.com/sp1-patches/RustCrypto-hashes", package = "sha2", tag = "patch-sha2-0.10.9-sp1-6.2.0" }
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ license.workspace = true
|
|||
|
||||
[dependencies]
|
||||
igneum-evm-types.workspace = true
|
||||
igneum-fin-core.workspace = true
|
||||
revm.workspace = true
|
||||
alloy-primitives.workspace = true
|
||||
alloy-consensus.workspace = true
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@
|
|||
use crate::executor::Carry;
|
||||
use crate::shard::ShardOutput;
|
||||
use alloy_primitives::{keccak256, B256};
|
||||
use igneum_fin_core::fold::{fold_block, ChainBlockWitness, FoldWitness};
|
||||
use igneum_fin_core::keys::{KeyLeafWitness, WitnessKeys};
|
||||
use igneum_fin_core::ring::{RingLeafWitness, WitnessRing};
|
||||
use igneum_fin_core::{FinExt, FinParams, FinState};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A verifying key hash as the guest receives it (SP1's `hash_u32`) and as the public values carry it.
|
||||
|
|
@ -22,6 +26,20 @@ pub struct PrevLink {
|
|||
pub public_values: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Finality in the proof (docs/design/finality-in-proof.md): what the fold of this chain block takes. The previous
|
||||
/// state is checked against the previous proof's extension when that proof carries one; else it is the root the
|
||||
/// attestation starts from (taken as given, `history_first` = this block).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct FinInput {
|
||||
pub params: FinParams,
|
||||
pub prev_state: FinState,
|
||||
pub block: ChainBlockWitness,
|
||||
pub ring: Vec<RingLeafWitness>,
|
||||
#[serde(default)]
|
||||
pub keys: Vec<KeyLeafWitness>,
|
||||
pub witness: FoldWitness,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct AggInput {
|
||||
pub shard_vk: [u32; 8],
|
||||
|
|
@ -29,6 +47,10 @@ pub struct AggInput {
|
|||
pub shards: Vec<Vec<u8>>,
|
||||
pub parent_hash: B256,
|
||||
pub prev: Option<PrevLink>,
|
||||
/// Absent: the 340-byte statement of proving v1, byte for byte. Present: the statement gains the finality
|
||||
/// extension (`FinExt`, 164 bytes).
|
||||
#[serde(default)]
|
||||
pub fin: Option<FinInput>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -54,10 +76,14 @@ pub struct BlockOutput {
|
|||
pub agg_vk: B256,
|
||||
/// Blocks attested by this proof: 1, or the previous proof's count plus one.
|
||||
pub chain_len: u64,
|
||||
/// The finality extension (docs/design/finality-in-proof.md section 1), from `finality_in_proof_activation_daa`.
|
||||
pub fin: Option<FinExt>,
|
||||
}
|
||||
|
||||
impl BlockOutput {
|
||||
pub const LEN: usize = 8 + 8 + 32 + 32 + 4 + 32 * 4 + 8 + 8 + 4 + 4 + 32 * 3 + 8;
|
||||
/// The length with the finality extension.
|
||||
pub const LEN2: usize = Self::LEN + FinExt::LEN;
|
||||
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let mut v = Vec::with_capacity(Self::LEN);
|
||||
|
|
@ -78,13 +104,18 @@ impl BlockOutput {
|
|||
}
|
||||
v.extend_from_slice(&self.chain_len.to_be_bytes());
|
||||
debug_assert_eq!(v.len(), Self::LEN);
|
||||
if let Some(f) = &self.fin {
|
||||
v.extend_from_slice(&f.to_bytes());
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
pub fn from_bytes(b: &[u8]) -> Option<Self> {
|
||||
if b.len() != Self::LEN {
|
||||
return None;
|
||||
}
|
||||
let fin = match b.len() {
|
||||
Self::LEN => None,
|
||||
Self::LEN2 => Some(FinExt::from_bytes(&b[Self::LEN..])?),
|
||||
_ => return None,
|
||||
};
|
||||
let u64_at = |i: usize| u64::from_be_bytes(b[i..i + 8].try_into().unwrap());
|
||||
let u32_at = |i: usize| u32::from_be_bytes(b[i..i + 4].try_into().unwrap());
|
||||
let b256_at = |i: usize| B256::from_slice(&b[i..i + 32]);
|
||||
|
|
@ -106,6 +137,7 @@ impl BlockOutput {
|
|||
shard_vk: b256_at(268),
|
||||
agg_vk: b256_at(300),
|
||||
chain_len: u64_at(332),
|
||||
fin,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -154,11 +186,13 @@ pub fn aggregate(input: &AggInput, verify: &mut dyn FnMut(&[u32; 8], &[u8])) ->
|
|||
let first = first.unwrap();
|
||||
let last = last.unwrap();
|
||||
let shard_vk = vk_bytes(&input.shard_vk);
|
||||
let mut prev_fin: Option<FinExt> = None;
|
||||
let (agg_vk, chain_len) = match &input.prev {
|
||||
None => (B256::ZERO, 1u64),
|
||||
Some(prev) => {
|
||||
verify(&prev.agg_vk, &prev.public_values);
|
||||
let p = BlockOutput::from_bytes(&prev.public_values).expect("previous block public values decode");
|
||||
prev_fin = p.fin.clone();
|
||||
let agg_vk = vk_bytes(&prev.agg_vk);
|
||||
assert_eq!(p.chain_id, first.chain_id);
|
||||
assert_eq!(p.number + 1, first.number, "the previous proof is of the parent segment");
|
||||
|
|
@ -169,6 +203,26 @@ pub fn aggregate(input: &AggInput, verify: &mut dyn FnMut(&[u32; 8], &[u8])) ->
|
|||
(agg_vk, p.chain_len + 1)
|
||||
}
|
||||
};
|
||||
// finality in the proof: the fold of this chain block, rooted at the previous proof's state or at the witness
|
||||
let fin = input.fin.as_ref().map(|f| {
|
||||
let mut state = f.prev_state.clone();
|
||||
match &prev_fin {
|
||||
Some(ext) => assert_eq!(&state.extension(), ext, "the finality state is not the one the previous proof committed"),
|
||||
None => {
|
||||
// the root: a state the proof did not verify (the node's native compare does, a light client bridges
|
||||
// to it from the proof it holds). `history_first` stays the attestation's own start (the tracker's,
|
||||
// from the activation block); where THIS proof chain's verification starts is `number - chain_len + 1`
|
||||
assert_eq!(state.end_number + 1, first.number, "the root state ends at the parent of this block");
|
||||
}
|
||||
}
|
||||
assert_eq!(f.block.number, first.number, "the finality witness is of this chain block");
|
||||
assert_eq!(f.block.block_hash.as_slice(), first.block_hash.as_slice(), "the finality witness names this chain block");
|
||||
let mut ring = WitnessRing::new(f.ring.clone());
|
||||
let mut keys = WitnessKeys::new(f.keys.clone());
|
||||
fold_block(&mut state, &f.params, &f.block, &mut ring, &mut keys, &f.witness, &igneum_fin_core::bls::ZkBls).expect("the finality fold");
|
||||
assert!(ring.witnesses.is_empty() && keys.witnesses.is_empty(), "every witness consumed");
|
||||
state.extension()
|
||||
});
|
||||
BlockOutput {
|
||||
chain_id: first.chain_id,
|
||||
number: first.number,
|
||||
|
|
@ -187,5 +241,6 @@ pub fn aggregate(input: &AggInput, verify: &mut dyn FnMut(&[u32; 8], &[u8])) ->
|
|||
shard_vk,
|
||||
agg_vk,
|
||||
chain_len,
|
||||
fin,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,22 +1,22 @@
|
|||
{
|
||||
"aggregator": {
|
||||
"elf": "igneum-prove-aggregator.elf",
|
||||
"elf_bytes": 319744,
|
||||
"elf_sha256": "0x143d9c243dd12e87e90be71f6b8cd42353e513bf8ce78903ef6f972f1bc9aa7b",
|
||||
"program_id": "0x474678f35f7545db28055d5e5bbc308231d84a5a072202087a2a8d5b09123896",
|
||||
"elf_bytes": 827056,
|
||||
"elf_sha256": "0xe66cd548e91d255c0402ad28d81fb5bc0076940aa4ae8d82640f02c167c5d1f5",
|
||||
"program_id": "0x4ab78fb11ce9cbef44d3232127ded9870e4262103e9162b867afeed3606f82ea",
|
||||
"vk": "igneum-prove-aggregator.vk",
|
||||
"vk_sha256": "0xad17bc1ae5be816554dbb13cb5b4d242678adfb8e1a4f7247ceb8b5ba9001b9f"
|
||||
"vk_sha256": "0x0ca8225dd3acb14cde1a59c4bd39edaa83af470100c4610b64993e047fef87c5"
|
||||
},
|
||||
"format": "igneum-prove-elf-manifest-v1",
|
||||
"pinned_at": "2026-10-05T16:20:38Z",
|
||||
"pinned_at": "2026-10-07T08:51:27Z",
|
||||
"pinned_on": "Darwin MacBook-Pro.local 25.6.0 Darwin Kernel Version 25.6.0: Fri Jul 31 19:19:08 PDT 2026; root:xnu-12377.161.14~5/RELEASE_ARM64_T6050 arm64",
|
||||
"shard": {
|
||||
"elf": "igneum-prove-program.elf",
|
||||
"elf_bytes": 2832504,
|
||||
"elf_sha256": "0x150f4c05a2951fc56174a87089707a030b18df8fbe7e053a66459edb83053083",
|
||||
"program_id": "0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a",
|
||||
"elf_bytes": 2815168,
|
||||
"elf_sha256": "0x55ab05ac0b418196e411708abcda60d58d6e979056d5e693192f268ffdd7756b",
|
||||
"program_id": "0x344632b61a57aede53aad07b5ecffded3c705e86120009b75fd396ed0cc47bd3",
|
||||
"vk": "igneum-prove-program.vk",
|
||||
"vk_sha256": "0x8b4da5bff86d963f4210a78e5d800a1cd00ab41b158f6962f4ac009edc249d4c"
|
||||
"vk_sha256": "0xdac21c01551afbe24adc95ead07624dc644197fcded0abe0d475e32ac7e3ff89"
|
||||
},
|
||||
"sp1_circuit_version": "v6.1.0",
|
||||
"sp1_crate_version": "6.8.1"
|
||||
|
|
|
|||
24
proving/igneum-prove/fin/Cargo.toml
Normal file
24
proving/igneum-prove/fin/Cargo.toml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
[package]
|
||||
name = "igneum-fin-core"
|
||||
description = "Finality carried inside the segment proof (docs/design/finality-in-proof.md): the W2 weight table as a commitment, its per-block update, the certificate check against the carried table; one code path for the guest, the host and the node's native tracker"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
sha2 = "0.10"
|
||||
blake2b_simd = { version = "1.0", default-features = false }
|
||||
# The BLS12-381 verifier of the guest (zkcrypto's crate, SP1's patch puts the field operations on the precompiles).
|
||||
# Natively it is the slow but exact reference the tests cross-check against blst.
|
||||
bls12_381 = { version = "0.8", default-features = false, features = ["groups", "pairings", "alloc", "experimental"], optional = true }
|
||||
group = { version = "0.13", default-features = false, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
blst = "0.3"
|
||||
bincode.workspace = true
|
||||
hex.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["zk-bls"]
|
||||
zk-bls = ["dep:bls12_381", "dep:group"]
|
||||
92
proving/igneum-prove/fin/src/bls.rs
Normal file
92
proving/igneum-prove/fin/src/bls.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
//! BLS12-381 in the min-pubkey setting the node uses (blst `min_pk`: public keys in G1, signatures in G2,
|
||||
//! hash-to-curve `BLS12381G2_XMD:SHA-256_SSWU_RO_`). The guest verifies through zkcrypto's `bls12_381` under
|
||||
//! SP1's patch; the node passes its own blst-backed verifier. Both answer the same question:
|
||||
//! `e(sum of pubkeys, H(msg)) == e(g1, sig)`, every key in the group and none the identity.
|
||||
|
||||
use crate::{PUBKEY_LEN, SIG_LEN};
|
||||
|
||||
pub const POINT_LEN: usize = 96;
|
||||
|
||||
pub trait Bls {
|
||||
/// `fast_aggregate_verify`: the aggregate of `pubkeys` signed `msg` under `dst` with `sig`. Every key is
|
||||
/// decompressed in full (a square root and a subgroup check per key): the reveal path.
|
||||
fn verify_aggregate(&self, pubkeys: &[[u8; PUBKEY_LEN]], msg: &[u8], dst: &[u8], sig: &[u8; SIG_LEN]) -> bool;
|
||||
|
||||
/// The certificate path (docs/design/finality-in-proof.md 6.1, the second fix): every signer's key arrives
|
||||
/// uncompressed (`points`, 96 bytes each) beside its committed compressed form (`pubkeys`); the point must lie
|
||||
/// on the curve and compress to the committed bytes, which pins it to the key the reveal validated (the
|
||||
/// x-coordinate and the sign of y name one curve point), so no square root and no subgroup check per signer.
|
||||
fn verify_aggregate_points(&self, points: &[[u8; POINT_LEN]], pubkeys: &[[u8; PUBKEY_LEN]], msg: &[u8], dst: &[u8], sig: &[u8; SIG_LEN]) -> bool;
|
||||
|
||||
fn verify_one(&self, pubkey: &[u8; PUBKEY_LEN], msg: &[u8], dst: &[u8], sig: &[u8; SIG_LEN]) -> bool {
|
||||
self.verify_aggregate(std::slice::from_ref(pubkey), msg, dst, sig)
|
||||
}
|
||||
}
|
||||
|
||||
/// zkcrypto's curve: the guest's verifier and the native reference.
|
||||
#[cfg(feature = "zk-bls")]
|
||||
pub struct ZkBls;
|
||||
|
||||
#[cfg(feature = "zk-bls")]
|
||||
impl Bls for ZkBls {
|
||||
fn verify_aggregate(&self, pubkeys: &[[u8; PUBKEY_LEN]], msg: &[u8], dst: &[u8], sig: &[u8; SIG_LEN]) -> bool {
|
||||
use bls12_381::{G1Affine, G1Projective, G2Affine};
|
||||
if pubkeys.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut agg = G1Projective::identity();
|
||||
for pk in pubkeys {
|
||||
let p = G1Affine::from_compressed(pk);
|
||||
if p.is_none().into() {
|
||||
return false;
|
||||
}
|
||||
let p = p.unwrap();
|
||||
if p.is_identity().into() {
|
||||
return false;
|
||||
}
|
||||
agg += G1Projective::from(p);
|
||||
}
|
||||
let s = G2Affine::from_compressed(sig);
|
||||
if s.is_none().into() {
|
||||
return false;
|
||||
}
|
||||
let s = s.unwrap();
|
||||
zk_pair(agg, msg, dst, s)
|
||||
}
|
||||
|
||||
fn verify_aggregate_points(&self, points: &[[u8; POINT_LEN]], pubkeys: &[[u8; PUBKEY_LEN]], msg: &[u8], dst: &[u8], sig: &[u8; SIG_LEN]) -> bool {
|
||||
use bls12_381::{G1Affine, G1Projective, G2Affine};
|
||||
if points.is_empty() || points.len() != pubkeys.len() {
|
||||
return false;
|
||||
}
|
||||
let mut agg = G1Projective::identity();
|
||||
for (pt, pk) in points.iter().zip(pubkeys) {
|
||||
let p = G1Affine::from_uncompressed_unchecked(pt);
|
||||
if p.is_none().into() {
|
||||
return false;
|
||||
}
|
||||
let p = p.unwrap();
|
||||
if !bool::from(p.is_on_curve()) || p.is_identity().into() || p.to_compressed() != *pk {
|
||||
return false;
|
||||
}
|
||||
agg += G1Projective::from(p);
|
||||
}
|
||||
let s = G2Affine::from_compressed(sig);
|
||||
if s.is_none().into() {
|
||||
return false;
|
||||
}
|
||||
zk_pair(agg, msg, dst, s.unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "zk-bls")]
|
||||
fn zk_pair(agg: bls12_381::G1Projective, msg: &[u8], dst: &[u8], s: bls12_381::G2Affine) -> bool {
|
||||
use bls12_381::hash_to_curve::{ExpandMsgXmd, HashToCurve};
|
||||
use bls12_381::{multi_miller_loop, G1Affine, G2Prepared, G2Projective, Gt};
|
||||
use group::Curve;
|
||||
let hm: G2Projective = <G2Projective as HashToCurve<ExpandMsgXmd<sha2::Sha256>>>::hash_to_curve(&[msg], dst);
|
||||
let agg_affine = agg.to_affine();
|
||||
let neg_g1 = -G1Affine::generator();
|
||||
let r = multi_miller_loop(&[(&agg_affine, &G2Prepared::from(hm.to_affine())), (&neg_g1, &G2Prepared::from(s))]).final_exponentiation();
|
||||
r == Gt::identity()
|
||||
}
|
||||
157
proving/igneum-prove/fin/src/cert.rs
Normal file
157
proving/igneum-prove/fin/src/cert.rs
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
//! The certificate inside the recursion (design section 3): the checkpoint is a chain block in the history, the
|
||||
//! table at that block is the one the proof committed there, the bitmap's signers are revealed voters of that
|
||||
//! table, the aggregate signature verifies, two thirds of the table signed (Q3) and two thirds of the table at the
|
||||
//! previous lock (Q5, less the keys that left), which never expires inside the proof (section 4.4).
|
||||
|
||||
use crate::bls::Bls;
|
||||
use crate::mmr::{HistoryLeaf, MmrProof};
|
||||
use crate::keys::{canonical, dense_root};
|
||||
use crate::{vote_message, voters_at, FinParams, FinState, KeyEntry, Lock, H, SIG_LEN};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A table at a chain block: the leaf that commits it and every entry with its slot, slot order.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TableAt {
|
||||
pub leaf: HistoryLeaf,
|
||||
pub proof: MmrProof,
|
||||
pub keys: Vec<(u64, KeyEntry)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CertificateWitness {
|
||||
pub index: u64,
|
||||
pub checkpoint: H,
|
||||
pub voter_count: u32,
|
||||
pub bitmap: Vec<u8>,
|
||||
#[serde(with = "crate::serde_arrays")]
|
||||
pub signature: [u8; SIG_LEN],
|
||||
/// Every signer's key uncompressed (96 bytes), in bitmap order: the certificate path takes no square root and
|
||||
/// no subgroup check per signer (design 6.1, the second fix); each must compress to the table's key.
|
||||
#[serde(default)]
|
||||
pub signer_points: Vec<Vec<u8>>,
|
||||
/// The table at the checkpoint block.
|
||||
pub at: TableAt,
|
||||
/// The table at the previous lock's block, when a lock exists.
|
||||
pub frozen: Option<TableAt>,
|
||||
}
|
||||
|
||||
pub fn signer_positions(bitmap: &[u8], voter_count: u32) -> Vec<usize> {
|
||||
let mut out = Vec::new();
|
||||
for (byte_index, byte) in bitmap.iter().enumerate() {
|
||||
for bit in 0..8 {
|
||||
if byte & (1 << bit) != 0 {
|
||||
let pos = byte_index * 8 + bit;
|
||||
if pos < voter_count as usize {
|
||||
out.push(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The table's entries against the leaf the history holds for that block: the dense root over the slots must be
|
||||
/// the leaf's. Returns the canonical (sorted, duplicate-free) list.
|
||||
fn check_table(state: &FinState, t: &TableAt, what: &str) -> Result<Vec<KeyEntry>, String> {
|
||||
if !t.proof.verify(&t.leaf.hash(), &state.history_root(), state.leaves) {
|
||||
return Err(format!("the {what} block is not in the proof's history"));
|
||||
}
|
||||
if dense_root(&t.keys, t.leaf.key_count)? != t.leaf.keys_root {
|
||||
return Err(format!("the {what} table is not the one the proof committed at that block"));
|
||||
}
|
||||
canonical(&t.keys).map_err(|e| format!("the {what} table: {e}"))
|
||||
}
|
||||
|
||||
pub fn verify_certificate(state: &mut FinState, params: &FinParams, c: &CertificateWitness, bls: &dyn Bls) -> Result<(), String> {
|
||||
if state.stale {
|
||||
return Err("the proof chain is stale: no lock for a full window, no certificate is accepted".into());
|
||||
}
|
||||
if c.index <= state.lock.index {
|
||||
return Err(format!("certificate index {} is not above the last lock {}", c.index, state.lock.index));
|
||||
}
|
||||
let at_keys = check_table(state, &c.at, "checkpoint")?;
|
||||
let leaf = &c.at.leaf;
|
||||
if leaf.block_hash != c.checkpoint {
|
||||
return Err("the certificate's checkpoint is not the block the history leaf names".into());
|
||||
}
|
||||
if leaf.number < state.history_first || (state.lock.index > 0 && leaf.number <= state.lock.number) {
|
||||
return Err(format!("checkpoint block {} is not above the last lock's block {}", leaf.number, state.lock.number));
|
||||
}
|
||||
if state.lock.index > 0 && leaf.daa <= state.lock.daa {
|
||||
return Err("checkpoint DAA score is not above the last lock's".into());
|
||||
}
|
||||
if leaf.daa < params.min_daa {
|
||||
return Err(format!("checkpoint DAA score {} is below min_daa {} (C5)", leaf.daa, params.min_daa));
|
||||
}
|
||||
// the canonical voter list at the checkpoint and the signers
|
||||
let (voters, total) = voters_at(&at_keys, leaf.daa, params.dust);
|
||||
if voters.len() != c.voter_count as usize {
|
||||
return Err(format!("the certificate names {} voters, the table at the checkpoint has {}", c.voter_count, voters.len()));
|
||||
}
|
||||
let positions = signer_positions(&c.bitmap, c.voter_count);
|
||||
if positions.is_empty() {
|
||||
return Err("the certificate has no signer".into());
|
||||
}
|
||||
let mut signed = 0u64;
|
||||
let mut pubkeys = Vec::with_capacity(positions.len());
|
||||
let mut signer_hashes: Vec<H> = Vec::with_capacity(positions.len());
|
||||
for p in &positions {
|
||||
let k = &at_keys[voters[*p]];
|
||||
if !k.revealed() {
|
||||
return Err("a signer's key is not revealed".into());
|
||||
}
|
||||
signed += k.blocks;
|
||||
pubkeys.push(k.pubkey);
|
||||
signer_hashes.push(k.key_hash);
|
||||
}
|
||||
let msg = vote_message(¶ms.chain_id, c.index, &c.checkpoint);
|
||||
let sig_ok = if c.signer_points.is_empty() {
|
||||
bls.verify_aggregate(&pubkeys, &msg, crate::DST_VOTE, &c.signature)
|
||||
} else {
|
||||
if c.signer_points.len() != pubkeys.len() {
|
||||
return Err("the certificate carries a point count other than its signer count".into());
|
||||
}
|
||||
let mut points = Vec::with_capacity(pubkeys.len());
|
||||
for p in &c.signer_points {
|
||||
let a: [u8; crate::bls::POINT_LEN] = p.as_slice().try_into().map_err(|_| "a signer point is not 96 bytes")?;
|
||||
points.push(a);
|
||||
}
|
||||
bls.verify_aggregate_points(&points, &pubkeys, &msg, crate::DST_VOTE, &c.signature)
|
||||
};
|
||||
if !sig_ok {
|
||||
return Err("the certificate's aggregate signature does not verify".into());
|
||||
}
|
||||
if total == 0 || !FinParams::floor_met(signed, total) {
|
||||
return Err(format!("signed {signed} of {total} is under two thirds (Q3)"));
|
||||
}
|
||||
// Q5, never expiring in the proof
|
||||
let (frozen_signed, frozen_total) = if state.lock.index > 0 {
|
||||
let f = c.frozen.as_ref().ok_or("a lock exists and the frozen table is missing")?;
|
||||
let f_keys = check_table(state, f, "frozen")?;
|
||||
if f.leaf.number != state.lock.number || f.leaf.block_hash != state.lock.hash {
|
||||
return Err("the frozen table is not the table at the last lock's block".into());
|
||||
}
|
||||
let (fvoters, ftotal) = voters_at(&f_keys, f.leaf.daa, params.dust);
|
||||
// keys gone at the checkpoint (their leaves carried after the lock) leave the frozen denominator
|
||||
let gone = |kh: &H| at_keys.binary_search_by(|k| k.key_hash.cmp(kh)).ok().map(|i| at_keys[i].is_gone(leaf.daa)).unwrap_or(false);
|
||||
let mut left = 0u64;
|
||||
let mut fsigned = 0u64;
|
||||
for &i in &fvoters {
|
||||
let k = &f_keys[i];
|
||||
if gone(&k.key_hash) {
|
||||
left += k.blocks;
|
||||
} else if signer_hashes.binary_search(&k.key_hash).is_ok() {
|
||||
fsigned += k.blocks;
|
||||
}
|
||||
}
|
||||
let ft = ftotal.saturating_sub(left);
|
||||
if ft == 0 || !FinParams::floor_met(fsigned, ft) {
|
||||
return Err(format!("signed {fsigned} of the frozen table's {ft} is under two thirds (Q5)"));
|
||||
}
|
||||
(fsigned, ft)
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
state.lock = Lock { index: c.index, hash: c.checkpoint, number: leaf.number, signed, total, frozen_signed, frozen_total, daa: leaf.daa };
|
||||
Ok(())
|
||||
}
|
||||
361
proving/igneum-prove/fin/src/fold.rs
Normal file
361
proving/igneum-prove/fin/src/fold.rs
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
//! One fold: one chain block into the carried state (design section 2), then the certificates that landed
|
||||
//! (section 3, `cert`). The same function runs in the guest (witnessed ring), on the host and in the node's
|
||||
//! tracker (sparse ring, witnesses recorded).
|
||||
|
||||
use crate::bls::Bls;
|
||||
use crate::cert::{verify_certificate, CertificateWitness};
|
||||
use crate::header::{outranks, HeaderWitness};
|
||||
use crate::mmr::{self, HistoryLeaf};
|
||||
use crate::ring::{slot_of, RingAccess, RingEntry, RING_LEAF_DAA};
|
||||
use crate::keys::KeyAccess;
|
||||
use crate::{vote_key_hash, vote_message, FinParams, FinState, KeyEntry, H, PUBKEY_LEN, SIG_LEN};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One blue block of the chain block's past counted for its key: the chain block itself and its mergeset blues
|
||||
/// (`ChainBlockRecord.mergeset` with `is_blue` on the node).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BlueBlock {
|
||||
pub block_hash: H,
|
||||
pub key_hash: H,
|
||||
pub daa: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChainBlockWitness {
|
||||
pub number: u64,
|
||||
pub block_hash: H,
|
||||
pub daa: u64,
|
||||
pub blues: Vec<BlueBlock>,
|
||||
/// Level 1 (design 5.2): the chain block's own header first, then every mergeset block's (blue and red).
|
||||
/// Empty: level 0, the blues are the prover's word (vetoed on the chain by every full node).
|
||||
#[serde(default)]
|
||||
pub headers: Vec<HeaderWitness>,
|
||||
}
|
||||
|
||||
/// W1: a key reveal with its proof of possession.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Reveal {
|
||||
#[serde(with = "crate::serde_arrays")]
|
||||
pub pubkey: [u8; PUBKEY_LEN],
|
||||
#[serde(with = "crate::serde_arrays")]
|
||||
pub pop: [u8; SIG_LEN],
|
||||
}
|
||||
|
||||
/// 3.6: two votes by one key at one index for different blocks, dated by their carrier.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EvidenceWitness {
|
||||
#[serde(with = "crate::serde_arrays")]
|
||||
pub pubkey: [u8; PUBKEY_LEN],
|
||||
pub index: u64,
|
||||
pub hash_a: H,
|
||||
#[serde(with = "crate::serde_arrays")]
|
||||
pub sig_a: [u8; SIG_LEN],
|
||||
pub hash_b: H,
|
||||
#[serde(with = "crate::serde_arrays")]
|
||||
pub sig_b: [u8; SIG_LEN],
|
||||
/// DAA score of the lowest carrier in the chain block's past (the node's `bans_at`).
|
||||
pub carrier_daa: u64,
|
||||
}
|
||||
|
||||
/// W7: a departure announcement dated by its carrier.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LeaveWitness {
|
||||
pub daa: u64,
|
||||
#[serde(with = "crate::serde_arrays")]
|
||||
pub pubkey: [u8; PUBKEY_LEN],
|
||||
#[serde(with = "crate::serde_arrays")]
|
||||
pub signature: [u8; SIG_LEN],
|
||||
pub carrier_daa: u64,
|
||||
}
|
||||
|
||||
/// What one fold takes beside the ring witnesses.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FoldWitness {
|
||||
pub reveals: Vec<Reveal>,
|
||||
pub evidence: Vec<EvidenceWitness>,
|
||||
pub leaves: Vec<LeaveWitness>,
|
||||
pub certificates: Vec<CertificateWitness>,
|
||||
}
|
||||
|
||||
/// What a fold reports beside the new state.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct FoldReport {
|
||||
pub counted: u64,
|
||||
pub skipped_old: u64,
|
||||
pub expired: u64,
|
||||
pub locks: u64,
|
||||
}
|
||||
|
||||
/// Opens the key's leaf, applies `f` to its entry (a fresh one when the key has no slot), writes it back (an entry
|
||||
/// that holds nothing at `daa` empties its leaf; the slot is never reused).
|
||||
fn touch(state: &mut FinState, keys: &mut dyn KeyAccess, key: &H, daa: u64, f: &mut dyn FnMut(&mut KeyEntry) -> Result<(), String>) -> Result<(), String> {
|
||||
let (slot, entry, siblings) = keys.touch(key, &state.keys_root, state.key_count)?;
|
||||
let mut e = match entry {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
if slot != state.key_count {
|
||||
return Err("a new key must take the next free slot".into());
|
||||
}
|
||||
state.key_count += 1;
|
||||
KeyEntry::new(*key)
|
||||
}
|
||||
};
|
||||
f(&mut e)?;
|
||||
let out = if e.is_empty_at(daa) { None } else { Some(e) };
|
||||
state.keys_root = keys.write(slot, out, &siblings);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Folds chain block `block` into `state`. Errors make the proof impossible (the guest panics on them).
|
||||
pub fn fold_block(state: &mut FinState, params: &FinParams, block: &ChainBlockWitness, ring: &mut dyn RingAccess, keys: &mut dyn KeyAccess, witness: &FoldWitness, bls: &dyn Bls) -> Result<FoldReport, String> {
|
||||
if state.params_hash != params.hash() {
|
||||
return Err("the state was computed under other finality parameters".into());
|
||||
}
|
||||
if state.leaves > 0 && block.number != state.end_number + 1 {
|
||||
return Err(format!("chain block {} does not follow {}", block.number, state.end_number));
|
||||
}
|
||||
if state.leaves == 0 && block.number != state.history_first {
|
||||
return Err(format!("the attestation starts at {} and the first block folded is {}", state.history_first, block.number));
|
||||
}
|
||||
if block.daa < state.end_daa {
|
||||
return Err(format!("chain block {} has DAA score {} below the previous block's {}", block.number, block.daa, state.end_daa));
|
||||
}
|
||||
let mut report = FoldReport::default();
|
||||
let window_start = block.daa.saturating_sub(params.weight_window);
|
||||
|
||||
// 0. level 1: the headers pin the blues (design 5.2); reds ride into the ring uncounted
|
||||
let reds = if block.headers.is_empty() { Vec::new() } else { check_headers(state, block)? };
|
||||
|
||||
// 1. the block's blue blocks into the ring and their keys
|
||||
for b in &block.blues {
|
||||
if b.daa > block.daa {
|
||||
return Err(format!("blue block {} has DAA score {} above its chain block's {}", hex32(&b.block_hash), b.daa, block.daa));
|
||||
}
|
||||
if b.daa <= window_start {
|
||||
report.skipped_old += 1;
|
||||
continue;
|
||||
}
|
||||
let slot = slot_of(b.daa);
|
||||
let (mut entries, siblings) = ring.open(slot, &state.ring_root)?;
|
||||
let e = RingEntry { daa: b.daa, block_hash: b.block_hash, key_hash: b.key_hash, blue: true };
|
||||
if entries.iter().any(|x| x.block_hash == b.block_hash) {
|
||||
return Err(format!("blue block {} counted twice", hex32(&b.block_hash)));
|
||||
}
|
||||
let pos = entries.binary_search(&e).unwrap_err();
|
||||
entries.insert(pos, e);
|
||||
state.ring_root = ring.write(slot, entries, &siblings);
|
||||
touch(state, keys, &b.key_hash, block.daa, &mut |e| {
|
||||
e.blocks += 1;
|
||||
Ok(())
|
||||
})?;
|
||||
report.counted += 1;
|
||||
}
|
||||
for r in &reds {
|
||||
if r.daa <= window_start {
|
||||
continue;
|
||||
}
|
||||
let slot = slot_of(r.daa);
|
||||
let (mut entries, siblings) = ring.open(slot, &state.ring_root)?;
|
||||
if entries.iter().any(|x| x.block_hash == r.block_hash) {
|
||||
return Err(format!("red block {} was in an earlier mergeset", hex32(&r.block_hash)));
|
||||
}
|
||||
let e = RingEntry { daa: r.daa, block_hash: r.block_hash, key_hash: r.key_hash, blue: false };
|
||||
let pos = entries.binary_search(&e).unwrap_err();
|
||||
entries.insert(pos, e);
|
||||
state.ring_root = ring.write(slot, entries, &siblings);
|
||||
}
|
||||
|
||||
// 2. blocks that left the window: DAA scores in (old_start, window_start]
|
||||
let old_start = state.end_daa.saturating_sub(params.weight_window);
|
||||
if state.leaves > 0 && window_start > old_start {
|
||||
let first_slot_daa = (old_start + 1) / RING_LEAF_DAA * RING_LEAF_DAA;
|
||||
let mut d = first_slot_daa;
|
||||
while d <= window_start {
|
||||
let slot = slot_of(d);
|
||||
let (entries, siblings) = ring.open(slot, &state.ring_root)?;
|
||||
let (gone, kept): (Vec<RingEntry>, Vec<RingEntry>) = entries.into_iter().partition(|e| e.daa <= window_start);
|
||||
if !gone.is_empty() {
|
||||
for g in gone.iter().filter(|g| g.blue) {
|
||||
touch(state, keys, &g.key_hash, block.daa, &mut |e| {
|
||||
e.blocks = e.blocks.checked_sub(1).ok_or("a key's block count went below zero")?;
|
||||
Ok(())
|
||||
})?;
|
||||
report.expired += 1;
|
||||
}
|
||||
state.ring_root = ring.write(slot, kept, &siblings);
|
||||
}
|
||||
d += RING_LEAF_DAA;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. reveals, evidence, leaves (W1, 3.6, W7), each signature verified here
|
||||
for r in &witness.reveals {
|
||||
if !bls.verify_one(&r.pubkey, &r.pubkey, crate::DST_POP, &r.pop) {
|
||||
return Err("a key reveal's proof of possession does not verify".into());
|
||||
}
|
||||
let kh = vote_key_hash(&r.pubkey);
|
||||
touch(state, keys, &kh, block.daa, &mut |e| {
|
||||
if e.revealed() && e.pubkey != r.pubkey {
|
||||
return Err("a key reveal names another key for a revealed hash".into());
|
||||
}
|
||||
e.pubkey = r.pubkey;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
for e in &witness.evidence {
|
||||
if e.hash_a == e.hash_b {
|
||||
return Err("evidence names one block twice".into());
|
||||
}
|
||||
if e.carrier_daa > block.daa {
|
||||
return Err("evidence carried after this chain block".into());
|
||||
}
|
||||
let ok_a = bls.verify_one(&e.pubkey, &vote_message(¶ms.chain_id, e.index, &e.hash_a), crate::DST_VOTE, &e.sig_a);
|
||||
let ok_b = bls.verify_one(&e.pubkey, &vote_message(¶ms.chain_id, e.index, &e.hash_b), crate::DST_VOTE, &e.sig_b);
|
||||
if !(ok_a && ok_b) {
|
||||
return Err("an equivocation vote does not verify".into());
|
||||
}
|
||||
let kh = vote_key_hash(&e.pubkey);
|
||||
let until = e.carrier_daa.saturating_add(params.equivocation_ban);
|
||||
touch(state, keys, &kh, block.daa, &mut |k| {
|
||||
k.ban_until = k.ban_until.max(until);
|
||||
if !k.revealed() {
|
||||
k.pubkey = e.pubkey;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
for l in &witness.leaves {
|
||||
if l.carrier_daa > block.daa {
|
||||
return Err("a leave carried after this chain block".into());
|
||||
}
|
||||
if !bls.verify_one(&l.pubkey, &crate::leave_message(¶ms.chain_id, l.daa), crate::DST_LEAVE, &l.signature) {
|
||||
return Err("a leave does not verify".into());
|
||||
}
|
||||
let kh = vote_key_hash(&l.pubkey);
|
||||
let (from, until) = (l.carrier_daa.saturating_add(params.leave_delay), l.carrier_daa.saturating_add(params.weight_window));
|
||||
touch(state, keys, &kh, block.daa, &mut |k| {
|
||||
if k.leave_until == 0 {
|
||||
k.leave_from = from;
|
||||
k.leave_until = until;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
|
||||
// 4. the certificates that landed with this block, against the history as it stood before it (every witness
|
||||
// builder, the tracker, the host and the harness, makes the table proofs before the fold)
|
||||
for c in &witness.certificates {
|
||||
verify_certificate(state, params, c, bls)?;
|
||||
report.locks += 1;
|
||||
}
|
||||
// slots whose entries emptied before this fold's touches stay empty; nothing to retain
|
||||
|
||||
// 5. staleness: a window without a lock (the frozen table never expires in the proof, design 4.4)
|
||||
if state.lock.index > 0 && block.daa >= state.lock.daa.saturating_add(params.weight_window) {
|
||||
state.stale = true;
|
||||
}
|
||||
|
||||
// 6. the block's own entry in the history, with the table after it
|
||||
state.end_daa = block.daa;
|
||||
state.end_number = block.number;
|
||||
state.end_hash = block.block_hash;
|
||||
if let Some(h) = block.headers.first() {
|
||||
state.end_blue_score = h.blue_score;
|
||||
state.end_blue_work = h.blue_work.clone();
|
||||
}
|
||||
let leaf = HistoryLeaf { number: block.number, block_hash: block.block_hash, daa: block.daa, table_root: state.table_root(), keys_root: state.keys_root, key_count: state.key_count };
|
||||
mmr::append(&mut state.peaks, &mut state.leaves, leaf.hash());
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
pub fn hex32(h: &H) -> String {
|
||||
let mut s = String::with_capacity(64);
|
||||
for b in h {
|
||||
s.push_str(&format!("{b:02x}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Level 1: every header hashes to the hash it stands for; the chain block's header names this block, its DAA
|
||||
/// score and the selected parent (the previous chain block, by blue work then hash among its direct parents);
|
||||
/// every blue of the witness is a header's block with that header's key and DAA score; the blue count equals
|
||||
/// the blue score step; every mergeset block is reached from the chain block by parent links through mergeset
|
||||
/// blocks. Returns the reds (uncounted, for the ring).
|
||||
fn check_headers(state: &FinState, block: &ChainBlockWitness) -> Result<Vec<BlueBlock>, String> {
|
||||
let own = &block.headers[0];
|
||||
let own_hash = own.hash();
|
||||
if own_hash != block.block_hash {
|
||||
return Err(format!("the chain block's header hashes to {}, not {}", hex32(&own_hash), hex32(&block.block_hash)));
|
||||
}
|
||||
if own.daa_score != block.daa {
|
||||
return Err("the chain block's header carries another DAA score".into());
|
||||
}
|
||||
if !own.blue {
|
||||
return Err("the chain block is blue in its own mergeset".into());
|
||||
}
|
||||
// the mergeset headers by hash
|
||||
let mut by_hash: std::collections::BTreeMap<H, &HeaderWitness> = std::collections::BTreeMap::new();
|
||||
for h in &block.headers[1..] {
|
||||
let hh = h.hash();
|
||||
if hh == block.block_hash || by_hash.insert(hh, h).is_some() {
|
||||
return Err(format!("mergeset header {} listed twice", hex32(&hh)));
|
||||
}
|
||||
}
|
||||
// the selected parent: the previous chain block, and the greatest direct parent by (blue work, hash)
|
||||
if state.leaves > 0 {
|
||||
let parents = own.direct_parents();
|
||||
if !parents.contains(&state.end_hash) {
|
||||
return Err("the previous chain block is not a direct parent of this one".into());
|
||||
}
|
||||
for p in parents {
|
||||
if *p == state.end_hash {
|
||||
continue;
|
||||
}
|
||||
let Some(ph) = by_hash.get(p) else { return Err(format!("direct parent {} is neither the selected parent nor in the mergeset", hex32(p))) };
|
||||
if outranks(&ph.blue_work, p, &state.end_blue_work, &state.end_hash) {
|
||||
return Err(format!("direct parent {} outranks the selected parent", hex32(p)));
|
||||
}
|
||||
}
|
||||
if own.blue_score != state.end_blue_score + block.blues.len() as u64 {
|
||||
return Err(format!("blue score {} is not the selected parent's {} plus the {} blues counted", own.blue_score, state.end_blue_score, block.blues.len()));
|
||||
}
|
||||
}
|
||||
// every blue is a header's block with that header's key and DAA score (the chain block itself first)
|
||||
let mut seen_blue = std::collections::BTreeSet::new();
|
||||
for b in &block.blues {
|
||||
let h = if b.block_hash == block.block_hash { own } else { by_hash.get(&b.block_hash).copied().ok_or_else(|| format!("blue block {} has no header in the witness", hex32(&b.block_hash)))? };
|
||||
if !h.blue || h.vote_key_hash != b.key_hash || h.daa_score != b.daa {
|
||||
return Err(format!("blue block {} differs from its header (colour, key or DAA score)", hex32(&b.block_hash)));
|
||||
}
|
||||
if !seen_blue.insert(b.block_hash) {
|
||||
return Err(format!("blue block {} listed twice", hex32(&b.block_hash)));
|
||||
}
|
||||
}
|
||||
let mut reds = Vec::new();
|
||||
for (hh, h) in &by_hash {
|
||||
if h.blue {
|
||||
if !seen_blue.contains(hh) {
|
||||
return Err(format!("mergeset block {} is blue in its header and not counted", hex32(hh)));
|
||||
}
|
||||
} else {
|
||||
reds.push(BlueBlock { block_hash: *hh, key_hash: h.vote_key_hash, daa: h.daa_score });
|
||||
}
|
||||
}
|
||||
// reachability: from the chain block's direct parents through mergeset blocks' direct parents
|
||||
let mut reached: std::collections::BTreeSet<H> = std::collections::BTreeSet::new();
|
||||
let mut queue: Vec<H> = own.direct_parents().to_vec();
|
||||
while let Some(p) = queue.pop() {
|
||||
if p == state.end_hash || !reached.insert(p) {
|
||||
continue;
|
||||
}
|
||||
if let Some(h) = by_hash.get(&p) {
|
||||
queue.extend(h.direct_parents().iter().copied());
|
||||
}
|
||||
}
|
||||
for hh in by_hash.keys() {
|
||||
if !reached.contains(hh) {
|
||||
return Err(format!("mergeset block {} is not reached from the chain block by parent links", hex32(hh)));
|
||||
}
|
||||
}
|
||||
Ok(reds)
|
||||
}
|
||||
75
proving/igneum-prove/fin/src/header.rs
Normal file
75
proving/igneum-prove/fin/src/header.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
//! Level 1 of the blue colouring (docs/design/finality-in-proof.md 5.2): the fold takes every mergeset block's
|
||||
//! header, recomputes the chain's block hash (BLAKE2b-256 keyed `BlockHash` over the fields in the order of the
|
||||
//! fork's `consensus/core/src/hashing/header.rs`), and checks what a header pins: the block's vote key hash, its
|
||||
//! DAA score, the chain block's blue score against the previous one (the blue count), the selected parent (the
|
||||
//! direct parent of greatest blue work, hash as the tie-break), and that every mergeset block is reached from the
|
||||
//! chain block by parent links through mergeset blocks. What a lie can still do: swap the colours of two mergeset
|
||||
//! blocks of one chain block (the blue count holds, the ring refuses any block seen before).
|
||||
|
||||
use crate::H;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HeaderWitness {
|
||||
pub version: u16,
|
||||
/// Parents by level, level 0 first (the direct parents).
|
||||
pub parents_by_level: Vec<Vec<H>>,
|
||||
pub hash_merkle_root: H,
|
||||
pub accepted_id_merkle_root: H,
|
||||
pub utxo_commitment: H,
|
||||
pub timestamp: u64,
|
||||
pub bits: u32,
|
||||
pub nonce: u64,
|
||||
pub daa_score: u64,
|
||||
/// Big-endian bytes of the 192-bit blue work with leading zeros stripped (what the hash writes).
|
||||
pub blue_work: Vec<u8>,
|
||||
pub blue_score: u64,
|
||||
pub pruning_point: H,
|
||||
pub vote_key_hash: H,
|
||||
/// Blue in its chain block's mergeset (counted) or red (kept in the ring uncounted).
|
||||
pub blue: bool,
|
||||
}
|
||||
|
||||
impl HeaderWitness {
|
||||
/// The fork's `hash_override_nonce_time` with the header's own nonce and timestamp.
|
||||
pub fn hash(&self) -> H {
|
||||
let mut st = blake2b_simd::Params::new().hash_length(32).key(b"BlockHash").to_state();
|
||||
st.update(&self.version.to_le_bytes());
|
||||
st.update(&(self.parents_by_level.len() as u64).to_le_bytes());
|
||||
for level in &self.parents_by_level {
|
||||
st.update(&(level.len() as u64).to_le_bytes());
|
||||
for p in level {
|
||||
st.update(p);
|
||||
}
|
||||
}
|
||||
st.update(&self.hash_merkle_root);
|
||||
st.update(&self.accepted_id_merkle_root);
|
||||
st.update(&self.utxo_commitment);
|
||||
st.update(&self.timestamp.to_le_bytes());
|
||||
st.update(&self.bits.to_le_bytes());
|
||||
st.update(&self.nonce.to_le_bytes());
|
||||
st.update(&self.daa_score.to_le_bytes());
|
||||
st.update(&self.blue_score.to_le_bytes());
|
||||
st.update(&(self.blue_work.len() as u64).to_le_bytes());
|
||||
st.update(&self.blue_work);
|
||||
st.update(&self.pruning_point);
|
||||
st.update(&self.vote_key_hash);
|
||||
let mut h = [0u8; 32];
|
||||
h.copy_from_slice(st.finalize().as_bytes());
|
||||
h
|
||||
}
|
||||
|
||||
pub fn direct_parents(&self) -> &[H] {
|
||||
self.parents_by_level.first().map(|v| v.as_slice()).unwrap_or(&[])
|
||||
}
|
||||
}
|
||||
|
||||
/// Greater blue work wins; equal work, the greater hash (GHOSTDAG's `find_selected_parent` order on
|
||||
/// `SortableBlock`: blue work then hash).
|
||||
pub fn outranks(work_a: &[u8], hash_a: &H, work_b: &[u8], hash_b: &H) -> bool {
|
||||
match work_a.len().cmp(&work_b.len()).then_with(|| work_a.cmp(work_b)) {
|
||||
std::cmp::Ordering::Greater => true,
|
||||
std::cmp::Ordering::Less => false,
|
||||
std::cmp::Ordering::Equal => hash_a > hash_b,
|
||||
}
|
||||
}
|
||||
260
proving/igneum-prove/fin/src/keys.rs
Normal file
260
proving/igneum-prove/fin/src/keys.rs
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
//! The key table as a Merkle tree (docs/design/finality-in-proof.md section 2, the scale form): 2^KEY_DEPTH leaf
|
||||
//! slots, a key takes the next free slot the first time it is seen and keeps it until its entry empties (a slot is
|
||||
//! never reused), so the non-empty leaves are a prefix `0..key_count` and the fold touches one path per key it
|
||||
//! changes instead of hashing the whole table at every block. The certificate step takes every entry once and
|
||||
//! rebuilds the root over that dense prefix (about 2N hashes at N keys), sorts by key hash for the canonical voter
|
||||
//! list of spec 3.10 C3 and refuses a key that appears twice.
|
||||
//!
|
||||
//! What a lying witness can do: insert a key a second time at a fresh slot (the guest cannot know the key has a
|
||||
//! slot already). The split is refused at the next certificate (duplicate key hashes in the full table), so no
|
||||
//! certificate verifies while it stands, and the native compare vetoes the record on the chain.
|
||||
|
||||
use crate::{sha256, KeyEntry, H, ZERO};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub const KEY_DEPTH: usize = 24;
|
||||
|
||||
pub fn leaf_hash(entry: Option<&KeyEntry>) -> H {
|
||||
match entry {
|
||||
None => ZERO,
|
||||
Some(e) => {
|
||||
let mut buf = Vec::with_capacity(1 + KeyEntry::LEN);
|
||||
buf.push(5u8);
|
||||
e.write(&mut buf);
|
||||
sha256(&[&buf])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn node_hash(left: &H, right: &H) -> H {
|
||||
sha256(&[&[6u8], left, right])
|
||||
}
|
||||
|
||||
pub fn defaults() -> Vec<H> {
|
||||
let mut d = Vec::with_capacity(KEY_DEPTH + 1);
|
||||
d.push(ZERO);
|
||||
for l in 1..=KEY_DEPTH {
|
||||
let below = d[l - 1];
|
||||
d.push(node_hash(&below, &below));
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
pub fn empty_root() -> H {
|
||||
defaults()[KEY_DEPTH]
|
||||
}
|
||||
|
||||
pub fn root_from_path(slot: u64, leaf: H, siblings: &[H]) -> H {
|
||||
assert_eq!(siblings.len(), KEY_DEPTH, "a key path has {KEY_DEPTH} siblings");
|
||||
let mut h = leaf;
|
||||
let mut idx = slot;
|
||||
for s in siblings {
|
||||
h = if idx & 1 == 0 { node_hash(&h, s) } else { node_hash(s, &h) };
|
||||
idx >>= 1;
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// The root over the dense prefix: `entries` in slot order (every non-empty leaf below `key_count`), empty slots
|
||||
/// between them the zero leaf, everything from `key_count` on the default subtrees.
|
||||
pub fn dense_root(entries: &[(u64, KeyEntry)], key_count: u64) -> Result<H, String> {
|
||||
let d = defaults();
|
||||
let mut level: Vec<H> = Vec::with_capacity(key_count as usize);
|
||||
let mut next_slot = 0u64;
|
||||
for (slot, e) in entries {
|
||||
if *slot < next_slot || *slot >= key_count {
|
||||
return Err(format!("key table entries out of order or past key_count ({slot}, {key_count})"));
|
||||
}
|
||||
while next_slot < *slot {
|
||||
level.push(ZERO);
|
||||
next_slot += 1;
|
||||
}
|
||||
level.push(leaf_hash(Some(e)));
|
||||
next_slot += 1;
|
||||
}
|
||||
while next_slot < key_count {
|
||||
level.push(ZERO);
|
||||
next_slot += 1;
|
||||
}
|
||||
for l in 0..KEY_DEPTH {
|
||||
if level.is_empty() {
|
||||
return Ok(d[KEY_DEPTH]);
|
||||
}
|
||||
let mut next = Vec::with_capacity(level.len().div_ceil(2));
|
||||
for i in (0..level.len()).step_by(2) {
|
||||
let r = level.get(i + 1).copied().unwrap_or(d[l]);
|
||||
next.push(node_hash(&level[i], &r));
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
Ok(level[0])
|
||||
}
|
||||
|
||||
/// The canonical voter order: by key hash, no key twice.
|
||||
pub fn canonical(entries: &[(u64, KeyEntry)]) -> Result<Vec<KeyEntry>, String> {
|
||||
let mut v: Vec<KeyEntry> = entries.iter().map(|(_, e)| e.clone()).collect();
|
||||
v.sort_by(|a, b| a.key_hash.cmp(&b.key_hash));
|
||||
if v.windows(2).any(|p| p[0].key_hash == p[1].key_hash) {
|
||||
return Err("a key appears twice in the table".into());
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// One key leaf opened for the guest: the slot the key sits at (or the next free slot when absent), its entry and
|
||||
/// the siblings as they stand at that moment of the fold.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct KeyLeafWitness {
|
||||
pub slot: u64,
|
||||
pub entry: Option<KeyEntry>,
|
||||
pub siblings: Vec<H>,
|
||||
}
|
||||
|
||||
pub trait KeyAccess {
|
||||
/// Opens the leaf of `key`: its slot, its entry (None when the key has no slot: the slot is then `key_count`,
|
||||
/// the next free one) and the siblings, checked against `root`.
|
||||
fn touch(&mut self, key: &H, root: &H, key_count: u64) -> Result<(u64, Option<KeyEntry>, Vec<H>), String>;
|
||||
/// Writes `entry` (None empties the leaf) to `slot` and returns the new root.
|
||||
fn write(&mut self, slot: u64, entry: Option<KeyEntry>, siblings: &[H]) -> H;
|
||||
}
|
||||
|
||||
/// The guest's table: a queue of witnesses.
|
||||
pub struct WitnessKeys {
|
||||
pub witnesses: std::collections::VecDeque<KeyLeafWitness>,
|
||||
}
|
||||
|
||||
impl WitnessKeys {
|
||||
pub fn new(witnesses: Vec<KeyLeafWitness>) -> Self {
|
||||
Self { witnesses: witnesses.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyAccess for WitnessKeys {
|
||||
fn touch(&mut self, key: &H, root: &H, key_count: u64) -> Result<(u64, Option<KeyEntry>, Vec<H>), String> {
|
||||
let w = self.witnesses.pop_front().ok_or("key witness missing")?;
|
||||
match &w.entry {
|
||||
Some(e) => {
|
||||
if e.key_hash != *key || w.slot >= key_count {
|
||||
return Err("key witness names another key or a slot past the table".into());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if w.slot != key_count {
|
||||
return Err(format!("a new key takes slot {key_count}, the witness names {}", w.slot));
|
||||
}
|
||||
}
|
||||
}
|
||||
if root_from_path(w.slot, leaf_hash(w.entry.as_ref()), &w.siblings) != *root {
|
||||
return Err("key witness does not open the table root".into());
|
||||
}
|
||||
Ok((w.slot, w.entry, w.siblings))
|
||||
}
|
||||
|
||||
fn write(&mut self, slot: u64, entry: Option<KeyEntry>, siblings: &[H]) -> H {
|
||||
root_from_path(slot, leaf_hash(entry.as_ref()), siblings)
|
||||
}
|
||||
}
|
||||
|
||||
/// The native table (the tracker, the host, the tests): every entry by slot, the key index, a sparse node map,
|
||||
/// and the witnesses recorded for the guest.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SparseKeys {
|
||||
defaults: Vec<H>,
|
||||
nodes: HashMap<(u8, u64), H>,
|
||||
entries: Vec<Option<KeyEntry>>,
|
||||
index: HashMap<H, u64>,
|
||||
pub recorded: Vec<KeyLeafWitness>,
|
||||
}
|
||||
|
||||
impl Default for SparseKeys {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SparseKeys {
|
||||
pub fn new() -> Self {
|
||||
Self { defaults: defaults(), nodes: HashMap::new(), entries: Vec::new(), index: HashMap::new(), recorded: Vec::new() }
|
||||
}
|
||||
|
||||
fn node(&self, level: u8, idx: u64) -> H {
|
||||
self.nodes.get(&(level, idx)).copied().unwrap_or(self.defaults[level as usize])
|
||||
}
|
||||
|
||||
pub fn root(&self) -> H {
|
||||
self.node(KEY_DEPTH as u8, 0)
|
||||
}
|
||||
|
||||
pub fn key_count(&self) -> u64 {
|
||||
self.entries.len() as u64
|
||||
}
|
||||
|
||||
pub fn siblings(&self, slot: u64) -> Vec<H> {
|
||||
let mut idx = slot;
|
||||
let mut out = Vec::with_capacity(KEY_DEPTH);
|
||||
for level in 0..KEY_DEPTH as u8 {
|
||||
out.push(self.node(level, idx ^ 1));
|
||||
idx >>= 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &H) -> Option<&KeyEntry> {
|
||||
self.index.get(key).and_then(|&s| self.entries[s as usize].as_ref())
|
||||
}
|
||||
|
||||
/// Every non-empty entry with its slot, slot order (the certificate step's table).
|
||||
pub fn all_entries(&self) -> Vec<(u64, KeyEntry)> {
|
||||
self.entries.iter().enumerate().filter_map(|(i, e)| e.as_ref().map(|e| (i as u64, e.clone()))).collect()
|
||||
}
|
||||
|
||||
pub fn set(&mut self, slot: u64, entry: Option<KeyEntry>) {
|
||||
if slot as usize >= self.entries.len() {
|
||||
self.entries.resize(slot as usize + 1, None);
|
||||
}
|
||||
if let Some(old) = &self.entries[slot as usize] {
|
||||
self.index.remove(&old.key_hash);
|
||||
}
|
||||
if let Some(e) = &entry {
|
||||
self.index.insert(e.key_hash, slot);
|
||||
}
|
||||
let mut h = leaf_hash(entry.as_ref());
|
||||
self.entries[slot as usize] = entry;
|
||||
let mut idx = slot;
|
||||
for level in 0..=KEY_DEPTH as u8 {
|
||||
if h == self.defaults[level as usize] {
|
||||
self.nodes.remove(&(level, idx));
|
||||
} else {
|
||||
self.nodes.insert((level, idx), h);
|
||||
}
|
||||
if level == KEY_DEPTH as u8 {
|
||||
break;
|
||||
}
|
||||
let sib = self.node(level, idx ^ 1);
|
||||
h = if idx & 1 == 0 { node_hash(&h, &sib) } else { node_hash(&sib, &h) };
|
||||
idx >>= 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_witnesses(&mut self) -> Vec<KeyLeafWitness> {
|
||||
std::mem::take(&mut self.recorded)
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyAccess for SparseKeys {
|
||||
fn touch(&mut self, key: &H, root: &H, key_count: u64) -> Result<(u64, Option<KeyEntry>, Vec<H>), String> {
|
||||
if self.root() != *root || self.key_count() != key_count {
|
||||
return Err("the native key table differs from the state's".into());
|
||||
}
|
||||
let slot = self.index.get(key).copied().unwrap_or(key_count);
|
||||
let entry = if slot < key_count { self.entries[slot as usize].clone() } else { None };
|
||||
let siblings = self.siblings(slot);
|
||||
self.recorded.push(KeyLeafWitness { slot, entry: entry.clone(), siblings: siblings.clone() });
|
||||
Ok((slot, entry, siblings))
|
||||
}
|
||||
|
||||
fn write(&mut self, slot: u64, entry: Option<KeyEntry>, _siblings: &[H]) -> H {
|
||||
self.set(slot, entry);
|
||||
self.root()
|
||||
}
|
||||
}
|
||||
348
proving/igneum-prove/fin/src/lib.rs
Normal file
348
proving/igneum-prove/fin/src/lib.rs
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
//! Finality carried inside the segment proof (`docs/design/finality-in-proof.md`).
|
||||
//!
|
||||
//! The weight table of spec 03 W2, as the node computes it (`compute_weights` in the fork's
|
||||
//! `consensus/src/processes/finality.rs`), kept incrementally by the aggregator guest: one chain block per fold,
|
||||
//! the block's blue blocks added to their keys, the blocks that left the 30-day window taken off, the table
|
||||
//! committed by hash, every chain block appended to a history MMR with its table commitment, and a lock
|
||||
//! certificate verified against the table at its own checkpoint block. The same functions run in the guest, on
|
||||
//! the host natively, and in the node's tracker (which feeds the witnesses and compares the result: the veto).
|
||||
//!
|
||||
//! Nothing here depends on kaspa types: bytes in, bytes out, so the crate compiles for the zkVM target.
|
||||
|
||||
pub mod bls;
|
||||
pub mod cert;
|
||||
pub mod fold;
|
||||
pub mod header;
|
||||
pub mod keys;
|
||||
pub mod mmr;
|
||||
pub mod ring;
|
||||
pub mod tracker;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub type H = [u8; 32];
|
||||
pub const PUBKEY_LEN: usize = 48;
|
||||
pub const SIG_LEN: usize = 96;
|
||||
pub const ZERO: H = [0u8; 32];
|
||||
|
||||
/// The vote tag of spec 3.10 C2 (the fork's `DST_VOTE`).
|
||||
pub const DST_VOTE: &[u8] = b"IGNEUM_VOTE_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_";
|
||||
/// The proof-of-possession tag (W1).
|
||||
pub const DST_POP: &[u8] = b"IGNEUM_POP_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_";
|
||||
/// The leave tag (W7).
|
||||
pub const DST_LEAVE: &[u8] = b"IGNEUM_LEAVE_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_";
|
||||
|
||||
pub fn sha256(parts: &[&[u8]]) -> H {
|
||||
let mut h = Sha256::new();
|
||||
for p in parts {
|
||||
h.update(p);
|
||||
}
|
||||
h.finalize().into()
|
||||
}
|
||||
|
||||
/// W1: `vote_key_hash` = BLAKE2b-256 keyed `IgneumVoteKeyHash` over the 48-byte compressed G1 key (the fork's
|
||||
/// `kaspa_hashes::VoteKeyHash`).
|
||||
pub fn vote_key_hash(pubkey: &[u8; PUBKEY_LEN]) -> H {
|
||||
let out = blake2b_simd::Params::new().hash_length(32).key(b"IgneumVoteKeyHash").hash(pubkey);
|
||||
let mut h = [0u8; 32];
|
||||
h.copy_from_slice(out.as_bytes());
|
||||
h
|
||||
}
|
||||
|
||||
/// C2: `"igneum-vote-v1/" || chain_id || 0 || index_le || checkpoint`.
|
||||
pub fn vote_message(chain_id: &str, index: u64, checkpoint: &H) -> Vec<u8> {
|
||||
let mut m = Vec::with_capacity(16 + chain_id.len() + 8 + 32);
|
||||
m.extend_from_slice(b"igneum-vote-v1/");
|
||||
m.extend_from_slice(chain_id.as_bytes());
|
||||
m.push(0);
|
||||
m.extend_from_slice(&index.to_le_bytes());
|
||||
m.extend_from_slice(checkpoint);
|
||||
m
|
||||
}
|
||||
|
||||
/// W7: `"igneum-leave-v1/" || chain_id || 0 || daa_le`.
|
||||
pub fn leave_message(chain_id: &str, daa: u64) -> Vec<u8> {
|
||||
let mut m = Vec::with_capacity(17 + chain_id.len() + 8);
|
||||
m.extend_from_slice(b"igneum-leave-v1/");
|
||||
m.extend_from_slice(chain_id.as_bytes());
|
||||
m.push(0);
|
||||
m.extend_from_slice(&daa.to_le_bytes());
|
||||
m
|
||||
}
|
||||
|
||||
/// The finality parameters the table is computed under (spec 03, `FinalityParams` on the node). Committed into
|
||||
/// every table root, so a proof made under other parameters commits to another table.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FinParams {
|
||||
/// The network name the votes are domain-separated with (`igneum-devnet`, `igneum-devnet-7`).
|
||||
pub chain_id: String,
|
||||
/// W2: the trailing weight window in DAA seconds (2,592,000 mainnet, 7,200 devnet).
|
||||
pub weight_window: u64,
|
||||
/// W3: a key under this many blue blocks in the window is no voter.
|
||||
pub dust: u64,
|
||||
/// C5: no certificate below this DAA score.
|
||||
pub min_daa: u64,
|
||||
/// W7: a leave takes effect this many DAA seconds after its carrier.
|
||||
pub leave_delay: u64,
|
||||
/// 3.6: an equivocating key is stripped for this many DAA seconds after the evidence's carrier.
|
||||
pub equivocation_ban: u64,
|
||||
}
|
||||
|
||||
impl FinParams {
|
||||
pub fn hash(&self) -> H {
|
||||
sha256(&[
|
||||
b"igneum-fin-params-v1",
|
||||
&(self.chain_id.len() as u64).to_le_bytes(),
|
||||
self.chain_id.as_bytes(),
|
||||
&self.weight_window.to_le_bytes(),
|
||||
&self.dust.to_le_bytes(),
|
||||
&self.min_daa.to_le_bytes(),
|
||||
&self.leave_delay.to_le_bytes(),
|
||||
&self.equivocation_ban.to_le_bytes(),
|
||||
])
|
||||
}
|
||||
|
||||
/// Q3's floor, inclusive: `3 x signed >= 2 x total` (the node's `FinalityParams::floor_met`).
|
||||
pub fn floor_met(signed: u64, total: u64) -> bool {
|
||||
(signed as u128) * 3 >= 2 * (total as u128)
|
||||
}
|
||||
|
||||
pub fn devnet(chain_id: &str) -> Self {
|
||||
Self { chain_id: chain_id.into(), weight_window: 7_200, dust: 5, min_daa: 7_200, leave_delay: 3_600, equivocation_ban: 7_200 }
|
||||
}
|
||||
|
||||
pub fn mainnet(chain_id: &str) -> Self {
|
||||
Self { chain_id: chain_id.into(), weight_window: 2_592_000, dust: 100, min_daa: 2_592_000, leave_delay: 3_600, equivocation_ban: 2_592_000 }
|
||||
}
|
||||
}
|
||||
|
||||
/// One key of the table: its blue blocks in the window, its revealed public key (zero until revealed), the end
|
||||
/// of its ban (3.6) and the span of its leave (W7). 112 bytes serialised.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct KeyEntry {
|
||||
pub key_hash: H,
|
||||
#[serde(with = "serde_arrays")]
|
||||
pub pubkey: [u8; PUBKEY_LEN],
|
||||
pub blocks: u64,
|
||||
pub ban_until: u64,
|
||||
pub leave_from: u64,
|
||||
pub leave_until: u64,
|
||||
}
|
||||
|
||||
impl KeyEntry {
|
||||
pub const LEN: usize = 32 + PUBKEY_LEN + 8 + 8 + 8 + 8;
|
||||
|
||||
pub fn new(key_hash: H) -> Self {
|
||||
Self { key_hash, pubkey: [0u8; PUBKEY_LEN], blocks: 0, ban_until: 0, leave_from: 0, leave_until: 0 }
|
||||
}
|
||||
|
||||
pub fn write(&self, out: &mut Vec<u8>) {
|
||||
out.extend_from_slice(&self.key_hash);
|
||||
out.extend_from_slice(&self.pubkey);
|
||||
out.extend_from_slice(&self.blocks.to_le_bytes());
|
||||
out.extend_from_slice(&self.ban_until.to_le_bytes());
|
||||
out.extend_from_slice(&self.leave_from.to_le_bytes());
|
||||
out.extend_from_slice(&self.leave_until.to_le_bytes());
|
||||
}
|
||||
|
||||
pub fn revealed(&self) -> bool {
|
||||
self.pubkey != [0u8; PUBKEY_LEN]
|
||||
}
|
||||
|
||||
/// A voter at DAA score `daa`: above dust, not stripped, not gone (W3, 3.6, W7).
|
||||
pub fn is_voter(&self, daa: u64, dust: u64) -> bool {
|
||||
self.blocks >= dust && daa >= self.ban_until && !self.is_gone(daa)
|
||||
}
|
||||
|
||||
/// W7: the key has left at `daa`.
|
||||
pub fn is_gone(&self, daa: u64) -> bool {
|
||||
self.leave_until > 0 && self.leave_from <= daa && daa < self.leave_until
|
||||
}
|
||||
|
||||
/// An entry that holds nothing any more is dropped from the table at the end of a fold.
|
||||
pub fn is_empty_at(&self, daa: u64) -> bool {
|
||||
self.blocks == 0 && daa >= self.ban_until && daa >= self.leave_until
|
||||
}
|
||||
}
|
||||
|
||||
/// The canonical voter list at `daa` (positions index a certificate's bitmap) and the total weight.
|
||||
pub fn voters_at(keys: &[KeyEntry], daa: u64, dust: u64) -> (Vec<usize>, u64) {
|
||||
let mut positions = Vec::new();
|
||||
let mut total = 0u64;
|
||||
for (i, k) in keys.iter().enumerate() {
|
||||
if k.is_voter(daa, dust) {
|
||||
positions.push(i);
|
||||
total += k.blocks;
|
||||
}
|
||||
}
|
||||
(positions, total)
|
||||
}
|
||||
|
||||
/// Table lookup by key hash (the table is sorted).
|
||||
pub fn find_key(keys: &[KeyEntry], key_hash: &H) -> Result<usize, usize> {
|
||||
keys.binary_search_by(|k| k.key_hash.cmp(key_hash))
|
||||
}
|
||||
|
||||
/// The latest lock the proof chain has verified (zero before the first).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Lock {
|
||||
pub index: u64,
|
||||
pub hash: H,
|
||||
pub number: u64,
|
||||
pub signed: u64,
|
||||
pub total: u64,
|
||||
pub frozen_signed: u64,
|
||||
pub frozen_total: u64,
|
||||
pub daa: u64,
|
||||
}
|
||||
|
||||
/// The carried state: what one fold takes in and gives out. Its commitment is `extension()`, the public values.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FinState {
|
||||
pub params_hash: H,
|
||||
/// DAA score and chain number of the last chain block folded in.
|
||||
pub end_daa: u64,
|
||||
pub end_number: u64,
|
||||
/// Level 1 (design 5.2): the last chain block's hash, blue score and blue work, so the next fold can check
|
||||
/// its selected parent and its blue count against the headers. Zero before level 1 witnesses.
|
||||
#[serde(default)]
|
||||
pub end_hash: H,
|
||||
#[serde(default)]
|
||||
pub end_blue_score: u64,
|
||||
#[serde(default)]
|
||||
pub end_blue_work: Vec<u8>,
|
||||
/// The first chain block this attestation counted from (section 1 of the design: `history_first`).
|
||||
pub history_first: u64,
|
||||
/// The key table's Merkle root and the number of slots taken (`keys`).
|
||||
pub keys_root: H,
|
||||
pub key_count: u64,
|
||||
/// Root of the block ring (`ring`).
|
||||
pub ring_root: H,
|
||||
/// The history MMR's peaks, left to right, and its leaf count (`mmr`).
|
||||
pub peaks: Vec<(u8, H)>,
|
||||
pub leaves: u64,
|
||||
pub lock: Lock,
|
||||
pub stale: bool,
|
||||
}
|
||||
|
||||
/// The fixed extension of the block statement (design section 1).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FinExt {
|
||||
pub fin_version: u16,
|
||||
pub table_root: H,
|
||||
pub history_root: H,
|
||||
pub history_first: u64,
|
||||
pub lock: Lock,
|
||||
pub flags: u16,
|
||||
}
|
||||
|
||||
impl FinExt {
|
||||
pub const VERSION: u16 = 1;
|
||||
pub const LEN: usize = 2 + 32 + 32 + 8 + (8 + 32 + 8 + 8 + 8 + 8 + 8 + 8) + 2;
|
||||
pub const FLAG_STALE: u16 = 1;
|
||||
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let mut v = Vec::with_capacity(Self::LEN);
|
||||
v.extend_from_slice(&self.fin_version.to_be_bytes());
|
||||
v.extend_from_slice(&self.table_root);
|
||||
v.extend_from_slice(&self.history_root);
|
||||
v.extend_from_slice(&self.history_first.to_be_bytes());
|
||||
v.extend_from_slice(&self.lock.index.to_be_bytes());
|
||||
v.extend_from_slice(&self.lock.hash);
|
||||
v.extend_from_slice(&self.lock.number.to_be_bytes());
|
||||
v.extend_from_slice(&self.lock.signed.to_be_bytes());
|
||||
v.extend_from_slice(&self.lock.total.to_be_bytes());
|
||||
v.extend_from_slice(&self.lock.frozen_signed.to_be_bytes());
|
||||
v.extend_from_slice(&self.lock.frozen_total.to_be_bytes());
|
||||
v.extend_from_slice(&self.lock.daa.to_be_bytes());
|
||||
v.extend_from_slice(&self.flags.to_be_bytes());
|
||||
debug_assert_eq!(v.len(), Self::LEN);
|
||||
v
|
||||
}
|
||||
|
||||
pub fn from_bytes(b: &[u8]) -> Option<Self> {
|
||||
if b.len() != Self::LEN {
|
||||
return None;
|
||||
}
|
||||
let u64_at = |i: usize| u64::from_be_bytes(b[i..i + 8].try_into().unwrap());
|
||||
let h_at = |i: usize| -> H { b[i..i + 32].try_into().unwrap() };
|
||||
Some(Self {
|
||||
fin_version: u16::from_be_bytes([b[0], b[1]]),
|
||||
table_root: h_at(2),
|
||||
history_root: h_at(34),
|
||||
history_first: u64_at(66),
|
||||
lock: Lock {
|
||||
index: u64_at(74),
|
||||
hash: h_at(82),
|
||||
number: u64_at(114),
|
||||
signed: u64_at(122),
|
||||
total: u64_at(130),
|
||||
frozen_signed: u64_at(138),
|
||||
frozen_total: u64_at(146),
|
||||
daa: u64_at(154),
|
||||
},
|
||||
flags: u16::from_be_bytes([b[162], b[163]]),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn stale(&self) -> bool {
|
||||
self.flags & Self::FLAG_STALE != 0
|
||||
}
|
||||
}
|
||||
|
||||
impl FinState {
|
||||
/// The empty state rooted at chain block `first_number` (the attestation counts from that block on).
|
||||
pub fn empty(params: &FinParams, first_number: u64) -> Self {
|
||||
Self {
|
||||
params_hash: params.hash(),
|
||||
end_daa: 0,
|
||||
end_number: first_number.saturating_sub(1),
|
||||
end_hash: ZERO,
|
||||
end_blue_score: 0,
|
||||
end_blue_work: Vec::new(),
|
||||
history_first: first_number,
|
||||
keys_root: keys::empty_root(),
|
||||
key_count: 0,
|
||||
ring_root: ring::empty_root(),
|
||||
peaks: Vec::new(),
|
||||
leaves: 0,
|
||||
lock: Lock::default(),
|
||||
stale: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `sha256("igneum-fin-state-v1" || params || end block || keys_root || key_count || ring_root)`.
|
||||
pub fn table_root(&self) -> H {
|
||||
sha256(&[b"igneum-fin-state-v1", &self.params_hash, &self.end_daa.to_le_bytes(), &self.end_number.to_le_bytes(), &self.end_hash, &self.end_blue_score.to_le_bytes(), &(self.end_blue_work.len() as u64).to_le_bytes(), &self.end_blue_work, &self.keys_root, &self.key_count.to_le_bytes(), &self.ring_root])
|
||||
}
|
||||
|
||||
pub fn history_root(&self) -> H {
|
||||
mmr::bag_peaks(&self.peaks)
|
||||
}
|
||||
|
||||
pub fn extension(&self) -> FinExt {
|
||||
FinExt {
|
||||
fin_version: FinExt::VERSION,
|
||||
table_root: self.table_root(),
|
||||
history_root: self.history_root(),
|
||||
history_first: self.history_first,
|
||||
lock: self.lock.clone(),
|
||||
flags: if self.stale { FinExt::FLAG_STALE } else { 0 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `serde` for fixed arrays over 32 bytes.
|
||||
pub mod serde_arrays {
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
pub fn serialize<S: Serializer, const N: usize>(a: &[u8; N], s: S) -> Result<S::Ok, S::Error> {
|
||||
a.as_slice().serialize(s)
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>, const N: usize>(d: D) -> Result<[u8; N], D::Error> {
|
||||
let v: Vec<u8> = Vec::deserialize(d)?;
|
||||
v.try_into().map_err(|v: Vec<u8>| serde::de::Error::custom(format!("expected {N} bytes, got {}", v.len())))
|
||||
}
|
||||
}
|
||||
164
proving/igneum-prove/fin/src/mmr.rs
Normal file
164
proving/igneum-prove/fin/src/mmr.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
//! The history: a Merkle mountain range with one leaf per chain block the attestation covers. A leaf commits the
|
||||
//! block's number, hash, DAA score, the table root after it and its total weight, so a certificate's checkpoint
|
||||
//! is looked up by its leaf and a verifier answers "is block n final" from a leaf and a path.
|
||||
|
||||
use crate::{sha256, H};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HistoryLeaf {
|
||||
pub number: u64,
|
||||
pub block_hash: H,
|
||||
pub daa: u64,
|
||||
/// The table root (keys and ring) after this block was folded.
|
||||
pub table_root: H,
|
||||
/// The key table's root and slot count alone, so a certificate's table witness is checked without the ring.
|
||||
pub keys_root: H,
|
||||
pub key_count: u64,
|
||||
}
|
||||
|
||||
impl HistoryLeaf {
|
||||
pub fn hash(&self) -> H {
|
||||
sha256(&[&[4u8], &self.number.to_le_bytes(), &self.block_hash, &self.daa.to_le_bytes(), &self.table_root, &self.keys_root, &self.key_count.to_le_bytes()])
|
||||
}
|
||||
}
|
||||
|
||||
pub fn merge(left: &H, right: &H) -> H {
|
||||
sha256(&[&[2u8], left, right])
|
||||
}
|
||||
|
||||
/// Bags the peaks right to left into one root; zero for an empty range.
|
||||
pub fn bag_peaks(peaks: &[(u8, H)]) -> H {
|
||||
let Some((_, last)) = peaks.last() else { return [0u8; 32] };
|
||||
let mut root = *last;
|
||||
for (_, p) in peaks[..peaks.len() - 1].iter().rev() {
|
||||
root = sha256(&[&[3u8], p, &root]);
|
||||
}
|
||||
root
|
||||
}
|
||||
|
||||
/// Appends a leaf hash: the new peak of height 0 merges with equal-height peaks to its left.
|
||||
pub fn append(peaks: &mut Vec<(u8, H)>, leaves: &mut u64, leaf: H) {
|
||||
let mut h = 0u8;
|
||||
let mut node = leaf;
|
||||
while let Some(&(ph, p)) = peaks.last() {
|
||||
if ph != h {
|
||||
break;
|
||||
}
|
||||
peaks.pop();
|
||||
node = merge(&p, &node);
|
||||
h += 1;
|
||||
}
|
||||
peaks.push((h, node));
|
||||
*leaves += 1;
|
||||
}
|
||||
|
||||
/// A membership proof: the leaf's position, its siblings inside its mountain (with the side each sits on), and
|
||||
/// the peaks of the range at the size the proof is made for, the leaf's mountain's peak among them.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MmrProof {
|
||||
pub position: u64,
|
||||
/// (sibling, sibling_is_left)
|
||||
pub siblings: Vec<(H, bool)>,
|
||||
pub peaks: Vec<(u8, H)>,
|
||||
pub peak_index: usize,
|
||||
}
|
||||
|
||||
impl MmrProof {
|
||||
/// Checks that `leaf` sits at `position` in a range whose peaks bag to `root` with `leaves` leaves.
|
||||
pub fn verify(&self, leaf: &H, root: &H, leaves: u64) -> bool {
|
||||
if self.position >= leaves || self.peak_index >= self.peaks.len() {
|
||||
return false;
|
||||
}
|
||||
// the peaks' heights must describe exactly `leaves` leaves, strictly decreasing left to right
|
||||
let mut count = 0u64;
|
||||
let mut last: Option<u8> = None;
|
||||
for &(h, _) in &self.peaks {
|
||||
if let Some(l) = last {
|
||||
if l <= h {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
last = Some(h);
|
||||
count += 1u64 << h;
|
||||
}
|
||||
if count != leaves {
|
||||
return false;
|
||||
}
|
||||
// the leaf's mountain: positions before it are the earlier peaks' leaves
|
||||
let before: u64 = self.peaks[..self.peak_index].iter().map(|&(h, _)| 1u64 << h).sum();
|
||||
let height = self.peaks[self.peak_index].0 as usize;
|
||||
if self.position < before || self.position - before >= (1u64 << height) || self.siblings.len() != height {
|
||||
return false;
|
||||
}
|
||||
let mut idx = self.position - before;
|
||||
let mut node = *leaf;
|
||||
for (s, left) in &self.siblings {
|
||||
let expect_left = idx & 1 == 1;
|
||||
if *left != expect_left {
|
||||
return false;
|
||||
}
|
||||
node = if *left { merge(s, &node) } else { merge(&node, s) };
|
||||
idx >>= 1;
|
||||
}
|
||||
node == self.peaks[self.peak_index].1 && bag_peaks(&self.peaks) == *root
|
||||
}
|
||||
}
|
||||
|
||||
/// A full in-memory MMR (the tracker and the tests): every node kept, proofs for any leaf at any later size.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Mmr {
|
||||
/// All leaf hashes in order.
|
||||
pub leaf_hashes: Vec<H>,
|
||||
pub peaks: Vec<(u8, H)>,
|
||||
}
|
||||
|
||||
impl Mmr {
|
||||
pub fn append(&mut self, leaf: H) {
|
||||
let mut n = self.leaf_hashes.len() as u64;
|
||||
append(&mut self.peaks, &mut n, leaf);
|
||||
self.leaf_hashes.push(leaf);
|
||||
}
|
||||
|
||||
pub fn leaves(&self) -> u64 {
|
||||
self.leaf_hashes.len() as u64
|
||||
}
|
||||
|
||||
pub fn root(&self) -> H {
|
||||
bag_peaks(&self.peaks)
|
||||
}
|
||||
|
||||
/// The proof of leaf `position` at the current size (recomputed from the leaves: the tracker makes few).
|
||||
pub fn proof(&self, position: u64) -> Option<MmrProof> {
|
||||
let leaves = self.leaves();
|
||||
if position >= leaves {
|
||||
return None;
|
||||
}
|
||||
let mut before = 0u64;
|
||||
let mut peak_index = 0usize;
|
||||
for (i, &(h, _)) in self.peaks.iter().enumerate() {
|
||||
let size = 1u64 << h;
|
||||
if position < before + size {
|
||||
peak_index = i;
|
||||
break;
|
||||
}
|
||||
before += size;
|
||||
}
|
||||
let height = self.peaks[peak_index].0 as usize;
|
||||
// build the mountain's levels from its leaves
|
||||
let mut level: Vec<H> = self.leaf_hashes[before as usize..(before + (1u64 << height)) as usize].to_vec();
|
||||
let mut idx = (position - before) as usize;
|
||||
let mut siblings = Vec::with_capacity(height);
|
||||
for _ in 0..height {
|
||||
let sib = idx ^ 1;
|
||||
siblings.push((level[sib], sib < idx));
|
||||
let mut next = Vec::with_capacity(level.len() / 2);
|
||||
for pair in level.chunks(2) {
|
||||
next.push(merge(&pair[0], &pair[1]));
|
||||
}
|
||||
level = next;
|
||||
idx >>= 1;
|
||||
}
|
||||
Some(MmrProof { position, siblings, peaks: self.peaks.clone(), peak_index })
|
||||
}
|
||||
}
|
||||
136
proving/igneum-prove/fin/src/ring.rs
Normal file
136
proving/igneum-prove/fin/src/ring.rs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
//! The block ring: a Merkle tree of 2^RING_DEPTH leaves, one per RING_LEAF_DAA DAA seconds, each leaf the
|
||||
//! sorted list of the blue blocks (DAA score, block hash, key hash) that fell in its span. The window is
|
||||
//! 2,592,000 DAA seconds on mainnet; the ring spans 2^18 x 16 = 4,194,304, so a slot is reused only after its
|
||||
//! blocks have aged out. The ring is what lets the fold age blocks out exactly (each expired block's key is
|
||||
//! decremented, as the node's window would drop it) and what refuses a block hash counted twice (level 1 of the
|
||||
//! design's section 5.2).
|
||||
//!
|
||||
//! The guest never holds the ring; it opens a leaf through a witness (the entries and the siblings) and writes it
|
||||
//! back by recomputing the root. The node's tracker holds the ring sparsely and produces the witnesses.
|
||||
|
||||
use crate::{sha256, H, ZERO};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const RING_LEAF_DAA: u64 = 16;
|
||||
pub const RING_DEPTH: usize = 18;
|
||||
pub const RING_SLOTS: u64 = 1 << RING_DEPTH;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub struct RingEntry {
|
||||
pub daa: u64,
|
||||
pub block_hash: H,
|
||||
pub key_hash: H,
|
||||
/// Level 1 (docs/design/finality-in-proof.md 5.2): a red mergeset block is kept in the ring uncounted, so a
|
||||
/// block seen in one chain block's mergeset is refused in any later one, blue or red.
|
||||
#[serde(default = "default_blue")]
|
||||
pub blue: bool,
|
||||
}
|
||||
|
||||
fn default_blue() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl RingEntry {
|
||||
pub fn write(&self, out: &mut Vec<u8>) {
|
||||
out.extend_from_slice(&self.daa.to_le_bytes());
|
||||
out.extend_from_slice(&self.block_hash);
|
||||
out.extend_from_slice(&self.key_hash);
|
||||
out.push(u8::from(self.blue));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn slot_of(daa: u64) -> u32 {
|
||||
((daa / RING_LEAF_DAA) % RING_SLOTS) as u32
|
||||
}
|
||||
|
||||
/// A leaf's hash: zero when empty, else `sha256(0x00 || entries)` over the sorted entries.
|
||||
pub fn leaf_hash(entries: &[RingEntry]) -> H {
|
||||
if entries.is_empty() {
|
||||
return ZERO;
|
||||
}
|
||||
let mut buf = Vec::with_capacity(1 + entries.len() * 73);
|
||||
buf.push(0u8);
|
||||
for e in entries {
|
||||
e.write(&mut buf);
|
||||
}
|
||||
sha256(&[&buf])
|
||||
}
|
||||
|
||||
pub fn node_hash(left: &H, right: &H) -> H {
|
||||
sha256(&[&[1u8], left, right])
|
||||
}
|
||||
|
||||
/// The hash of an all-empty subtree at each level (level 0 = a leaf).
|
||||
pub fn defaults() -> Vec<H> {
|
||||
let mut d = Vec::with_capacity(RING_DEPTH + 1);
|
||||
d.push(ZERO);
|
||||
for l in 1..=RING_DEPTH {
|
||||
let below = d[l - 1];
|
||||
d.push(node_hash(&below, &below));
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
pub fn empty_root() -> H {
|
||||
defaults()[RING_DEPTH]
|
||||
}
|
||||
|
||||
/// The root from a leaf hash and its siblings, bottom up.
|
||||
pub fn root_from_path(slot: u32, leaf: H, siblings: &[H]) -> H {
|
||||
assert_eq!(siblings.len(), RING_DEPTH, "a ring path has {RING_DEPTH} siblings");
|
||||
let mut h = leaf;
|
||||
let mut idx = slot as u64;
|
||||
for s in siblings {
|
||||
h = if idx & 1 == 0 { node_hash(&h, s) } else { node_hash(s, &h) };
|
||||
idx >>= 1;
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// One leaf opened for the guest: its entries and siblings as they stand at that moment of the fold.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RingLeafWitness {
|
||||
pub slot: u32,
|
||||
pub entries: Vec<RingEntry>,
|
||||
pub siblings: Vec<H>,
|
||||
}
|
||||
|
||||
/// How a fold reaches the ring: the guest consumes witnesses in order, the tracker reads its own tree and
|
||||
/// records what it read as the witnesses the guest will need.
|
||||
pub trait RingAccess {
|
||||
/// Opens `slot`: the leaf's entries and siblings, checked against `root`.
|
||||
fn open(&mut self, slot: u32, root: &H) -> Result<(Vec<RingEntry>, Vec<H>), String>;
|
||||
/// Writes `entries` to `slot` and returns the new root (the siblings are those `open` returned).
|
||||
fn write(&mut self, slot: u32, entries: Vec<RingEntry>, siblings: &[H]) -> H;
|
||||
}
|
||||
|
||||
/// The guest's ring: a queue of witnesses.
|
||||
pub struct WitnessRing {
|
||||
pub witnesses: std::collections::VecDeque<RingLeafWitness>,
|
||||
}
|
||||
|
||||
impl WitnessRing {
|
||||
pub fn new(witnesses: Vec<RingLeafWitness>) -> Self {
|
||||
Self { witnesses: witnesses.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl RingAccess for WitnessRing {
|
||||
fn open(&mut self, slot: u32, root: &H) -> Result<(Vec<RingEntry>, Vec<H>), String> {
|
||||
let w = self.witnesses.pop_front().ok_or_else(|| format!("ring witness missing for slot {slot}"))?;
|
||||
if w.slot != slot {
|
||||
return Err(format!("ring witness names slot {} where the fold opens slot {slot}", w.slot));
|
||||
}
|
||||
if !w.entries.windows(2).all(|p| p[0] < p[1]) {
|
||||
return Err(format!("ring leaf {slot} is not sorted"));
|
||||
}
|
||||
if root_from_path(slot, leaf_hash(&w.entries), &w.siblings) != *root {
|
||||
return Err(format!("ring witness for slot {slot} does not open the ring root"));
|
||||
}
|
||||
Ok((w.entries, w.siblings))
|
||||
}
|
||||
|
||||
fn write(&mut self, slot: u32, entries: Vec<RingEntry>, siblings: &[H]) -> H {
|
||||
root_from_path(slot, leaf_hash(&entries), siblings)
|
||||
}
|
||||
}
|
||||
103
proving/igneum-prove/fin/src/tracker.rs
Normal file
103
proving/igneum-prove/fin/src/tracker.rs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
//! The native side: a sparse ring the node's tracker (and the tests) hold in full, which records what it reads so
|
||||
//! the guest's witnesses fall out of a native fold. Memory: one node per distinct hash on a path from a non-empty
|
||||
//! leaf, at most about 2 x 2^18 entries.
|
||||
|
||||
use crate::ring::{defaults, leaf_hash, node_hash, RingAccess, RingEntry, RingLeafWitness, RING_DEPTH};
|
||||
use crate::H;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SparseRing {
|
||||
defaults: Vec<H>,
|
||||
/// (level, index) -> hash, for nodes that differ from the level's default
|
||||
nodes: HashMap<(u8, u64), H>,
|
||||
leaves: HashMap<u32, Vec<RingEntry>>,
|
||||
/// Every leaf opened, in order: the witnesses a guest needs to replay the same fold.
|
||||
pub recorded: Vec<RingLeafWitness>,
|
||||
}
|
||||
|
||||
impl Default for SparseRing {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SparseRing {
|
||||
pub fn new() -> Self {
|
||||
Self { defaults: defaults(), nodes: HashMap::new(), leaves: HashMap::new(), recorded: Vec::new() }
|
||||
}
|
||||
|
||||
fn node(&self, level: u8, index: u64) -> H {
|
||||
self.nodes.get(&(level, index)).copied().unwrap_or(self.defaults[level as usize])
|
||||
}
|
||||
|
||||
pub fn root(&self) -> H {
|
||||
self.node(RING_DEPTH as u8, 0)
|
||||
}
|
||||
|
||||
pub fn siblings(&self, slot: u32) -> Vec<H> {
|
||||
let mut idx = slot as u64;
|
||||
let mut out = Vec::with_capacity(RING_DEPTH);
|
||||
for level in 0..RING_DEPTH as u8 {
|
||||
out.push(self.node(level, idx ^ 1));
|
||||
idx >>= 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn entries(&self, slot: u32) -> Vec<RingEntry> {
|
||||
self.leaves.get(&slot).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn set(&mut self, slot: u32, entries: Vec<RingEntry>) {
|
||||
let mut h = leaf_hash(&entries);
|
||||
if entries.is_empty() {
|
||||
self.leaves.remove(&slot);
|
||||
} else {
|
||||
self.leaves.insert(slot, entries);
|
||||
}
|
||||
let mut idx = slot as u64;
|
||||
for level in 0..=RING_DEPTH as u8 {
|
||||
if h == self.defaults[level as usize] {
|
||||
self.nodes.remove(&(level, idx));
|
||||
} else {
|
||||
self.nodes.insert((level, idx), h);
|
||||
}
|
||||
if level == RING_DEPTH as u8 {
|
||||
break;
|
||||
}
|
||||
let sib = self.node(level, idx ^ 1);
|
||||
h = if idx & 1 == 0 { node_hash(&h, &sib) } else { node_hash(&sib, &h) };
|
||||
idx >>= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes the witnesses recorded so far (the guest's input for the fold just run).
|
||||
pub fn take_witnesses(&mut self) -> Vec<RingLeafWitness> {
|
||||
std::mem::take(&mut self.recorded)
|
||||
}
|
||||
|
||||
/// Every non-empty leaf's entries (the differential test counts the window from them).
|
||||
pub fn all_entries(&self) -> Vec<RingEntry> {
|
||||
let mut v: Vec<RingEntry> = self.leaves.values().flatten().cloned().collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
impl RingAccess for SparseRing {
|
||||
fn open(&mut self, slot: u32, root: &H) -> Result<(Vec<RingEntry>, Vec<H>), String> {
|
||||
if self.root() != *root {
|
||||
return Err("the tracker's ring root differs from the state's".into());
|
||||
}
|
||||
let entries = self.entries(slot);
|
||||
let siblings = self.siblings(slot);
|
||||
self.recorded.push(RingLeafWitness { slot, entries: entries.clone(), siblings: siblings.clone() });
|
||||
Ok((entries, siblings))
|
||||
}
|
||||
|
||||
fn write(&mut self, slot: u32, entries: Vec<RingEntry>, _siblings: &[H]) -> H {
|
||||
self.set(slot, entries);
|
||||
self.root()
|
||||
}
|
||||
}
|
||||
564
proving/igneum-prove/fin/tests/harness.rs
Normal file
564
proving/igneum-prove/fin/tests/harness.rs
Normal file
|
|
@ -0,0 +1,564 @@
|
|||
//! The harness of design section 7: a simulated chain of chain blocks with real BLS keys (blst, the node's
|
||||
//! library), folded natively through the tracker (witnesses recorded) and replayed through the witnessed ring (the
|
||||
//! guest's path); the table checked against a brute-force count of the window at every block; certificates built
|
||||
//! from real votes; the known-failed case first (today's light client accepts a certificate over a voter list the
|
||||
//! node of its choosing hands it); then the proof-carried table refusing the same certificate; then a light client
|
||||
//! answering "final at checkpoint N" from the extension and a history proof alone.
|
||||
|
||||
use blst::min_pk::{AggregateSignature, SecretKey, Signature};
|
||||
use igneum_fin_core::bls::{Bls, ZkBls};
|
||||
use igneum_fin_core::cert::{signer_positions, CertificateWitness, TableAt};
|
||||
use igneum_fin_core::fold::{fold_block, BlueBlock, ChainBlockWitness, FoldWitness, LeaveWitness, Reveal};
|
||||
use igneum_fin_core::mmr::{HistoryLeaf, Mmr};
|
||||
use igneum_fin_core::keys::{canonical, SparseKeys, WitnessKeys};
|
||||
use igneum_fin_core::ring::WitnessRing;
|
||||
use igneum_fin_core::tracker::SparseRing;
|
||||
use igneum_fin_core::{vote_key_hash, vote_message, voters_at, FinExt, FinParams, FinState, KeyEntry, DST_LEAVE, DST_POP, DST_VOTE, H, PUBKEY_LEN, SIG_LEN};
|
||||
use std::collections::HashMap;
|
||||
|
||||
struct Key {
|
||||
sk: SecretKey,
|
||||
pk: [u8; PUBKEY_LEN],
|
||||
hash: H,
|
||||
}
|
||||
|
||||
fn key(label: &str) -> Key {
|
||||
let mut ikm = [7u8; 32];
|
||||
for (i, b) in label.bytes().enumerate() {
|
||||
ikm[i % 32] ^= b;
|
||||
}
|
||||
let sk = SecretKey::key_gen(&ikm, b"igneum").unwrap();
|
||||
let pk = sk.sk_to_pk().compress();
|
||||
Key { sk, pk, hash: vote_key_hash(&pk) }
|
||||
}
|
||||
|
||||
fn reveal(k: &Key) -> Reveal {
|
||||
Reveal { pubkey: k.pk, pop: k.sk.sign(&k.pk, DST_POP, &[]).compress() }
|
||||
}
|
||||
|
||||
fn hash_of(tag: &str, n: u64) -> H {
|
||||
igneum_fin_core::sha256(&[tag.as_bytes(), &n.to_le_bytes()])
|
||||
}
|
||||
|
||||
/// A native BLS verifier through blst, the node's own library (the cross-check for the guest's curve).
|
||||
struct BlstBls;
|
||||
impl Bls for BlstBls {
|
||||
fn verify_aggregate(&self, pubkeys: &[[u8; PUBKEY_LEN]], msg: &[u8], dst: &[u8], sig: &[u8; SIG_LEN]) -> bool {
|
||||
let pks: Option<Vec<blst::min_pk::PublicKey>> = pubkeys.iter().map(|p| blst::min_pk::PublicKey::from_bytes(p).ok()).collect();
|
||||
let Some(pks) = pks else { return false };
|
||||
let Some(sig) = Signature::from_bytes(sig).ok() else { return false };
|
||||
let refs: Vec<&blst::min_pk::PublicKey> = pks.iter().collect();
|
||||
sig.fast_aggregate_verify(true, msg, dst, &refs) == blst::BLST_ERROR::BLST_SUCCESS
|
||||
}
|
||||
|
||||
fn verify_aggregate_points(&self, points: &[[u8; 96]], pubkeys: &[[u8; PUBKEY_LEN]], msg: &[u8], dst: &[u8], sig: &[u8; SIG_LEN]) -> bool {
|
||||
let mut pks = Vec::with_capacity(points.len());
|
||||
for (p, want) in points.iter().zip(pubkeys) {
|
||||
let Ok(pk) = blst::min_pk::PublicKey::deserialize(p) else { return false };
|
||||
if pk.compress() != *want {
|
||||
return false;
|
||||
}
|
||||
pks.push(pk);
|
||||
}
|
||||
let Some(sig) = Signature::from_bytes(sig).ok() else { return false };
|
||||
let refs: Vec<&blst::min_pk::PublicKey> = pks.iter().collect();
|
||||
sig.fast_aggregate_verify(true, msg, dst, &refs) == blst::BLST_ERROR::BLST_SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
/// The simulated chain: one chain block per DAA second, every block blue, keys mine in proportion to `share`.
|
||||
struct Sim {
|
||||
params: FinParams,
|
||||
keys: Vec<Key>,
|
||||
/// (number, hash, daa, blues) per chain block
|
||||
blocks: Vec<ChainBlockWitness>,
|
||||
/// the full table after each block (number -> entries by slot) and the history leaves, from the tracker's fold
|
||||
tables: HashMap<u64, Vec<(u64, KeyEntry)>>,
|
||||
mmr: Mmr,
|
||||
leaves: HashMap<u64, HistoryLeaf>,
|
||||
tracker: SparseRing,
|
||||
keytree: SparseKeys,
|
||||
state: FinState,
|
||||
bls: BlstBls,
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
fn new(params: FinParams, labels: &[&str]) -> Self {
|
||||
let keys: Vec<Key> = labels.iter().map(|l| key(l)).collect();
|
||||
let state = FinState::empty(¶ms, 0);
|
||||
Self { params, keys, blocks: Vec::new(), tables: HashMap::new(), mmr: Mmr::default(), leaves: HashMap::new(), tracker: SparseRing::new(), keytree: SparseKeys::new(), state, bls: BlstBls }
|
||||
}
|
||||
|
||||
/// Chain block `n` at DAA `n`, mined by key `miner`, with `extra` more blue blocks by the keys named; folds it.
|
||||
fn mine(&mut self, miner: usize, extra: &[usize], witness: FoldWitness) -> Result<(), String> {
|
||||
let n = self.blocks.len() as u64;
|
||||
let mut blues = vec![BlueBlock { block_hash: hash_of("chain", n), key_hash: self.keys[miner].hash, daa: n }];
|
||||
for (j, &k) in extra.iter().enumerate() {
|
||||
blues.push(BlueBlock { block_hash: hash_of(&format!("side-{j}"), n), key_hash: self.keys[k].hash, daa: n });
|
||||
}
|
||||
let block = ChainBlockWitness { number: n, block_hash: hash_of("chain", n), daa: n, blues, headers: Vec::new() };
|
||||
// a key's reveal rides with its first block in the table (the node's W1 rule; the tracker re-supplies it
|
||||
// whenever a key whose entry aged out mines again)
|
||||
let mut witness = witness;
|
||||
for b in &block.blues {
|
||||
let known = self.keytree.get(&b.key_hash).map(|e| e.revealed()).unwrap_or(false);
|
||||
if !known && !witness.reveals.iter().any(|r| vote_key_hash(&r.pubkey) == b.key_hash) {
|
||||
let k = self.keys.iter().find(|k| k.hash == b.key_hash).unwrap();
|
||||
witness.reveals.push(reveal(k));
|
||||
}
|
||||
}
|
||||
let before = self.state.clone();
|
||||
let ring_before = self.tracker.clone();
|
||||
let keys_before = self.keytree.clone();
|
||||
let r = fold_block(&mut self.state, &self.params, &block, &mut self.tracker, &mut self.keytree, &witness, &self.bls);
|
||||
let witnesses = self.tracker.take_witnesses();
|
||||
let key_witnesses = self.keytree.take_witnesses();
|
||||
match r {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
self.state = before;
|
||||
self.tracker = ring_before;
|
||||
self.keytree = keys_before;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
// the guest's path: the same fold through the witnesses, from the same previous state, must agree
|
||||
let mut replay = before;
|
||||
let mut ring = WitnessRing::new(witnesses);
|
||||
let mut keys = WitnessKeys::new(key_witnesses);
|
||||
fold_block(&mut replay, &self.params, &block, &mut ring, &mut keys, &witness, &ZkBls).expect("the witnessed replay folds");
|
||||
assert_eq!(replay.extension(), self.state.extension(), "the guest's fold and the tracker's agree at block {n}");
|
||||
assert!(ring.witnesses.is_empty() && keys.witnesses.is_empty(), "every witness consumed");
|
||||
let leaf = HistoryLeaf { number: n, block_hash: block.block_hash, daa: n, table_root: self.state.table_root(), keys_root: self.state.keys_root, key_count: self.state.key_count };
|
||||
self.mmr.append(leaf.hash());
|
||||
assert_eq!(self.mmr.root(), self.state.history_root());
|
||||
self.leaves.insert(n, leaf);
|
||||
self.tables.insert(n, self.keytree.all_entries());
|
||||
self.blocks.push(block);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The canonical table after block `number`.
|
||||
fn table(&self, number: u64) -> Vec<KeyEntry> {
|
||||
canonical(&self.tables[&number]).unwrap()
|
||||
}
|
||||
|
||||
/// A key's blocks in the current table.
|
||||
fn blocks_of(&self, key: &Key) -> u64 {
|
||||
self.keytree.get(&key.hash).map(|e| e.blocks).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The brute-force window count: blue blocks per key with DAA in (daa - W, daa].
|
||||
fn brute(&self, daa: u64) -> HashMap<H, u64> {
|
||||
let mut m = HashMap::new();
|
||||
for b in &self.blocks {
|
||||
for bl in &b.blues {
|
||||
if bl.daa > daa.saturating_sub(self.params.weight_window) && bl.daa <= daa {
|
||||
*m.entry(bl.key_hash).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
fn table_at(&self, number: u64) -> TableAt {
|
||||
TableAt { leaf: self.leaves[&number].clone(), proof: self.mmr.proof(number).unwrap(), keys: self.tables[&number].clone() }
|
||||
}
|
||||
|
||||
/// A certificate for index `index` over chain block `number`, signed by `signers`.
|
||||
fn certificate(&self, index: u64, number: u64, signers: &[usize]) -> CertificateWitness {
|
||||
let checkpoint = self.blocks[number as usize].block_hash;
|
||||
let table = &self.table(number);
|
||||
let (voters, _) = voters_at(table, number, self.params.dust);
|
||||
let msg = vote_message(&self.params.chain_id, index, &checkpoint);
|
||||
let mut sigs = Vec::new();
|
||||
let mut positions = Vec::new();
|
||||
for &s in signers {
|
||||
let k = &self.keys[s];
|
||||
let pos = voters.iter().position(|&v| table[v].key_hash == k.hash).expect("a signer is a voter");
|
||||
positions.push(pos);
|
||||
sigs.push(k.sk.sign(&msg, DST_VOTE, &[]));
|
||||
}
|
||||
let refs: Vec<&Signature> = sigs.iter().collect();
|
||||
let agg = AggregateSignature::aggregate(&refs, true).unwrap().to_signature().compress();
|
||||
let mut bitmap = vec![0u8; voters.len().div_ceil(8)];
|
||||
for p in positions {
|
||||
bitmap[p / 8] |= 1 << (p % 8);
|
||||
}
|
||||
let frozen = (self.state.lock.index > 0).then(|| self.table_at(self.state.lock.number));
|
||||
let mut order: Vec<usize> = signers.to_vec();
|
||||
order.sort_by_key(|&s| voters.iter().position(|&v| table[v].key_hash == self.keys[s].hash).unwrap());
|
||||
let signer_points: Vec<Vec<u8>> = order.iter().map(|&s| self.keys[s].sk.sk_to_pk().serialize().to_vec()).collect();
|
||||
CertificateWitness { index, checkpoint, voter_count: voters.len() as u32, bitmap, signature: agg, signer_points, at: self.table_at(number), frozen }
|
||||
}
|
||||
}
|
||||
|
||||
fn params() -> FinParams {
|
||||
FinParams { chain_id: "igneum-fintest".into(), weight_window: 200, dust: 5, min_daa: 200, leave_delay: 20, equivocation_ban: 200 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_carried_table_equals_a_brute_force_count_of_the_window_at_every_block() {
|
||||
let mut sim = Sim::new(params(), &["a", "b", "c", "d"]);
|
||||
// keys mine 4:3:2:1 with side blocks; the window (200) rolls over twice in 500 blocks
|
||||
for n in 0..500u64 {
|
||||
let miner = [0, 0, 0, 0, 1, 1, 1, 2, 2, 3][(n % 10) as usize];
|
||||
let extra: Vec<usize> = if n % 7 == 0 { vec![1, 2] } else if n % 11 == 0 { vec![3] } else { vec![] };
|
||||
sim.mine(miner, &extra, FoldWitness::default()).unwrap();
|
||||
let brute = sim.brute(n);
|
||||
for (_, k) in sim.keytree.all_entries() {
|
||||
assert_eq!(k.blocks, brute.get(&k.key_hash).copied().unwrap_or(0), "key blocks at block {n}");
|
||||
}
|
||||
for (kh, b) in &brute {
|
||||
assert_eq!(sim.keytree.get(kh).expect("every key with blocks is in the table").blocks, *b);
|
||||
}
|
||||
let ring_count: u64 = sim.tracker.all_entries().iter().filter(|e| e.blue).count() as u64;
|
||||
assert_eq!(ring_count, brute.values().sum::<u64>(), "the ring holds exactly the window at block {n}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_counted_twice_is_refused_and_ageing_is_exact_at_the_edge() {
|
||||
let mut sim = Sim::new(params(), &["a", "b"]);
|
||||
for _ in 0..3 {
|
||||
sim.mine(0, &[], FoldWitness::default()).unwrap();
|
||||
}
|
||||
// the same side block hash twice in one chain block
|
||||
let n = sim.blocks.len() as u64;
|
||||
let dup = BlueBlock { block_hash: hash_of("dup", 1), key_hash: sim.keys[1].hash, daa: n };
|
||||
let block = ChainBlockWitness { number: n, block_hash: hash_of("chain", n), daa: n, blues: vec![BlueBlock { block_hash: hash_of("chain", n), key_hash: sim.keys[0].hash, daa: n }, dup.clone(), dup], headers: Vec::new() };
|
||||
let mut s = sim.state.clone();
|
||||
let err = fold_block(&mut s, &sim.params, &block, &mut sim.tracker.clone(), &mut sim.keytree.clone(), &FoldWitness::default(), &sim.bls).unwrap_err();
|
||||
assert!(err.contains("counted twice"), "{err}");
|
||||
// ageing: with W = 200, the block at DAA d leaves exactly when the chain reaches d + 200
|
||||
let mut sim = Sim::new(params(), &["a", "b"]);
|
||||
for _ in 0..201u64 {
|
||||
sim.mine(0, &[], FoldWitness::default()).unwrap();
|
||||
}
|
||||
// blocks 0..=200 mined; at DAA 200 the window is (0, 200]: block 0 is out, 200 blocks counted
|
||||
assert_eq!(sim.blocks_of(&sim.keys[0]), 200);
|
||||
sim.mine(0, &[], FoldWitness::default()).unwrap();
|
||||
assert_eq!(sim.blocks_of(&sim.keys[0]), 200, "one in, one out");
|
||||
}
|
||||
|
||||
/// Builds a chain past the first-month gate with four keys of weight 40, 30, 20 and 10 percent, every key revealed.
|
||||
fn chain_past_the_gate(labels: &[&str]) -> Sim {
|
||||
let mut sim = Sim::new(params(), labels);
|
||||
let mut w = FoldWitness::default();
|
||||
w.reveals = (0..labels.len()).map(|i| reveal(&sim.keys[i])).collect();
|
||||
sim.mine(0, &[], w).unwrap();
|
||||
for n in 1..260u64 {
|
||||
let miner = [0, 0, 0, 0, 1, 1, 1, 2, 2, 3][(n % 10) as usize] % labels.len();
|
||||
sim.mine(miner, &[], FoldWitness::default()).unwrap();
|
||||
}
|
||||
sim
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_failed_first_todays_light_client_takes_the_voter_list_from_a_node_and_the_proof_does_not() {
|
||||
// keys a, b, c, d hold 40, 30, 20, 10 percent of the real 200-block window; e is the attacker with nothing
|
||||
let mut sim = chain_past_the_gate(&["a", "b", "c", "d", "e"]);
|
||||
let cp = 250u64;
|
||||
let real_entries = sim.tables[&cp].clone();
|
||||
let real_table = sim.table(cp);
|
||||
let (real_voters, real_total) = voters_at(&real_table, cp, sim.params.dust);
|
||||
assert_eq!(real_voters.len(), 4, "e mined nothing and is no voter");
|
||||
|
||||
// The attacker's node hands a light client a voter list of its own making: e with 70 percent of the weight. The
|
||||
// certificate over the real checkpoint is signed by e alone. Today's client (site/verify/core.js, 10.4 items 2
|
||||
// and 3) sums the weights of the list it was given, verifies the aggregate signature and locks: the rule it runs
|
||||
// is right, the list is the node's word.
|
||||
let e = &sim.keys[4];
|
||||
let mut forged: Vec<KeyEntry> = real_table.clone();
|
||||
let mut e_entry = KeyEntry::new(e.hash);
|
||||
e_entry.pubkey = e.pk;
|
||||
e_entry.blocks = real_total * 7 / 3 + 1;
|
||||
let pos = forged.binary_search_by(|k| k.key_hash.cmp(&e.hash)).unwrap_err();
|
||||
forged.insert(pos, e_entry.clone());
|
||||
let (forged_voters, forged_total) = voters_at(&forged, cp, sim.params.dust);
|
||||
let checkpoint = sim.blocks[cp as usize].block_hash;
|
||||
let msg = vote_message(&sim.params.chain_id, 9, &checkpoint);
|
||||
let sig = e.sk.sign(&msg, DST_VOTE, &[]).compress();
|
||||
let e_pos = forged_voters.iter().position(|&v| forged[v].key_hash == e.hash).unwrap();
|
||||
let mut bitmap = vec![0u8; forged_voters.len().div_ceil(8)];
|
||||
bitmap[e_pos / 8] |= 1 << (e_pos % 8);
|
||||
// today's client, the JS logic as Rust
|
||||
let positions = signer_positions(&bitmap, forged_voters.len() as u32);
|
||||
let signed: u64 = positions.iter().map(|&p| forged[forged_voters[p]].blocks).sum();
|
||||
let pks: Vec<[u8; PUBKEY_LEN]> = positions.iter().map(|&p| forged[forged_voters[p]].pubkey).collect();
|
||||
let sig_ok = BlstBls.verify_aggregate(&pks, &msg, DST_VOTE, &sig);
|
||||
let todays_client_locks = sig_ok && FinParams::floor_met(signed, forged_total);
|
||||
assert!(todays_client_locks, "KNOWN FAILED: the client of spec 10.4 locks on a forged voter list ({signed} of {forged_total} signed)");
|
||||
|
||||
// The proof-carried table: the same certificate presented to the fold with the forged table as the witness
|
||||
// is refused (the table at the checkpoint is the one the proof committed), and with the real table it is
|
||||
// refused too (e holds nothing there and is no voter: the bitmap names a position outside the list, or e's
|
||||
// weight is zero).
|
||||
let mut forged_entries = real_entries.clone();
|
||||
forged_entries.push((real_entries.len() as u64 + 7, e_entry.clone()));
|
||||
let forged_at = TableAt { leaf: sim.leaves[&cp].clone(), proof: sim.mmr.proof(cp).unwrap(), keys: forged_entries };
|
||||
let cert = CertificateWitness { index: 9, checkpoint, voter_count: forged_voters.len() as u32, bitmap: bitmap.clone(), signature: sig, signer_points: Vec::new(), at: forged_at, frozen: None };
|
||||
let mut w = FoldWitness::default();
|
||||
w.certificates.push(cert);
|
||||
let err = sim.mine(1, &[], w).unwrap_err();
|
||||
assert!(err.contains("not the one the proof committed"), "the forged table is refused: {err}");
|
||||
let cert_real = CertificateWitness { index: 9, checkpoint, voter_count: forged_voters.len() as u32, bitmap, signature: sig, signer_points: Vec::new(), at: sim.table_at(cp), frozen: None };
|
||||
let mut w = FoldWitness::default();
|
||||
w.certificates.push(cert_real);
|
||||
let err = sim.mine(1, &[], w).unwrap_err();
|
||||
assert!(err.contains("names 5 voters"), "the real table has four voters: {err}");
|
||||
assert_eq!(sim.state.lock.index, 0, "no lock from the attacker");
|
||||
|
||||
// and the honest certificate (a, b, c: 90 percent) locks, with the extension saying so
|
||||
let cert = sim.certificate(9, cp, &[0, 1, 2]);
|
||||
let mut w = FoldWitness::default();
|
||||
w.certificates.push(cert);
|
||||
sim.mine(1, &[], w).unwrap();
|
||||
let ext = sim.state.extension();
|
||||
assert_eq!(ext.lock.index, 9);
|
||||
assert_eq!(ext.lock.hash, checkpoint);
|
||||
assert_eq!(ext.lock.number, cp);
|
||||
assert!(FinParams::floor_met(ext.lock.signed, ext.lock.total));
|
||||
assert_eq!(ext.lock.total, real_total);
|
||||
assert!(!ext.stale());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_thirds_is_inclusive_and_under_it_is_refused() {
|
||||
// six equal keys over a 240-block window (40 blocks each): 4 of 6 is exactly two thirds and locks, 3 of 6 does
|
||||
// not (spec 3.10 Q3's unit test, here inside the proof)
|
||||
let mut p = params();
|
||||
p.weight_window = 240;
|
||||
p.min_daa = 240;
|
||||
p.equivocation_ban = 240;
|
||||
let mut sim = Sim::new(p, &["a", "b", "c", "d", "e", "f"]);
|
||||
let mut w = FoldWitness::default();
|
||||
w.reveals = (0..6).map(|i| reveal(&sim.keys[i])).collect();
|
||||
sim.mine(0, &[], w).unwrap();
|
||||
for n in 1..250u64 {
|
||||
sim.mine((n % 6) as usize, &[], FoldWitness::default()).unwrap();
|
||||
}
|
||||
let cp = 240u64;
|
||||
let (_, total) = voters_at(&sim.table(cp), cp, sim.params.dust);
|
||||
assert_eq!(total, 240);
|
||||
let three = sim.certificate(8, cp, &[0, 1, 2]);
|
||||
let mut w = FoldWitness::default();
|
||||
w.certificates.push(three);
|
||||
let err = sim.mine(0, &[], w).unwrap_err();
|
||||
assert!(err.contains("under two thirds (Q3)"), "{err}");
|
||||
let four = sim.certificate(8, cp, &[0, 1, 2, 3]);
|
||||
let mut w = FoldWitness::default();
|
||||
w.certificates.push(four);
|
||||
sim.mine(0, &[], w).unwrap();
|
||||
assert_eq!(sim.state.lock.index, 8);
|
||||
// a second certificate at a locked index is refused, as is one below it
|
||||
let again = sim.certificate(8, cp, &[0, 1, 2, 3, 4]);
|
||||
let mut w = FoldWitness::default();
|
||||
w.certificates.push(again);
|
||||
let err = sim.mine(0, &[], w).unwrap_err();
|
||||
assert!(err.contains("not above the last lock"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_frozen_table_holds_a_side_without_its_partner_and_a_leave_releases_it_after_the_delay() {
|
||||
// a (60 percent) and b (40 percent) lock together; b stops; a alone cannot lock against the frozen table however
|
||||
// long it mines; b's leave shrinks the frozen denominator after leave_delay and a locks; after a window with no
|
||||
// lock the proof chain is stale and refuses everything
|
||||
let mut sim = Sim::new(params(), &["a", "b"]);
|
||||
let mut w = FoldWitness::default();
|
||||
w.reveals = vec![reveal(&sim.keys[0]), reveal(&sim.keys[1])];
|
||||
sim.mine(0, &[], w).unwrap();
|
||||
for n in 1..230u64 {
|
||||
sim.mine(if n % 5 < 3 { 0 } else { 1 }, &[], FoldWitness::default()).unwrap();
|
||||
}
|
||||
let cert = sim.certificate(7, 220, &[0, 1]);
|
||||
let mut w = FoldWitness::default();
|
||||
w.certificates.push(cert);
|
||||
sim.mine(0, &[], w).unwrap();
|
||||
assert_eq!(sim.state.lock.index, 7);
|
||||
let lock_daa = sim.state.lock.daa;
|
||||
// b stops; a mines alone for 150 blocks: under v2 a holds two thirds of its own sliding table by then
|
||||
for _ in 0..150 {
|
||||
sim.mine(0, &[], FoldWitness::default()).unwrap();
|
||||
}
|
||||
let n = sim.blocks.len() as u64 - 1;
|
||||
let (_, total) = voters_at(&sim.table(n), n, sim.params.dust);
|
||||
let a_blocks = sim.blocks_of(&sim.keys[0]);
|
||||
assert!(FinParams::floor_met(a_blocks, total), "a holds two thirds of the sliding table by now ({a_blocks} of {total})");
|
||||
let cert = sim.certificate(12, n, &[0]);
|
||||
let mut w = FoldWitness::default();
|
||||
w.certificates.push(cert);
|
||||
let err = sim.mine(0, &[], w).unwrap_err();
|
||||
assert!(err.contains("(Q5)"), "the frozen table holds a: {err}");
|
||||
// b's leave, carried now; it takes effect leave_delay later
|
||||
let leave_daa = sim.blocks.len() as u64;
|
||||
let leave = LeaveWitness { daa: leave_daa, pubkey: sim.keys[1].pk, signature: sim.keys[1].sk.sign(&igneum_fin_core::leave_message(&sim.params.chain_id, leave_daa), DST_LEAVE, &[]).compress(), carrier_daa: leave_daa };
|
||||
let mut w = FoldWitness::default();
|
||||
w.leaves.push(leave);
|
||||
sim.mine(0, &[], w).unwrap();
|
||||
for _ in 0..5 {
|
||||
sim.mine(0, &[], FoldWitness::default()).unwrap();
|
||||
}
|
||||
let n = sim.blocks.len() as u64 - 1;
|
||||
let cert = sim.certificate(13, n, &[0]);
|
||||
let mut w = FoldWitness::default();
|
||||
w.certificates.push(cert);
|
||||
let err = sim.mine(0, &[], w).unwrap_err();
|
||||
assert!(err.contains("(Q5)"), "before the delay the leave removes nothing: {err}");
|
||||
for _ in 0..sim.params.leave_delay {
|
||||
sim.mine(0, &[], FoldWitness::default()).unwrap();
|
||||
}
|
||||
let n = sim.blocks.len() as u64 - 1;
|
||||
assert!(n < lock_daa + sim.params.weight_window, "still inside the window after the last lock");
|
||||
let cert = sim.certificate(14, n, &[0]);
|
||||
let mut w = FoldWitness::default();
|
||||
w.certificates.push(cert);
|
||||
sim.mine(0, &[], w).unwrap();
|
||||
assert_eq!(sim.state.lock.index, 14, "after the delay b is out of the frozen denominator and a locks alone");
|
||||
assert!(sim.state.lock.frozen_total < sim.state.lock.total + sim.state.lock.frozen_total, "the frozen total shrank");
|
||||
|
||||
// stale: a window with no lock
|
||||
let since = sim.state.lock.daa;
|
||||
while (sim.blocks.len() as u64) < since + sim.params.weight_window + 2 {
|
||||
sim.mine(0, &[], FoldWitness::default()).unwrap();
|
||||
}
|
||||
assert!(sim.state.stale);
|
||||
let n = sim.blocks.len() as u64 - 1;
|
||||
let cert = sim.certificate(20, n, &[0]);
|
||||
let mut w = FoldWitness::default();
|
||||
w.certificates.push(cert);
|
||||
let err = sim.mine(0, &[], w).unwrap_err();
|
||||
assert!(err.contains("stale"), "{err}");
|
||||
assert!(sim.state.extension().stale());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_light_client_answers_final_at_checkpoint_from_the_extension_and_a_history_proof_alone() {
|
||||
let mut sim = chain_past_the_gate(&["a", "b", "c", "d"]);
|
||||
let cert = sim.certificate(9, 250, &[0, 1, 2]);
|
||||
let mut w = FoldWitness::default();
|
||||
w.certificates.push(cert);
|
||||
sim.mine(1, &[], w).unwrap();
|
||||
for _ in 0..10 {
|
||||
sim.mine(0, &[], FoldWitness::default()).unwrap();
|
||||
}
|
||||
// what a verifier holds: the extension bytes of the latest proof (the proof itself verified elsewhere)
|
||||
let bytes = sim.state.extension().to_bytes();
|
||||
assert_eq!(bytes.len(), FinExt::LEN);
|
||||
let ext = FinExt::from_bytes(&bytes).unwrap();
|
||||
let leaves = sim.state.leaves;
|
||||
// block 240 is at or below the lock's block 250: final; block 255 is not; a tampered leaf is not in the history
|
||||
let answer = |number: u64| -> &'static str {
|
||||
let leaf = sim.leaves[&number].clone();
|
||||
let proof = sim.mmr.proof(number).unwrap();
|
||||
if !proof.verify(&leaf.hash(), &ext.history_root, leaves) {
|
||||
return "not in this chain";
|
||||
}
|
||||
if ext.stale() {
|
||||
return "stale";
|
||||
}
|
||||
if leaf.number <= ext.lock.number {
|
||||
"final"
|
||||
} else {
|
||||
"not final"
|
||||
}
|
||||
};
|
||||
assert_eq!(answer(240), "final");
|
||||
assert_eq!(answer(250), "final");
|
||||
assert_eq!(answer(255), "not final");
|
||||
let mut bad = sim.leaves[&240].clone();
|
||||
bad.block_hash = hash_of("other", 240);
|
||||
assert!(!sim.mmr.proof(240).unwrap().verify(&bad.hash(), &ext.history_root, leaves));
|
||||
assert_eq!(igneum_fin_core::keys::dense_root(&sim.tables[&250], sim.leaves[&250].key_count).unwrap(), sim.leaves[&250].keys_root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_guest_curve_and_blst_agree_on_real_certificates_and_disagree_with_nothing() {
|
||||
let sim = chain_past_the_gate(&["a", "b", "c"]);
|
||||
let cert = sim.certificate(9, 250, &[0, 1]);
|
||||
let table = &sim.table(250);
|
||||
let (voters, _) = voters_at(table, 250, sim.params.dust);
|
||||
let pks: Vec<[u8; PUBKEY_LEN]> = signer_positions(&cert.bitmap, cert.voter_count).iter().map(|&p| table[voters[p]].pubkey).collect();
|
||||
let msg = vote_message(&sim.params.chain_id, 9, &cert.checkpoint);
|
||||
assert!(BlstBls.verify_aggregate(&pks, &msg, DST_VOTE, &cert.signature));
|
||||
assert!(ZkBls.verify_aggregate(&pks, &msg, DST_VOTE, &cert.signature));
|
||||
let mut bad = cert.signature;
|
||||
bad[10] ^= 1;
|
||||
assert!(!BlstBls.verify_aggregate(&pks, &msg, DST_VOTE, &bad));
|
||||
assert!(!ZkBls.verify_aggregate(&pks, &msg, DST_VOTE, &bad));
|
||||
let other = vote_message(&sim.params.chain_id, 10, &cert.checkpoint);
|
||||
assert!(!ZkBls.verify_aggregate(&pks, &other, DST_VOTE, &cert.signature));
|
||||
// the certificate path with uncompressed points, both curves; a point that compresses to another key refused
|
||||
let pts: Vec<[u8; 96]> = [0usize, 1].iter().map(|&i| sim.keys[i].sk.sk_to_pk().serialize()).collect();
|
||||
assert!(ZkBls.verify_aggregate_points(&pts, &pks, &msg, DST_VOTE, &cert.signature));
|
||||
assert!(BlstBls.verify_aggregate_points(&pts, &pks, &msg, DST_VOTE, &cert.signature));
|
||||
let swapped = vec![pts[1], pts[0]];
|
||||
assert!(!ZkBls.verify_aggregate_points(&swapped, &pks, &msg, DST_VOTE, &cert.signature), "a point must compress to its own table key");
|
||||
let mut off = pts.clone();
|
||||
off[0][95] ^= 1;
|
||||
assert!(!ZkBls.verify_aggregate_points(&off, &pks, &msg, DST_VOTE, &cert.signature), "a point off the curve is refused");
|
||||
// a proof of possession
|
||||
let r = reveal(&sim.keys[0]);
|
||||
assert!(ZkBls.verify_one(&r.pubkey, &r.pubkey, DST_POP, &r.pop));
|
||||
assert!(BlstBls.verify_one(&r.pubkey, &r.pubkey, DST_POP, &r.pop));
|
||||
}
|
||||
|
||||
/// Level 1: a chain of headers hashed the chain's way, with a side block blue and one red; a swapped key, a
|
||||
/// wrong blue count, an unreached block and a replayed red are each refused.
|
||||
#[test]
|
||||
fn level_one_pins_the_blues_to_headers_and_parent_links() {
|
||||
use igneum_fin_core::header::HeaderWitness;
|
||||
let p = params();
|
||||
let mut state = FinState::empty(&p, 0);
|
||||
let mut ring = SparseRing::new();
|
||||
let mut kt = SparseKeys::new();
|
||||
let keys: Vec<Key> = ["a", "b", "c"].iter().map(|l| key(l)).collect();
|
||||
let hdr = |parents: Vec<H>, daa: u64, blue_score: u64, work: u8, key: &Key, blue: bool| HeaderWitness { version: 2, parents_by_level: vec![parents], hash_merkle_root: hash_of("m", daa), accepted_id_merkle_root: ZERO_H, utxo_commitment: ZERO_H, timestamp: 1_000 + daa, bits: 0x1e00ffff, nonce: daa * 7, daa_score: daa, blue_work: vec![work], blue_score, pruning_point: ZERO_H, vote_key_hash: key.hash, blue };
|
||||
// block 0 (genesis-like, no parents), by a
|
||||
let h0 = hdr(vec![], 0, 0, 1, &keys[0], true);
|
||||
let b0 = ChainBlockWitness { number: 0, block_hash: h0.hash(), daa: 0, blues: vec![BlueBlock { block_hash: h0.hash(), key_hash: keys[0].hash, daa: 0 }], headers: vec![h0.clone()] };
|
||||
let mut w = FoldWitness::default();
|
||||
w.reveals = keys.iter().map(reveal).collect();
|
||||
fold_block(&mut state, &p, &b0, &mut ring, &mut kt, &w, &BlstBls).unwrap();
|
||||
ring.take_witnesses();
|
||||
kt.take_witnesses();
|
||||
// a side block s by b (parent: block 0) and a red r by c (parent: block 0); block 1 by a with parents [0, s, r]
|
||||
let hs = hdr(vec![h0.hash()], 1, 1, 2, &keys[1], true);
|
||||
let hr = hdr(vec![h0.hash()], 1, 1, 1, &keys[2], false);
|
||||
let h1 = hdr(vec![h0.hash(), hs.hash(), hr.hash()], 1, 2, 9, &keys[0], true);
|
||||
let good = ChainBlockWitness { number: 1, block_hash: h1.hash(), daa: 1, blues: vec![BlueBlock { block_hash: h1.hash(), key_hash: keys[0].hash, daa: 1 }, BlueBlock { block_hash: hs.hash(), key_hash: keys[1].hash, daa: 1 }], headers: vec![h1.clone(), hs.clone(), hr.clone()] };
|
||||
// known-failed cases first
|
||||
let mut swapped = good.clone();
|
||||
swapped.blues[1].key_hash = keys[2].hash;
|
||||
let e = fold_block(&mut state.clone(), &p, &swapped, &mut ring.clone(), &mut kt.clone(), &FoldWitness::default(), &BlstBls).unwrap_err();
|
||||
assert!(e.contains("differs from its header"), "{e}");
|
||||
let mut miscount = good.clone();
|
||||
miscount.blues.push(BlueBlock { block_hash: hr.hash(), key_hash: keys[2].hash, daa: 1 });
|
||||
let e = fold_block(&mut state.clone(), &p, &miscount, &mut ring.clone(), &mut kt.clone(), &FoldWitness::default(), &BlstBls).unwrap_err();
|
||||
assert!(e.contains("blue score") || e.contains("differs from its header"), "{e}");
|
||||
let mut unreached = good.clone();
|
||||
let hx = hdr(vec![hash_of("nowhere", 9)], 1, 1, 1, &keys[1], false);
|
||||
unreached.headers.push(hx);
|
||||
let e = fold_block(&mut state.clone(), &p, &unreached, &mut ring.clone(), &mut kt.clone(), &FoldWitness::default(), &BlstBls).unwrap_err();
|
||||
assert!(e.contains("not reached"), "{e}");
|
||||
let mut wrong_sp = good.clone();
|
||||
wrong_sp.headers[0].parents_by_level = vec![vec![hs.hash(), hr.hash()]];
|
||||
wrong_sp.block_hash = wrong_sp.headers[0].hash();
|
||||
wrong_sp.blues[0].block_hash = wrong_sp.block_hash;
|
||||
let e = fold_block(&mut state.clone(), &p, &wrong_sp, &mut ring.clone(), &mut kt.clone(), &FoldWitness::default(), &BlstBls).unwrap_err();
|
||||
assert!(e.contains("not a direct parent"), "{e}");
|
||||
// the good block folds: b credited for s, c not credited for r, r in the ring uncounted
|
||||
fold_block(&mut state, &p, &good, &mut ring, &mut kt, &FoldWitness::default(), &BlstBls).unwrap();
|
||||
ring.take_witnesses();
|
||||
kt.take_witnesses();
|
||||
let w_of = |k: &Key| kt.get(&k.hash).map(|e| e.blocks).unwrap_or(0);
|
||||
assert_eq!((w_of(&keys[0]), w_of(&keys[1]), w_of(&keys[2])), (2, 1, 0));
|
||||
assert!(ring.all_entries().iter().any(|e| e.block_hash == hr.hash() && !e.blue));
|
||||
// block 2 replays r as a blue: refused by the ring
|
||||
let h2 = hdr(vec![h1.hash(), hr.hash()], 2, 4, 20, &keys[0], true);
|
||||
let replay = ChainBlockWitness { number: 2, block_hash: h2.hash(), daa: 2, blues: vec![BlueBlock { block_hash: h2.hash(), key_hash: keys[0].hash, daa: 2 }, BlueBlock { block_hash: hr.hash(), key_hash: keys[2].hash, daa: 1 }], headers: vec![h2.clone(), { let mut x = hr.clone(); x.blue = true; x }] };
|
||||
let e = fold_block(&mut state.clone(), &p, &replay, &mut ring.clone(), &mut kt.clone(), &FoldWitness::default(), &BlstBls).unwrap_err();
|
||||
assert!(e.contains("counted twice") || e.contains("not a direct parent") || e.contains("listed twice"), "{e}");
|
||||
}
|
||||
|
||||
const ZERO_H: H = [0u8; 32];
|
||||
|
|
@ -7,6 +7,8 @@ license.workspace = true
|
|||
|
||||
[dependencies]
|
||||
igneum-prove-core.workspace = true
|
||||
igneum-fin-core.workspace = true
|
||||
blst = "0.3"
|
||||
igneum-evm-types.workspace = true
|
||||
sp1-sdk = { workspace = true, features = ["blocking"] }
|
||||
alloy-primitives.workspace = true
|
||||
|
|
|
|||
270
proving/igneum-prove/host/src/fin.rs
Normal file
270
proving/igneum-prove/host/src/fin.rs
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
//! Finality in the proof, the host's half (docs/design/finality-in-proof.md): the witness file an aggregation
|
||||
//! takes (`--fin <file>`), folded natively first so every block's input state is known, and the light client's
|
||||
//! question (`--mode final-at`) answered from a proof's extension and a history proof alone.
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use igneum_fin_core::cert::TableAt;
|
||||
use igneum_fin_core::fold::{fold_block, ChainBlockWitness, FoldWitness};
|
||||
use igneum_fin_core::mmr::{HistoryLeaf, MmrProof};
|
||||
use igneum_fin_core::keys::{KeyLeafWitness, SparseKeys, WitnessKeys};
|
||||
use igneum_fin_core::ring::{RingLeafWitness, WitnessRing};
|
||||
use igneum_fin_core::{FinExt, FinParams, FinState};
|
||||
use igneum_prove_core::agg::{BlockOutput, FinInput};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One chain block's finality witness as the node's RPC hands it to the aggregator.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct BlockFin {
|
||||
pub block: ChainBlockWitness,
|
||||
pub ring: Vec<RingLeafWitness>,
|
||||
#[serde(default)]
|
||||
pub keys: Vec<KeyLeafWitness>,
|
||||
#[serde(default)]
|
||||
pub witness: FoldWitness,
|
||||
}
|
||||
|
||||
/// `--fin <file>`: the parameters, the state before the first block, and every block's witness in order.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct FinWitnessFile {
|
||||
pub format: String,
|
||||
pub params: FinParams,
|
||||
pub root_state: FinState,
|
||||
pub blocks: Vec<BlockFin>,
|
||||
}
|
||||
|
||||
pub const FORMAT: &str = "igneum-fin-witness-v1";
|
||||
|
||||
/// Folds every block natively (the known-finished case before any proof) and returns one `FinInput` per block,
|
||||
/// each with the state its fold starts from.
|
||||
pub fn prepare(file: &FinWitnessFile, first_number: u64, rooted: bool) -> Result<Vec<FinInput>> {
|
||||
if file.format != FORMAT {
|
||||
bail!("finality witness format {} (want {FORMAT})", file.format);
|
||||
}
|
||||
let mut state = file.root_state.clone();
|
||||
if state.leaves == 0 {
|
||||
state.history_first = first_number;
|
||||
state.end_number = first_number.saturating_sub(1);
|
||||
}
|
||||
if state.end_number + 1 != first_number {
|
||||
bail!("the finality root state ends at chain block {} and the first block to aggregate is {first_number}", state.end_number);
|
||||
}
|
||||
let _ = rooted; // the guest keeps the witness's history_first at a root (agg.rs, the root branch)
|
||||
let mut out = Vec::with_capacity(file.blocks.len());
|
||||
for (i, b) in file.blocks.iter().enumerate() {
|
||||
if b.block.number != first_number + i as u64 {
|
||||
bail!("finality witness {i} is of chain block {}, expected {}", b.block.number, first_number + i as u64);
|
||||
}
|
||||
let input = FinInput { params: file.params.clone(), prev_state: state.clone(), block: b.block.clone(), ring: b.ring.clone(), keys: b.keys.clone(), witness: b.witness.clone() };
|
||||
let mut ring = WitnessRing::new(b.ring.clone());
|
||||
let mut keys = WitnessKeys::new(b.keys.clone());
|
||||
fold_block(&mut state, &file.params, &b.block, &mut ring, &mut keys, &b.witness, &igneum_fin_core::bls::ZkBls).map_err(|e| anyhow!("finality fold of chain block {} natively: {e}", b.block.number))?;
|
||||
if !ring.witnesses.is_empty() || !keys.witnesses.is_empty() {
|
||||
bail!("finality witness of chain block {} carries {} ring and {} key leaves the fold did not open", b.block.number, ring.witnesses.len(), keys.witnesses.len());
|
||||
}
|
||||
out.push(input);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `--block <file>`: a chain block's history leaf and its proof at the size of the proof's history.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct BlockQuery {
|
||||
pub leaf: HistoryLeaf,
|
||||
pub proof: MmrProof,
|
||||
}
|
||||
|
||||
/// The light client's answer, from the extension alone (the proof itself verified by the caller).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Answer {
|
||||
/// Final at checkpoint `index` (locked at chain block `lock_number`).
|
||||
Final { index: u64, lock_number: u64 },
|
||||
/// On this chain but above the latest lock.
|
||||
NotFinal { lock_index: u64, lock_number: u64 },
|
||||
/// The proof chain has had no lock for a full window: the client needs a fresh root.
|
||||
Stale { lock_index: u64 },
|
||||
/// The block is not in the proof's history.
|
||||
NotInChain,
|
||||
/// The proof carries no finality claim (made before the activation).
|
||||
NoClaim,
|
||||
}
|
||||
|
||||
pub fn answer(output: &BlockOutput, history_leaves: u64, query: Option<&BlockQuery>) -> Answer {
|
||||
let Some(ext) = &output.fin else { return Answer::NoClaim };
|
||||
let (number, in_chain) = match query {
|
||||
None => (output.number, true),
|
||||
Some(q) => (q.leaf.number, q.proof.verify(&q.leaf.hash(), &ext.history_root, history_leaves)),
|
||||
};
|
||||
if !in_chain {
|
||||
return Answer::NotInChain;
|
||||
}
|
||||
if ext.stale() {
|
||||
return Answer::Stale { lock_index: ext.lock.index };
|
||||
}
|
||||
if ext.lock.index > 0 && number <= ext.lock.number {
|
||||
Answer::Final { index: ext.lock.index, lock_number: ext.lock.number }
|
||||
} else {
|
||||
Answer::NotFinal { lock_index: ext.lock.index, lock_number: ext.lock.number }
|
||||
}
|
||||
}
|
||||
|
||||
/// The history's leaf count from the extension: `number - history_first + 1` when the attestation rooted at an
|
||||
/// empty state; a witness root carries more, so the query names the count it was made at.
|
||||
pub fn leaves_hint(ext: &FinExt, output: &BlockOutput) -> u64 {
|
||||
output.number.saturating_sub(ext.history_first) + 1
|
||||
}
|
||||
|
||||
pub fn describe(a: &Answer) -> String {
|
||||
match a {
|
||||
Answer::Final { index, lock_number } => format!("final at checkpoint {index} (locked at chain block {lock_number})"),
|
||||
Answer::NotFinal { lock_index, lock_number } => format!("not final (latest lock: checkpoint {lock_index} at chain block {lock_number})"),
|
||||
Answer::Stale { lock_index } => format!("stale: no lock for a full window after checkpoint {lock_index}; this client needs a fresh root"),
|
||||
Answer::NotInChain => "not in this chain".into(),
|
||||
Answer::NoClaim => "no finality claim in this proof".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_witness(path: &str) -> Result<FinWitnessFile> {
|
||||
let text = std::fs::read_to_string(path).with_context(|| format!("read {path}"))?;
|
||||
serde_json::from_str(&text).with_context(|| format!("{path} is not a finality witness file"))
|
||||
}
|
||||
|
||||
pub fn load_query(path: &str) -> Result<BlockQuery> {
|
||||
let text = std::fs::read_to_string(path).with_context(|| format!("read {path}"))?;
|
||||
serde_json::from_str(&text).with_context(|| format!("{path} is not a block query"))
|
||||
}
|
||||
|
||||
/// Certificate witnesses carry tables; this is what one weighs on the wire.
|
||||
pub fn table_bytes(t: &TableAt) -> usize {
|
||||
t.keys.len() * (8 + igneum_fin_core::KeyEntry::LEN) + 32 * (t.proof.siblings.len() + t.proof.peaks.len())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// The measurement's synthetic witness (design section 6.1): N keys with real BLS key pairs, a window of blocks
|
||||
// behind the fixtures' chain blocks, every key revealed in the first block, and one certificate signed by the
|
||||
// heaviest keys over an earlier block of the run. The chain blocks are the real fixtures' numbers, hashes and DAA
|
||||
// scores, so the guest's cross-check against the shard statements holds; the blue blocks and keys are synthetic.
|
||||
|
||||
use igneum_fin_core::cert::CertificateWitness;
|
||||
use igneum_fin_core::fold::{BlueBlock, Reveal};
|
||||
use igneum_fin_core::mmr::Mmr;
|
||||
use igneum_fin_core::tracker::SparseRing;
|
||||
use igneum_fin_core::{vote_key_hash, vote_message, voters_at, KeyEntry, DST_POP, DST_VOTE, H};
|
||||
use igneum_fin_core::keys::canonical;
|
||||
use igneum_prove_core::Fixture;
|
||||
|
||||
struct SynthKey {
|
||||
sk: blst::min_pk::SecretKey,
|
||||
pk: [u8; 48],
|
||||
hash: H,
|
||||
}
|
||||
|
||||
fn synth_key(i: usize) -> SynthKey {
|
||||
let mut ikm = [0x5au8; 32];
|
||||
ikm[..8].copy_from_slice(&(i as u64).to_le_bytes());
|
||||
let sk = blst::min_pk::SecretKey::key_gen(&ikm, b"igneum-fin-synth").unwrap();
|
||||
let pk = sk.sk_to_pk().compress();
|
||||
SynthKey { sk, pk, hash: vote_key_hash(&pk) }
|
||||
}
|
||||
|
||||
fn synth_hash(tag: &str, n: u64) -> H {
|
||||
igneum_fin_core::sha256(&[tag.as_bytes(), &n.to_le_bytes()])
|
||||
}
|
||||
|
||||
/// Builds the witness file for `fixtures` (consecutive chain blocks): `keys` synthetic keys holding a window of
|
||||
/// `window_blocks` blue blocks before the first fixture (the root state), two blue blocks a chain block during the
|
||||
/// run, every key revealed in the first block, and a certificate signed by the heaviest `voters` keys carried in
|
||||
/// the last block over the chain block `cert_back` blocks before it. Returns the file and the number of blocks a
|
||||
/// certificate verification touched (for the report).
|
||||
pub fn synth(fixtures: &[Fixture], keys: usize, voters: usize, window_blocks: u64, cert_back: usize) -> Result<FinWitnessFile> {
|
||||
let first = fixtures.first().ok_or_else(|| anyhow!("no fixtures"))?;
|
||||
// a fixture written before the DAA field existed carries 0; the guest never checks the DAA against the shard
|
||||
// statement, so the synthetic run takes a base of its own then (the real chain's DAA is what the node feeds)
|
||||
let daa_base = if first.block.env.daa_score == 0 { 100_000 } else { first.block.env.daa_score };
|
||||
let first_daa = daa_base;
|
||||
// the window of the root state: W spans the synthetic history; min_daa under the first DAA so certificates pass C5
|
||||
let params = FinParams { chain_id: "igneum-fin-synth".into(), weight_window: window_blocks.max(16) * 2, dust: 1, min_daa: 1, leave_delay: 3_600, equivocation_ban: window_blocks.max(16) * 2 };
|
||||
let synth_keys: Vec<SynthKey> = (0..keys.max(1)).map(synth_key).collect();
|
||||
// root: fold a synthetic prefix so the table and ring are full (number counts down from the first fixture)
|
||||
let prefix = window_blocks.min(first.block.env.number.saturating_sub(1)).min(first_daa.saturating_sub(1));
|
||||
let root_number = first.block.env.number - prefix;
|
||||
let mut state = FinState::empty(¶ms, root_number);
|
||||
let mut ring = SparseRing::new();
|
||||
let mut keys_tree = SparseKeys::new();
|
||||
let bls = igneum_fin_core::bls::ZkBls;
|
||||
let mut mmr = Mmr::default();
|
||||
for i in 0..prefix {
|
||||
let n = root_number + i;
|
||||
let daa = first_daa - prefix + i;
|
||||
let k = &synth_keys[(i as usize) % synth_keys.len()];
|
||||
let k2 = &synth_keys[(i as usize * 7 + 3) % synth_keys.len()];
|
||||
let block = ChainBlockWitness { number: n, block_hash: synth_hash("prefix", n), daa, blues: vec![BlueBlock { block_hash: synth_hash("prefix", n), key_hash: k.hash, daa }, BlueBlock { block_hash: synth_hash("prefix-side", n), key_hash: k2.hash, daa }], headers: Vec::new() };
|
||||
let mut w = FoldWitness::default();
|
||||
reveals_for(&keys_tree, &synth_keys, &block, &mut w);
|
||||
fold_block(&mut state, ¶ms, &block, &mut ring, &mut keys_tree, &w, &bls).map_err(|e| anyhow!("prefix fold {n}: {e}"))?;
|
||||
ring.take_witnesses();
|
||||
keys_tree.take_witnesses();
|
||||
mmr.append(HistoryLeaf { number: n, block_hash: block.block_hash, daa, table_root: state.table_root(), keys_root: state.keys_root, key_count: state.key_count }.hash());
|
||||
}
|
||||
let root_state = state.clone();
|
||||
// the run over the fixtures, witnesses recorded; tables and leaves kept for the certificate
|
||||
let mut tables: Vec<(u64, Vec<(u64, KeyEntry)>, HistoryLeaf)> = Vec::new();
|
||||
let mut blocks = Vec::new();
|
||||
for (i, f) in fixtures.iter().enumerate() {
|
||||
let n = f.block.env.number;
|
||||
let daa = (daa_base + i as u64).max(state.end_daa);
|
||||
let k = &synth_keys[i % synth_keys.len()];
|
||||
let k2 = &synth_keys[(i * 5 + 1) % synth_keys.len()];
|
||||
let block = ChainBlockWitness { number: n, block_hash: f.block.env.hash.0, daa, blues: vec![BlueBlock { block_hash: f.block.env.hash.0, key_hash: k.hash, daa }, BlueBlock { block_hash: synth_hash("side", n), key_hash: k2.hash, daa }], headers: Vec::new() };
|
||||
let mut w = FoldWitness::default();
|
||||
reveals_for(&keys_tree, &synth_keys, &block, &mut w);
|
||||
if i + 1 == fixtures.len() && fixtures.len() > cert_back && cert_back > 0 {
|
||||
let (cp_number, cp_entries, cp_leaf) = tables[tables.len() - cert_back].clone();
|
||||
let cp_keys = canonical(&cp_entries).map_err(|e| anyhow!(e))?;
|
||||
let (vlist, _) = voters_at(&cp_keys, cp_leaf.daa, params.dust);
|
||||
let mut heavy: Vec<usize> = vlist.clone();
|
||||
heavy.sort_by_key(|&v| std::cmp::Reverse(cp_keys[v].blocks));
|
||||
heavy.truncate(voters.max(1));
|
||||
let index = 1 + (cp_number / 30);
|
||||
let msg = vote_message(¶ms.chain_id, index, &cp_leaf.block_hash);
|
||||
let mut sigs = Vec::new();
|
||||
let mut positions = Vec::new();
|
||||
for v in &heavy {
|
||||
let kh = cp_keys[*v].key_hash;
|
||||
let sk = &synth_keys.iter().find(|k| k.hash == kh).unwrap().sk;
|
||||
sigs.push(sk.sign(&msg, DST_VOTE, &[]));
|
||||
positions.push(vlist.iter().position(|x| x == v).unwrap());
|
||||
}
|
||||
let refs: Vec<&blst::min_pk::Signature> = sigs.iter().collect();
|
||||
let agg = blst::min_pk::AggregateSignature::aggregate(&refs, true).unwrap().to_signature().compress();
|
||||
let mut bitmap = vec![0u8; vlist.len().div_ceil(8)];
|
||||
for p in &positions {
|
||||
bitmap[p / 8] |= 1 << (p % 8);
|
||||
}
|
||||
// the signers' uncompressed points in bitmap order
|
||||
let mut by_pos: Vec<(usize, &usize)> = positions.iter().zip(heavy.iter()).map(|(p, v)| (*p, v)).collect();
|
||||
by_pos.sort();
|
||||
let signer_points: Vec<Vec<u8>> = by_pos.iter().map(|(_, v)| { let kh = cp_keys[**v].key_hash; synth_keys.iter().find(|k| k.hash == kh).unwrap().sk.sk_to_pk().serialize().to_vec() }).collect();
|
||||
let position = cp_number - root_state.history_first;
|
||||
let proof = mmr.proof(position).ok_or_else(|| anyhow!("no history proof for {cp_number}"))?;
|
||||
w.certificates.push(CertificateWitness { index, checkpoint: cp_leaf.block_hash, voter_count: vlist.len() as u32, bitmap, signature: agg, signer_points, at: TableAt { leaf: cp_leaf, proof, keys: cp_entries }, frozen: None });
|
||||
}
|
||||
fold_block(&mut state, ¶ms, &block, &mut ring, &mut keys_tree, &w, &bls).map_err(|e| anyhow!("fold {n}: {e}"))?;
|
||||
let leaf = HistoryLeaf { number: n, block_hash: block.block_hash, daa, table_root: state.table_root(), keys_root: state.keys_root, key_count: state.key_count };
|
||||
mmr.append(leaf.hash());
|
||||
tables.push((n, keys_tree.all_entries(), leaf));
|
||||
blocks.push(BlockFin { block, ring: ring.take_witnesses(), keys: keys_tree.take_witnesses(), witness: w });
|
||||
}
|
||||
Ok(FinWitnessFile { format: FORMAT.into(), params, root_state, blocks })
|
||||
}
|
||||
|
||||
/// W1 as the tracker applies it: a key's reveal rides with its first block in the table.
|
||||
fn reveals_for(table: &SparseKeys, keys: &[SynthKey], block: &ChainBlockWitness, w: &mut FoldWitness) {
|
||||
for b in &block.blues {
|
||||
let known = table.get(&b.key_hash).map(|e| e.revealed()).unwrap_or(false);
|
||||
if !known && !w.reveals.iter().any(|r| vote_key_hash(&r.pubkey) == b.key_hash) {
|
||||
if let Some(k) = keys.iter().find(|k| k.hash == b.key_hash) {
|
||||
w.reveals.push(Reveal { pubkey: k.pk, pop: k.sk.sign(&k.pk, DST_POP, &[]).compress() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
//! ids with no setup. `--mode verify` uses SP1's light verifier and the pinned verifying key: no prover client,
|
||||
//! no key generation (the 114 s to 138 s the Mac's node spent per proof on 5 October).
|
||||
|
||||
mod fin;
|
||||
mod pinned;
|
||||
mod proof_system;
|
||||
|
||||
|
|
@ -82,11 +83,71 @@ fn run() -> Result<()> {
|
|||
// proving v1 (spec 7.8): the node's pool verifies an aggregated segment proof against the pinned aggregator key
|
||||
return run_verify_segment(&pinned, &arg("--proof").context("--proof <file>")?, &arg("--statement").context("--statement 0x<keccak of the block public values>")?);
|
||||
}
|
||||
let prover: Address = arg("--prover").map(|s| s.parse()).transpose()?.unwrap_or_else(|| Address::from_slice(&[0x19; 20]));
|
||||
let out_path = arg("--out");
|
||||
if mode == "fin-synth" {
|
||||
// the measurement's witness (design 6.1): --chain <fixtures> --keys N --voters V --window B --cert-back K --out <file>
|
||||
let list = arg("--chain").context("--chain <f1.json,f2.json,...>")?;
|
||||
let fixtures: Vec<Fixture> = list.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).map(load_fixture).collect::<Result<_>>()?;
|
||||
let keys: usize = arg("--keys").map(|s| s.parse()).transpose()?.unwrap_or(1_000);
|
||||
let voters: usize = arg("--voters").map(|s| s.parse()).transpose()?.unwrap_or(keys * 7 / 10);
|
||||
let window: u64 = arg("--window").map(|s| s.parse()).transpose()?.unwrap_or(2_000);
|
||||
let cert_back: usize = arg("--cert-back").map(|s| s.parse()).transpose()?.unwrap_or(4);
|
||||
let out = arg("--out").context("--out <witness.json>")?;
|
||||
let t = Instant::now();
|
||||
let file = fin::synth(&fixtures, keys, voters, window, cert_back)?;
|
||||
let text = serde_json::to_string(&file)?;
|
||||
std::fs::write(&out, &text).with_context(|| format!("write {out}"))?;
|
||||
println!("RESULT fin-synth: {} blocks {}..={}, {} keys ({} slots in the table), {} signers, window {} blocks, certificate at block {} over {} back; {} bytes of JSON in {:.1} s at {}", file.blocks.len(), fixtures[0].block.env.number, fixtures[fixtures.len() - 1].block.env.number, keys, file.root_state.key_count, voters, window, file.blocks.len(), cert_back, text.len(), t.elapsed().as_secs_f64(), now());
|
||||
return Ok(());
|
||||
}
|
||||
if mode == "fin-execute" {
|
||||
// the cycle count (design 6.1): every block's aggregator in execute mode, with and without the finality input
|
||||
let list = arg("--chain").context("--chain <f1.json,f2.json,...>")?;
|
||||
let fixtures: Vec<Fixture> = list.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).map(load_fixture).collect::<Result<_>>()?;
|
||||
let file = fin::load_witness(&arg("--fin").context("--fin <witness.json>")?)?;
|
||||
let prover: Address = Address::from_slice(&[0x19; 20]);
|
||||
let first = fixtures[0].block.env.number;
|
||||
let inputs = fin::prepare(&file, first, true)?;
|
||||
let mut results = serde_json::Map::new();
|
||||
results.insert("mode".into(), "fin-execute".into());
|
||||
let sp1 = setup_sp1(&pinned, &mut results)?;
|
||||
let (mut prev_plain, mut prev_fin): (Option<Vec<u8>>, Option<Vec<u8>>) = (None, None);
|
||||
let mut rows = Vec::new();
|
||||
for (i, f) in fixtures.iter().enumerate() {
|
||||
let (_, _, shards) = build_shards(&f.block, f.plan.shard_budget, prover);
|
||||
let outputs: Vec<ShardOutput> = shards.iter().map(|s| s.output.clone()).collect();
|
||||
let parent = f.block.env.parent_hash;
|
||||
stage(&format!("fin-execute block {} plain", f.block.env.number));
|
||||
let (out_plain, rep_plain, dt_plain) = sp1.execute_aggregator_with(&outputs, parent, prev_plain.take(), None)?;
|
||||
stage(&format!("fin-execute block {} with finality", f.block.env.number));
|
||||
let fin_input = inputs[i].clone();
|
||||
let witness_bytes = bincode::serialize(&fin_input)?.len();
|
||||
let certs = fin_input.witness.certificates.len();
|
||||
let (out_fin, rep_fin, dt_fin) = sp1.execute_aggregator_with(&outputs, parent, prev_fin.take(), Some(fin_input))?;
|
||||
let ext = out_fin.fin.clone().ok_or_else(|| anyhow!("no extension in the output"))?;
|
||||
let (c0, c1) = (rep_plain.total_instruction_count(), rep_fin.total_instruction_count());
|
||||
let sys: Vec<(String, u64)> = rep_fin.syscall_counts.iter().map(|(k, v)| (format!("{k:?}"), *v)).filter(|(_, v)| *v > 0).collect();
|
||||
println!("RESULT fin-execute block {}: plain {} cycles ({:.2} s), with finality {} cycles ({:.2} s), extra {} cycles; key slots {} witness {} bytes certificates {}; lock index {} at block {} ({} of {}); syscalls {:?} at {}", f.block.env.number, c0, dt_plain.as_secs_f64(), c1, dt_fin.as_secs_f64(), c1.saturating_sub(c0), file.root_state.key_count, witness_bytes, certs, ext.lock.index, ext.lock.number, ext.lock.signed, ext.lock.total, sys, now());
|
||||
rows.push(serde_json::json!({ "number": f.block.env.number, "plain_cycles": c0, "fin_cycles": c1, "extra_cycles": c1.saturating_sub(c0), "witness_bytes": witness_bytes, "certificates": certs, "lock_index": ext.lock.index, "syscalls": sys.iter().map(|(k, v)| serde_json::json!({"name": k, "count": v})).collect::<Vec<_>>() }));
|
||||
prev_plain = Some(out_plain.to_bytes());
|
||||
prev_fin = Some(out_fin.to_bytes());
|
||||
}
|
||||
results.insert("blocks".into(), rows.into());
|
||||
results.insert("keys".into(), file.root_state.key_count.into());
|
||||
drop(sp1);
|
||||
return finish(results, out_path.clone());
|
||||
}
|
||||
if mode == "final-at" {
|
||||
// finality in the proof (docs/design/finality-in-proof.md section 4): the light client's question answered
|
||||
// from one verified proof's extension and, for an earlier block, a history proof; no node asked
|
||||
return run_final_at(&pinned, &arg("--proof").context("--proof <file>")?, arg("--block").as_deref(), arg("--leaves").as_deref());
|
||||
}
|
||||
let prover: Address = arg("--prover").map(|s| s.parse()).transpose()?.unwrap_or_else(|| Address::from_slice(&[0x19; 20]));
|
||||
// --fin <file>: the finality witness of every block aggregated (the statement gains the extension)
|
||||
let fin_path = arg("--fin");
|
||||
if mode == "aggregate" {
|
||||
// proving v1: the live aggregator, from shard proof files (the node's pool) and the previous segment proof
|
||||
return run_aggregate(&pinned, &arg("--proofs").context("--proofs <a.bin,b.bin,...> (the segment's shard proofs in shard order)")?, &arg("--parent").context("--parent 0x<parent chain block hash>")?, arg("--prev").as_deref(), out_path.as_deref());
|
||||
return run_aggregate(&pinned, &arg("--proofs").context("--proofs <a.bin,b.bin,...> (the segment's shard proofs in shard order)")?, &arg("--parent").context("--parent 0x<parent chain block hash>")?, arg("--prev").as_deref(), out_path.as_deref(), fin_path.as_deref());
|
||||
}
|
||||
if mode == "chain" {
|
||||
// proving v1: N consecutive fixtures proven shard by shard, each block aggregated with the previous block's
|
||||
|
|
@ -100,7 +161,7 @@ fn run() -> Result<()> {
|
|||
// the segment length, the chain rule of spec 7.8) instead of starting fresh. The app's segment path (6 October
|
||||
// 2026) passes it when the node reports the previous segment paid and its proof in the pool.
|
||||
let prev = arg("--prev");
|
||||
return run_chain(&pinned, &fixtures, prover, out_path.as_deref(), save_shards, prev.as_deref());
|
||||
return run_chain(&pinned, &fixtures, prover, out_path.as_deref(), save_shards, prev.as_deref(), fin_path.as_deref());
|
||||
}
|
||||
let path = args.get(1).filter(|a| !a.starts_with("--")).context("usage: igneum-prove-host <fixture.json> [--mode native|execute|shard|compressed|block|all] [--shard N] [--budget <test pgas>] [--prover 0x..] [--out results.json]; --mode chain --chain <f1,f2,...> [--prover 0x..] [--out results.json] [--save-shards] [--prev prev.bin]; --mode aggregate --proofs <a.bin,...> --parent 0x.. [--prev prev.bin] [--out results.json]; --mode verify --proof <file> --statement 0x..; --mode verify-segment --proof <file> --statement 0x..; --mode id")?;
|
||||
let shard_index: usize = arg("--shard").map(|s| s.parse()).transpose()?.unwrap_or(0);
|
||||
|
|
@ -222,7 +283,7 @@ fn run() -> Result<()> {
|
|||
if prev_root != outcome.state_root || sum_gas != outcome.gas_used || sum_pgas != outcome.pgas_used {
|
||||
bail!("the shards do not chain to the block's post-root or do not sum to its gas and pgas");
|
||||
}
|
||||
let native_block = agg::aggregate(&AggInput { shard_vk: [0; 8], shards: shards.iter().map(|s| s.output.to_bytes()).collect(), parent_hash: block.env.parent_hash, prev: None }, &mut |_, _| {});
|
||||
let native_block = agg::aggregate(&AggInput { shard_vk: [0; 8], shards: shards.iter().map(|s| s.output.to_bytes()).collect(), parent_hash: block.env.parent_hash, prev: None, fin: None }, &mut |_, _| {});
|
||||
if native_block.post_root != outcome.state_root || native_block.tx_commitment != outcome.tx_commitment || native_block.gas_used != outcome.gas_used {
|
||||
bail!("the native aggregation does not reproduce the block");
|
||||
}
|
||||
|
|
@ -625,7 +686,7 @@ fn out_dir_of(out_path: Option<&str>) -> std::path::PathBuf {
|
|||
/// `--mode chain`: every fixture in order, consecutive on the chain (number and parent hash), each block's shards
|
||||
/// proven compressed and aggregated with the previous block's aggregated proof (`AggInput.prev`, the chain rule),
|
||||
/// every proof verified. One RESULT line per shard, per block (with the running totals) and for the chain.
|
||||
fn run_chain(pinned: &pinned::Pinned, fixtures: &[String], prover: Address, out_path: Option<&str>, save_shards: bool, prev_path: Option<&str>) -> Result<()> {
|
||||
fn run_chain(pinned: &pinned::Pinned, fixtures: &[String], prover: Address, out_path: Option<&str>, save_shards: bool, prev_path: Option<&str>, fin_path: Option<&str>) -> Result<()> {
|
||||
if fixtures.is_empty() {
|
||||
bail!("--chain needs at least one fixture");
|
||||
}
|
||||
|
|
@ -678,6 +739,20 @@ fn run_chain(pinned: &pinned::Pinned, fixtures: &[String], prover: Address, out_
|
|||
}
|
||||
};
|
||||
let base_len = prev.as_ref().map(|p| p.output.chain_len).unwrap_or(0);
|
||||
// the finality witnesses, folded natively first (the known-finished case before any proof)
|
||||
let mut fin_inputs = match fin_path {
|
||||
None => Vec::new(),
|
||||
Some(p) => {
|
||||
let file = fin::load_witness(p)?;
|
||||
let inputs = fin::prepare(&file, first, prev.is_none())?;
|
||||
if inputs.len() != built.len() {
|
||||
bail!("{p} carries {} finality witnesses for {} blocks", inputs.len(), built.len());
|
||||
}
|
||||
println!("RESULT chain fin: {} finality witnesses folded natively, root state ends at chain block {} with {} key slots, {} ring and {} key leaves opened, {} certificates at {}", inputs.len(), file.root_state.end_number, file.root_state.key_count, file.blocks.iter().map(|b| b.ring.len()).sum::<usize>(), file.blocks.iter().map(|b| b.keys.len()).sum::<usize>(), file.blocks.iter().map(|b| b.witness.certificates.len()).sum::<usize>(), now());
|
||||
inputs
|
||||
}
|
||||
};
|
||||
fin_inputs.reverse();
|
||||
let sp1 = setup_sp1(pinned, &mut results)?;
|
||||
let chain_t = Instant::now();
|
||||
let mut blocks_json = Vec::with_capacity(built.len());
|
||||
|
|
@ -718,7 +793,10 @@ fn run_chain(pinned: &pinned::Pinned, fixtures: &[String], prover: Address, out_
|
|||
}
|
||||
shards_total += proofs.len();
|
||||
stage(&format!("chain block {number} aggregate {} shards{}", proofs.len(), if prev.is_some() { " with the previous block proof" } else { "" }));
|
||||
let seg = sp1.aggregate(prev.as_ref(), &proofs)?;
|
||||
let seg = sp1.aggregate_with(prev.as_ref(), &proofs, fin_inputs.pop())?;
|
||||
if let Some(f) = &seg.output.fin {
|
||||
println!("RESULT chain block {number} fin: table root {} history root {} lock index {} at chain block {} ({} of {} signed, frozen {} of {}){} at {}", B256::from(f.table_root), B256::from(f.history_root), f.lock.index, f.lock.number, f.lock.signed, f.lock.total, f.lock.frozen_signed, f.lock.frozen_total, if f.stale() { ", STALE" } else { "" }, now());
|
||||
}
|
||||
let adt = sp1.last_timing("aggregate").unwrap_or_default().as_secs_f64();
|
||||
let sdt = sp1.last_timing("aggregate-stdin").unwrap_or_default().as_secs_f64();
|
||||
agg_total += adt;
|
||||
|
|
@ -785,7 +863,7 @@ fn run_chain(pinned: &pinned::Pinned, fixtures: &[String], prover: Address, out_
|
|||
/// previous segment's aggregated proof when the chain continues. Each block is aggregated with the previous
|
||||
/// block's proof in one process (one key setup); the output is the last block's aggregated proof, its public
|
||||
/// values, the statement and the proof hash the segment record carries.
|
||||
fn run_aggregate(pinned: &pinned::Pinned, proofs: &str, parent: &str, prev_path: Option<&str>, out_path: Option<&str>) -> Result<()> {
|
||||
fn run_aggregate(pinned: &pinned::Pinned, proofs: &str, parent: &str, prev_path: Option<&str>, out_path: Option<&str>, fin_path: Option<&str>) -> Result<()> {
|
||||
let first_parent: B256 = parent.parse().context("--parent is not 32 bytes of hex")?;
|
||||
let mut results = serde_json::Map::new();
|
||||
results.insert("mode".into(), "aggregate".into());
|
||||
|
|
@ -831,6 +909,19 @@ fn run_aggregate(pinned: &pinned::Pinned, proofs: &str, parent: &str, prev_path:
|
|||
let first = blocks[0][0].output.number;
|
||||
let last = blocks[blocks.len() - 1][0].output.number;
|
||||
println!("igneum-prove-host sources {}: aggregate blocks {first}..={last} ({} shard proofs){}; {}", env!("IGNEUM_PROVE_SOURCES"), blocks.iter().map(|b| b.len()).sum::<usize>(), prev.as_ref().map(|p| format!(", chaining to block {} (chain_len {})", p.output.number, p.output.chain_len)).unwrap_or_default(), now());
|
||||
let mut fin_inputs = match fin_path {
|
||||
None => Vec::new(),
|
||||
Some(p) => {
|
||||
let file = fin::load_witness(p)?;
|
||||
let inputs = fin::prepare(&file, first, prev.is_none())?;
|
||||
if inputs.len() != blocks.len() {
|
||||
bail!("{p} carries {} finality witnesses for {} blocks", inputs.len(), blocks.len());
|
||||
}
|
||||
println!("RESULT aggregate fin: {} finality witnesses folded natively at {}", inputs.len(), now());
|
||||
inputs
|
||||
}
|
||||
};
|
||||
fin_inputs.reverse();
|
||||
let sp1 = setup_sp1(pinned, &mut results)?;
|
||||
let t_all = Instant::now();
|
||||
let mut per_block = Vec::new();
|
||||
|
|
@ -838,7 +929,10 @@ fn run_aggregate(pinned: &pinned::Pinned, proofs: &str, parent: &str, prev_path:
|
|||
let number = shards[0].output.number;
|
||||
stage(&format!("aggregate block {number}, {} shards", shards.len()));
|
||||
let deferred = shards.len() + usize::from(prev.is_some());
|
||||
let seg = sp1.aggregate(prev.as_ref(), shards)?;
|
||||
let seg = sp1.aggregate_with(prev.as_ref(), shards, fin_inputs.pop())?;
|
||||
if let Some(f) = &seg.output.fin {
|
||||
println!("RESULT aggregate block {number} fin: lock index {} at chain block {} ({} of {} signed){} at {}", f.lock.index, f.lock.number, f.lock.signed, f.lock.total, if f.stale() { ", STALE" } else { "" }, now());
|
||||
}
|
||||
let adt = sp1.last_timing("aggregate").unwrap_or_default().as_secs_f64();
|
||||
let sdt = sp1.last_timing("aggregate-stdin").unwrap_or_default().as_secs_f64();
|
||||
let claim = SegmentClaim::from_block(&seg.output);
|
||||
|
|
@ -911,6 +1005,42 @@ fn run_verify_segment(pinned: &pinned::Pinned, proof_path: &str, statement: &str
|
|||
}
|
||||
}
|
||||
|
||||
/// `--mode final-at --proof <file> [--block <query.json>] [--leaves N]`: the light client of design section 4.
|
||||
/// Verifies the segment proof with the light verifier against the pinned aggregator key, then answers from the
|
||||
/// extension: the proof's own last block, or the block a history leaf and proof name. Exit 0 = final, 4 = not
|
||||
/// final, 5 = stale or no claim, 3 = the proof did not verify.
|
||||
fn run_final_at(pinned: &pinned::Pinned, proof_path: &str, block_path: Option<&str>, leaves: Option<&str>) -> Result<()> {
|
||||
use sp1_sdk::blocking::{LightProver, Prover};
|
||||
let bytes = std::fs::read(proof_path).with_context(|| format!("read {proof_path}"))?;
|
||||
let t = Instant::now();
|
||||
let verifier = LightProver::new();
|
||||
let proof: sp1_sdk::SP1ProofWithPublicValues = bincode::deserialize(&bytes).context("the file is not a bincode SP1 proof")?;
|
||||
let same_program = pinned::claimed_program_id(&proof) == Some(pinned.agg_id);
|
||||
let output = BlockOutput::from_bytes(proof.public_values.as_slice()).context("public values are not a block statement")?;
|
||||
let ids_ok = output.shard_vk == pinned.shard_id && (output.agg_vk == pinned.agg_id || (output.chain_len == 1 && output.agg_vk == B256::ZERO));
|
||||
let ok = same_program && ids_ok && verifier.verify(&proof, &pinned.agg_vk, None).is_ok();
|
||||
let vdt = t.elapsed().as_secs_f64();
|
||||
if !ok {
|
||||
println!("RESULT final-at: the proof did not verify ({:.3} s) at {}", vdt, now());
|
||||
std::process::exit(3)
|
||||
}
|
||||
let t = Instant::now();
|
||||
let query = block_path.map(fin::load_query).transpose()?;
|
||||
let history_leaves = match (leaves, &output.fin) {
|
||||
(Some(n), _) => n.parse::<u64>().context("--leaves N")?,
|
||||
(None, Some(ext)) => fin::leaves_hint(ext, &output),
|
||||
(None, None) => 0,
|
||||
};
|
||||
let a = fin::answer(&output, history_leaves, query.as_ref());
|
||||
let adt = t.elapsed().as_secs_f64();
|
||||
println!("RESULT final-at: {}; proof of chain block {} ({}) chain_len {} verified in {vdt:.3} s, answered in {adt:.6} s from {} bytes of public values; {} at {}", fin::describe(&a), output.number, output.block_hash, output.chain_len, proof.public_values.as_slice().len(), output.fin.as_ref().map(|f| format!("history root {} first {} lock {} at block {} signed {} of {}", B256::from(f.history_root), f.history_first, f.lock.index, f.lock.number, f.lock.signed, f.lock.total)).unwrap_or_else(|| "no extension".into()), now());
|
||||
match a {
|
||||
fin::Answer::Final { .. } => Ok(()),
|
||||
fin::Answer::NotFinal { .. } | fin::Answer::NotInChain => std::process::exit(4),
|
||||
fin::Answer::Stale { .. } | fin::Answer::NoClaim => std::process::exit(5),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_shard_output(out: &ShardOutput, native: &ShardOutput) -> Result<()> {
|
||||
if out != native {
|
||||
bail!("the guest's public values differ from the native run:\n guest {out:?}\n native {native:?}");
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ impl ProofSystem for StubProofSystem {
|
|||
}
|
||||
}
|
||||
let parent_hash = B256::ZERO;
|
||||
let input = AggInput { shard_vk: [0; 8], shards: shards.iter().map(|s| s.output.to_bytes()).collect(), parent_hash, prev: None };
|
||||
let input = AggInput { shard_vk: [0; 8], shards: shards.iter().map(|s| s.output.to_bytes()).collect(), parent_hash, prev: None, fin: None };
|
||||
let out = agg::aggregate(&input, &mut |_, _| {});
|
||||
let claim = SegmentClaim::from_block(&out);
|
||||
Ok(StubProof { mac: self.mac(claim.digest()), claim })
|
||||
|
|
@ -223,13 +223,20 @@ impl Sp1ProofSystem {
|
|||
/// Executes the aggregator over the shards' public values without proofs (deferred verification off):
|
||||
/// the cycle count of the aggregation statement itself.
|
||||
pub fn execute_aggregator(&self, shard_outputs: &[ShardOutput], parent_hash: B256) -> Result<(BlockOutput, sp1_sdk::ExecutionReport, Duration)> {
|
||||
let input = AggInput { shard_vk: self.shard_vk_hash(), shards: shard_outputs.iter().map(|o| o.to_bytes()).collect(), parent_hash, prev: None };
|
||||
self.execute_aggregator_with(shard_outputs, parent_hash, None, None)
|
||||
}
|
||||
|
||||
/// The aggregator in execute mode with the previous block's public values and the finality input (the cycle
|
||||
/// count of docs/design/finality-in-proof.md section 6.1; deferred proof verification off, as above).
|
||||
pub fn execute_aggregator_with(&self, shard_outputs: &[ShardOutput], parent_hash: B256, prev_public_values: Option<Vec<u8>>, fin: Option<igneum_prove_core::agg::FinInput>) -> Result<(BlockOutput, sp1_sdk::ExecutionReport, Duration)> {
|
||||
let prev = prev_public_values.map(|public_values| PrevLink { agg_vk: self.agg_vk_hash(), public_values });
|
||||
let input = AggInput { shard_vk: self.shard_vk_hash(), shards: shard_outputs.iter().map(|o| o.to_bytes()).collect(), parent_hash, prev, fin };
|
||||
let mut stdin = SP1Stdin::new();
|
||||
stdin.write_vec(bincode::serialize(&input)?);
|
||||
let t = Instant::now();
|
||||
let (pv, report) = self.client.execute(self.agg_pk.elf().clone(), stdin).deferred_proof_verification(false).calculate_gas(true).run().map_err(|e| anyhow!("{e}"))?;
|
||||
let dt = t.elapsed();
|
||||
let out = BlockOutput::from_bytes(pv.as_slice()).ok_or_else(|| anyhow!("block public values are {} bytes, expected {}", pv.as_slice().len(), BlockOutput::LEN))?;
|
||||
let out = BlockOutput::from_bytes(pv.as_slice()).ok_or_else(|| anyhow!("block public values are {} bytes, expected {} or {}", pv.as_slice().len(), BlockOutput::LEN, BlockOutput::LEN2))?;
|
||||
Ok((out, report, dt))
|
||||
}
|
||||
|
||||
|
|
@ -295,34 +302,12 @@ impl ProofSystem for Sp1ProofSystem {
|
|||
|
||||
/// The aggregator guest over the shard proofs (and the previous segment's proof when given), by recursion.
|
||||
fn aggregate(&self, prev: Option<&Sp1SegmentProof>, shards: &[Sp1ShardProof]) -> Result<Sp1SegmentProof> {
|
||||
let first = shards.first().ok_or_else(|| anyhow!("no shards"))?;
|
||||
// 5 October 2026 (aggregation cost): the stdin build (the proof clones into the request) is timed apart
|
||||
// from the prove call, so the host's own share of an aggregation is visible next to the GPU's.
|
||||
let t_stdin = Instant::now();
|
||||
let mut stdin = SP1Stdin::new();
|
||||
let input = AggInput {
|
||||
shard_vk: self.shard_vk_hash(),
|
||||
shards: shards.iter().map(|s| s.output.to_bytes()).collect(),
|
||||
parent_hash: first.parent_hash,
|
||||
prev: prev.map(|p| PrevLink { agg_vk: self.agg_vk_hash(), public_values: p.proof.public_values.to_vec() }),
|
||||
};
|
||||
stdin.write_vec(bincode::serialize(&input)?);
|
||||
for s in shards {
|
||||
let SP1Proof::Compressed(proof) = s.proof.proof.clone() else { return Err(anyhow!("shard {} is not a compressed proof", s.output.shard_index)) };
|
||||
stdin.write_proof(*proof, self.shard_vk.vk.clone());
|
||||
}
|
||||
if let Some(p) = prev {
|
||||
let SP1Proof::Compressed(proof) = p.proof.proof.clone() else { return Err(anyhow!("the previous block proof is not a compressed proof")) };
|
||||
stdin.write_proof(*proof, self.agg_vk.vk.clone());
|
||||
}
|
||||
self.record("aggregate-stdin", t_stdin.elapsed());
|
||||
let t = Instant::now();
|
||||
let proof = self.client.prove(&self.agg_pk, stdin).compressed().run()?;
|
||||
self.record("aggregate", t.elapsed());
|
||||
let output = BlockOutput::from_bytes(proof.public_values.as_slice()).ok_or_else(|| anyhow!("block public values have the wrong length"))?;
|
||||
Ok(Sp1SegmentProof { proof, output })
|
||||
self.aggregate_with(prev, shards, None)
|
||||
}
|
||||
|
||||
fn pgas_table(&self) -> &PgasTable {
|
||||
&self.table
|
||||
}
|
||||
fn wrap(&self, _p: &Sp1SegmentProof) -> Result<Sp1WrappedProof> {
|
||||
Err(anyhow!("wrap (Groth16 or Plonk over bn254) is not run: it needs SP1's circuit artifacts and is the ledger P3 measurement"))
|
||||
}
|
||||
|
|
@ -340,8 +325,38 @@ impl ProofSystem for Sp1ProofSystem {
|
|||
let Some(out) = BlockOutput::from_bytes(p.0.public_values.as_slice()) else { return false };
|
||||
self.client.verify(&p.0, &self.agg_vk, None).is_ok() && &SegmentClaim::from_block(&out) == claim
|
||||
}
|
||||
}
|
||||
|
||||
fn pgas_table(&self) -> &PgasTable {
|
||||
&self.table
|
||||
impl Sp1ProofSystem {
|
||||
/// The aggregation with the finality fold (docs/design/finality-in-proof.md): `fin` is this chain block's
|
||||
/// finality input; the guest folds it and the statement gains the extension.
|
||||
pub fn aggregate_with(&self, prev: Option<&Sp1SegmentProof>, shards: &[Sp1ShardProof], fin: Option<igneum_prove_core::agg::FinInput>) -> Result<Sp1SegmentProof> {
|
||||
let first = shards.first().ok_or_else(|| anyhow!("no shards"))?;
|
||||
// 5 October 2026 (aggregation cost): the stdin build (the proof clones into the request) is timed apart
|
||||
// from the prove call, so the host's own share of an aggregation is visible next to the GPU's.
|
||||
let t_stdin = Instant::now();
|
||||
let mut stdin = SP1Stdin::new();
|
||||
let input = AggInput {
|
||||
shard_vk: self.shard_vk_hash(),
|
||||
shards: shards.iter().map(|s| s.output.to_bytes()).collect(),
|
||||
parent_hash: first.parent_hash,
|
||||
prev: prev.map(|p| PrevLink { agg_vk: self.agg_vk_hash(), public_values: p.proof.public_values.to_vec() }),
|
||||
fin,
|
||||
};
|
||||
stdin.write_vec(bincode::serialize(&input)?);
|
||||
for s in shards {
|
||||
let SP1Proof::Compressed(proof) = s.proof.proof.clone() else { return Err(anyhow!("shard {} is not a compressed proof", s.output.shard_index)) };
|
||||
stdin.write_proof(*proof, self.shard_vk.vk.clone());
|
||||
}
|
||||
if let Some(p) = prev {
|
||||
let SP1Proof::Compressed(proof) = p.proof.proof.clone() else { return Err(anyhow!("the previous block proof is not a compressed proof")) };
|
||||
stdin.write_proof(*proof, self.agg_vk.vk.clone());
|
||||
}
|
||||
self.record("aggregate-stdin", t_stdin.elapsed());
|
||||
let t = Instant::now();
|
||||
let proof = self.client.prove(&self.agg_pk, stdin).compressed().run()?;
|
||||
self.record("aggregate", t.elapsed());
|
||||
let output = BlockOutput::from_bytes(proof.public_values.as_slice()).ok_or_else(|| anyhow!("block public values have the wrong length"))?;
|
||||
Ok(Sp1SegmentProof { proof, output })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
104
site/verify/finproof.js
Normal file
104
site/verify/finproof.js
Normal file
|
|
@ -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)';
|
||||
|
|
@ -12,6 +12,8 @@
|
|||
# tools/build-remote.sh --jobs 48 -- check
|
||||
# tools/build-remote.sh --target-dir target-exp -- build --release another persistent target dir on the box
|
||||
# tools/build-remote.sh --no-fetch -- clippy --all-targets nothing comes back (tests, check, clippy)
|
||||
# tools/build-remote.sh --measure --no-fetch -- run --release -p <crate> -- <args> a MEASUREMENT under the box's measure
|
||||
# hold (every build waits, as with-lock.sh measure)
|
||||
# tools/build-remote.sh --ship [hive|rig|seed|linux] [--glibc X.Y] anything that SHIPS: cargo zigbuild for
|
||||
# x86_64-unknown-linux-gnu.<glibc> where the glibc comes
|
||||
# from the class (hive/rig 2.31: HiveOS is Ubuntu 20.04
|
||||
|
|
@ -71,6 +73,7 @@ while [ $# -gt 0 ]; do
|
|||
--artefacts) ARTEFACTS="$2"; ARTEFACTS_SET=1; shift 2 ;;
|
||||
--target-dir) TARGET_DIR="$2"; shift 2 ;;
|
||||
--no-fetch) FETCH=0; shift ;;
|
||||
--measure) export BR_MEASURE=1; shift ;; # a measurement: the box's measure hold (remote-run.sh BR_MEASURE=1), builds wait
|
||||
--self-test-repro) SELFTEST=1; shift ;;
|
||||
--ship) SHIP=1; case "${2:-}" in hive|rig|seed|linux|native) SHIP_CLASS="$2"; shift 2 ;; *) shift ;; esac ;;
|
||||
--glibc) GLIBC="$2"; shift 2 ;;
|
||||
|
|
|
|||
Loading…
Reference in a new issue