Merge remote-tracking branch 'build/master' into reference-apps
This commit is contained in:
commit
d987dc9038
37 changed files with 6091 additions and 101 deletions
121
docs/analysis/proving-pipeline-2026-10-08.md
Normal file
121
docs/analysis/proving-pipeline-2026-10-08.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# Devnet 3 proving pipeline, end to end, 8 October 2026
|
||||
|
||||
The external review's order (through the coordinator, 15:4x BST): every paid shard's time in each stage, separated, with the median and
|
||||
the slowest 5 and 1 percent; the failure and retry rate; the queue depth over time; realised earnings and wasted work per hardware tier,
|
||||
which cards complete paid work after the 10 DAA-second exclusive window, and how often a faster claimant takes an assigned prover's reward.
|
||||
|
||||
## The window and its limit
|
||||
|
||||
The measurement window is the whole of 8 October's proving on the rented fleet, 07:00:26Z (first claim) to 14:27:15Z (last claim), read
|
||||
from every prover box's own log after Devnet 3 was turned off at 15:34Z. Paid work exists only between 07:46:55Z and 10:43:10Z: 93 paid
|
||||
segments, 172.88 IGN. From 11:45Z the chain stalled at the class v5 crossing and then partitioned (every solo branch carried old-object
|
||||
blocks, the network restarted from the stall sink at 15:27Z and was turned off at 15:34Z), so every segment claimed after 11:45Z was
|
||||
submitted into a chain that never paid it. The hold's declared workload (150 tx/s) ran 11:42Z to 12:23Z with inclusion, then without, so
|
||||
no paid shard carries a hold transaction: the stage columns below are the fleet's proving of the chain's own blocks, under the pre-stall
|
||||
load (the DEX and faucet lanes, the hold's earlier steps), on the 0.3.24 node (5b673577). The window asked for, two hours under the hold,
|
||||
does not exist in the record; this is the honest substitute, and the instrument is in place for the next chain.
|
||||
|
||||
## The instrument
|
||||
|
||||
Collector `tools/fleet/pipeline-collect.py` (hub-1's Devnet 3 node, `igneum_getProvingStatus` every 30 s; every prover's RESULT lines
|
||||
every 5 min) and the per-box logs `/root/fleet/out/prover.log` written by `tools/fleet/box-prover.py`, pulled whole after the stop. Each
|
||||
column names its lines.
|
||||
|
||||
| column | source lines in prover.log | how the number is read |
|
||||
|---|---|---|
|
||||
| assignment wait | `RESULT claim <t> segment A..B (n shards, fresh) margin=M tip=T` | not separable from the logs: a segment becomes claimable when its last block settles and the box claims on its next pass (passes every 15 s). The proxy recorded is the margin at claim, the DAA left before the deadline (600 DAA window): median 467, p5 (slowest) 557 is not a wait but an early claim. Block timestamps would give the wait exactly; Devnet 3 is off, so they are not read. |
|
||||
| inputs | claim stamp to the chain's start (the `RESULT seg N chain` stamp minus its `wall`) | the export of the segment's records from the node, the pair check and the cuts |
|
||||
| proving | `RESULT seg N chain <t> k shard records, chain_len c, proof b bytes, shards P s, aggregation A s, wall W s, peak MiB` field P | the shard proofs on the card (SP1 floor server) |
|
||||
| aggregation | the same line's A | the segment chain over the shard proofs |
|
||||
| verification | chain stamp to `RESULT seg N shards <t> accepted k of n` | the node's verification of each shard record at submission; it answers inside the second, so verification and submission are one column |
|
||||
| inclusion and payment | `RESULT submitted <t> ... end to end E s` to `RESULT paid <t> ... after S s` | S is the prover's own clock from the record's acceptance to the payment read on its node |
|
||||
| failure, retry | `RESULT seg N ... FAILED`, `RESULT segment_refused`, `RESULT unpaid`, `RESULT paid_other`, `record accepted on retry` | counted per kind |
|
||||
| queue depth | `RESULT pass n <t> no whole segment inside the margin (worklist N entries, tip T)` | N is the node's assigned-shard worklist as the prover reads it on each idle pass |
|
||||
|
||||
## Stage columns, seconds, every segment that reached the stage
|
||||
|
||||
| stage | n | median | slowest 5 % | slowest 1 % | max |
|
||||
|---|---|---|---|---|---|
|
||||
| inputs (claim to export and cuts done) | 2,447 | 2.4 | 7.5 | 10.1 | 14.7 |
|
||||
| proving (shard proofs) | 2,453 | 26.8 | 216.1 | 364.7 | 1,104.7 |
|
||||
| aggregation (segment chain) | 2,453 | 22.8 | 44.2 | 96.4 | 156.6 |
|
||||
| verification and shard-record submission | 2,453 | 0.0 | 2.0 | 2.0 | 10.0 |
|
||||
| segment-record submission | 1,909 | 0.0 | 1.0 | 1.0 | 1.0 |
|
||||
| claim to submitted (end to end) | 1,909 | 75.1 | 230.3 | 301.6 | 497.4 |
|
||||
| inclusion and payment (submitted to paid) | 93 | 171.0 | 543.0 | 31,397 | 31,470 |
|
||||
| claim to paid | 91 | 336.0 | 720.0 | 1,098 | 1,240 |
|
||||
| peak GPU memory during the chain, MiB | 2,453 | 11,948 | 21,174 | 25,788 | 26,210 |
|
||||
|
||||
The two 31,000-second payments are segments submitted before the 02:4xZ pause and paid when the chain resumed; without them the
|
||||
inclusion-and-payment p99 is 902 s. The assignment wait is not in the table (see the instrument row).
|
||||
|
||||
## Paid segments per tier
|
||||
|
||||
| tier | paid segments | IGN | proving median s | aggregation median s | payment median s | payment p95 s |
|
||||
|---|---|---|---|---|---|---|
|
||||
| RTX 4090 | 48 | 89.33 | 117.3 | 20.1 | 177.5 | 649 |
|
||||
| RTX 3090 | 34 | 59.66 | 69.8 | 75.0 | 166.0 | 543 |
|
||||
| L40S | 10 | 21.86 | 67.0 | 18.7 | 125.0 | 370 |
|
||||
| RTX 6000 Ada | 1 | 2.03 | 20.2 | 18.4 | 116.0 | 116 |
|
||||
| RTX 3060 (12 GB) | 0 | 0 | | | | |
|
||||
|
||||
The 3090's aggregation median (75 s) is three times the 4090's: the segment chain is memory-bound and the 3090 pays for it. The 4090's
|
||||
proving median on paid segments (117 s) is above the all-segment median (27 s) because paid segments are the long ones (the short ones
|
||||
were taken by a faster claimant, below).
|
||||
|
||||
## Outcomes per tier and wasted work
|
||||
|
||||
| tier | claimed | paid | stolen | refused | submitted, never paid | claimed, never submitted | work s paid | work s wasted |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| RTX 4090 | 2,515 | 48 | 98 | 93 | 1,322 | 959 | 7,025 | 183,524 |
|
||||
| RTX 3060 12 GB | 313 | 0 | 0 | 0 | 34 | 280 | 0 | 6,765 |
|
||||
| L40S | 273 | 10 | 25 | 28 | 147 | 63 | 1,196 | 26,026 |
|
||||
| RTX 3090 | 263 | 34 | 8 | 30 | 152 | 41 | 6,484 | 34,855 |
|
||||
| RTX 6000 Ada | 45 | 1 | 6 | 0 | 26 | 12 | 57 | 3,055 |
|
||||
|
||||
- "submitted, never paid" (1,681 segments) is the partition: records accepted into a chain that never settled them after 11:45Z. It is
|
||||
the day's largest waste and is not a pipeline fault; it is the fault of the afternoon (the fleet record).
|
||||
- "claimed, never submitted" (1,355): the chain step failed or the claim was abandoned. The failures are counted below.
|
||||
- The 3060 tier completed no paid segment in 313 claims: at 12 GB the chain runs out of margin (its proving median on completed chains
|
||||
is above the 4090's by the card's ratio, and a 4090 claimant finishes the same segment first), so the 12 GB tier is a miner, not a
|
||||
prover, on this segment size. The 3060's 34 submitted segments were all after 11:45Z (never paid for the partition's reason).
|
||||
- Wasted work is the end-to-end seconds of every claimed segment that was not paid; the fleet spent 254,000 card-seconds (70 card-hours)
|
||||
on segments that did not pay against 14,800 (4.1 card-hours) that did. Before the stall the ratio was about 3 to 1 (the steals and
|
||||
refusals below); after it, everything was waste.
|
||||
|
||||
## Failures and retries
|
||||
|
||||
- `RESULT seg N chain FAILED`: 900, median wall 2.7 s. 629 "NotFound: No such file or directory": the sm_89 floor tarball's
|
||||
`igneum-prove-host` was a dangling link until the real host was served at 10:50Z (08:00 to 10:59Z, the fleet record's floor fault);
|
||||
264 "invalid string length" (the host's proof-bytes string on the 48 GB cards' larger segments); 4 OutOfMemory (12 GB cards). After
|
||||
10:50Z the NotFound class ended; the string-length class remains open for the node lane.
|
||||
- `RESULT segment_refused`: 153. 62 "does not chain to segment N..N" (the previous segment's record moved under the claim), 54 "unproven:
|
||||
the record is carried after the segment's deadline" (the chain step finished too late), 35 "segment already paid" (a faster claimant).
|
||||
- Retries: 0 lines "record accepted on retry". The prover does not retry a failed chain; it drops the export and claims afresh.
|
||||
- Failure rate on claims: 900 chain failures plus 153 refusals over 3,421 claims is 30.8 percent; without the floor-link class (fixed) it
|
||||
is 12.5 percent.
|
||||
|
||||
## Steals: a faster claimant takes an assigned prover's reward
|
||||
|
||||
`RESULT paid_other`: 137 segments this box had claimed were paid to another key, 4.0 percent of claims (98 on 4090s, 25 on L40S, 8 on
|
||||
3090s, 6 on the 6000 Ada). The time from this box's claim to the other key's payment read: median 306 s, p95 9,757 s (the long tail is the
|
||||
same pause-and-resume as the payment column). The exclusive window (10 DAA seconds) does not hold a slow claimant's segment for it: a
|
||||
second box that finishes its chain first is paid. The 3060 tier was never the winner and never the victim (it never finished).
|
||||
|
||||
## Queue depth over time
|
||||
|
||||
The worklist as the provers read it on idle passes, median entries per hour (tip in the line): 02Z 596, 05Z 596, 08Z 596, 11Z 713, 14Z
|
||||
594. Bounded at about 600 entries (the 600 DAA unproven window times one entry a DAA) for the whole day; it did not grow, because a
|
||||
segment leaves the list at its deadline whether proved or not. Growth would show the window itself lengthening; it did not.
|
||||
|
||||
## What this means
|
||||
|
||||
- With the floor link fixed, the pipeline's own stages are fast: inputs 2 s, verification and submission under 2 s, aggregation 23 s
|
||||
(75 s on a 3090); the proving stage sets the pace, 27 s median and 216 s at the slowest 5 percent, and payment lands 171 s after
|
||||
submission (543 s at the slowest 5 percent) when the chain settles.
|
||||
- The waste is structural, not incidental: 70 card-hours wasted against 4 paid, dominated by the partition, then by the floor fault,
|
||||
then by steals and late chains (13 percent of claims). Two changes would cut the pre-stall waste: a claim that is honoured for the
|
||||
window it was granted (the steal rate goes to zero) and a 12 GB tier that claims only segments it can finish (the 3060's 313 claims
|
||||
earned nothing).
|
||||
- The next chain (igneum-devnet-4) starts this instrument from block zero; the two-hour window under the hold's declared workload is
|
||||
the first measurement to run on it, with block timestamps read so the assignment wait becomes a real column.
|
||||
|
|
@ -2819,3 +2819,129 @@ What the rows say.
|
|||
2. The hot packs hold their rate under the lock far better than mx8: the 1,300 MHz lock costs mx8 7.5 percent of rate (137.7 to 127.3) and costs the hot packs 2 percent (hot32k4 147.4 to 144.4, hot64k8 164.2 to 160.9). The hot packs are bound by the table's latency, not by the core; the lock takes a third of the watts off every pack (319 to 215 W) and the hot packs pay almost no rate for it.
|
||||
3. Per watt at the lock the hot family runs 0.52 to 0.73 MH/W against the control's 0.60: hot64k8 is 22 percent cheaper per hash than mx8 on this card, hot32k4 11 percent cheaper, the "a" packs 10 to 13 percent dearer. Whether a cheaper hash on the GPU is a gain or a loss for resistance is the research lane's call: it is a gain only if the saving comes from the memory path an ASIC would have to buy too.
|
||||
4. The 5090's locked class v4 reading from the efficiency pass (1,300 MHz: 134.6 MH/s at 223 W, 0.60 MH/W) sits level with the mx8 control here (0.602), so the two passes agree on the control and the hot rows are comparable to the v4 grid.
|
||||
## 7 October 2026, 10:08 UK: igneum-testnet-1 re-cut, the two-node 18-decimal gate (GPU form) PASS
|
||||
|
||||
The re-cut testnet object (genesis `01294fd3...`, digest `80af8aa1...`, every switch on from genesis; `docs/plans/testnet-go.md`
|
||||
"The genesis, re-cut") on igneumd 8a6f1f56... and igneum-miner 726cf290... (fork `testnet-genesis-2-node` f1717419), the
|
||||
gate script `tools/fleet/base-unit-gate.sh` at 72b02b48, run by the fleet lane on a one-shot RunPod RTX 3090.
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| both nodes agree | 577 blocks, one sink, exec tips 0x1a5 and 0x1a5 |
|
||||
| blocks mined in 600 s | 577 (421 chain blocks) against the minimum 60 |
|
||||
| the UTXO side, last 30 blocks | identical on both nodes; 30 payload subsidies at the 18-decimal schedule, 0 wrong; 80/20 exact on 18 of 18 single-payee coinbases |
|
||||
| the execution layer | balance equal on both nodes; 577 rewards against the schedule at each blue block's own DAA, 0 wrong; the sum of the rewards equals eth_getBalance: 4,617,536,546,296,296,296,298 wei = 4,617.53654629 IGN |
|
||||
| the start-up lines | `Base unit: 10^18`, digest `80af8aa1...`, ladder active at rung 0, fees v1 from DAA 0, proof verification from DAA 0 under shard `0x2b1a81cb...` and aggregator `0x474678f3...` |
|
||||
|
||||
Consequences: the layout, the switches and the bridge hold at 18 decimals on real GPU blocks; no tier changes (docs/design/base-unit.md
|
||||
section 5 stands). The first run of the form read 165 of 561 rewards one ramp step low because the gate priced every reward at the
|
||||
chain block's DAA while the object credits each merged block its own (subsidy_per_block_activation_daa 0): the script, not the node.
|
||||
|
||||
## 7 October 2026, 11:12 UK: igneum-testnet-1 re-cut, the object at fast time (devnet-suffix, 5 nodes) PASS
|
||||
|
||||
`infra/fast-time/testnet-object.mjs` run 10 on igneum-build-1 (the archive binaries, fork `testnet-genesis-2-node` 5c25c1fb; network
|
||||
igneum-devnet-973 on the 60x profile with every switch of `infra/seed-nodes/testnet-object.json` from genesis, the fast-time finality
|
||||
profile, leave delay 60; four voting CPU miners and one `--no-vote` key; 600 s, the leave at 240 s).
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| start-up lines, one digest | every node: ladder active at rung 0, fees v1 from DAA 0, verification from DAA 0 under the pinned ids, rule v3 and C1 from DAA 0, the leave rule from DAA 0 |
|
||||
| class v4 at rung 0 | ten epochs, 27 shadow passes, no ladder bits on the chain, object byte 0 |
|
||||
| the chain | 551 blocks, 525 chain blocks, 0 rejected, one sink on five nodes |
|
||||
| finality | first lock at DAA 142 (172 s), lock 18 at the end, active |
|
||||
| the signing bonus (UTXO side) | the silent key's 87 blocks at 72 percent of each block's own subsidy; the voters' 320 at 80 percent |
|
||||
| the bridge identity | UTXO payments x 10^10 = execution credits for all five miners over the chain (the tip's own credit set aside) |
|
||||
| the leave item | accepted at DAA 212, voters 5 to 4 at DAA 351 (within the delay and a window), longest pause after it 1 s |
|
||||
|
||||
Consequences: a miner that stops signing while in the table earns 72 percent on both ledgers, never 80 on one and 72 on the other (the
|
||||
N7 fix, c7ea1e21); a side block merged one DAA late is credited its own subsidy on both ledgers (the N8 fix, d840537b, from genesis);
|
||||
a miner that leaves cleanly is out of every denominator an hour after its leave is carried on the testnet (60 DAA here) and holds
|
||||
nothing; nothing changes for hash rate, power or any tier's hardware.
|
||||
|
||||
## 7 October 2026, 14:36 UK: igneum-testnet-1 re-cut, the late-join gate (proof archive, ledger N9 second half) PASS
|
||||
|
||||
`infra/fast-time/tn-late-join.mjs` on a one-shot RunPod 3070 pod (os-latejoin-ylp1), the fast-time join shape of ca3-v4-node e5f993d4 with
|
||||
consensus proof verification and proving v0 from genesis in the override, a CPU prover beside node A (`infra/fast-time/tn-prover-loop.mjs`).
|
||||
|
||||
| Side | Binary | Result |
|
||||
|---|---|---|
|
||||
| node A | archive binary 57ad7dc2... (fork 5c25c1fb, aea0ca5c in) | 6,297 DAA, 37 proofs carried and verified, 34 in the archive, pruning point at DAA 1,679 |
|
||||
| B, known-failed first | 57ad7dc2... (before 70e4601e) | stalled at DAA 1,828 six times, "proofs this peer did not deliver in 20 s", while A held the file |
|
||||
| B, the fix | fbed53cd... (fork 26e648ff, 70e4601e in) | headers-proof IBD completed, A's sink reached in 35 s, 4,618 headers, 0 errors |
|
||||
|
||||
Consequences: with `proving_consensus_verify_daa` 0 from genesis a node joining igneum-testnet-1 after the pool's window syncs from the
|
||||
pruning point and fetches every proof above it from any peer's archive (one 1.27 MB file per carried proof, kept for the pruning window:
|
||||
108,000 DAA on the testnet, so about 30 hours of proofs, at most a few GB on a seed); before 70e4601e such a joiner never finished its
|
||||
IBD. The fix is on `testnet-genesis-2-node` (26e648ff) and the node lane's `proof-hold-fix`; the 0.3.20 line takes it from there.
|
||||
|
||||
## 7 October 2026, 15:19 UK: igneum-testnet-1 re-cut FINAL, the two-node 18-decimal gate (GPU form) PASS on the final object
|
||||
|
||||
The final object (genesis `01294fd3...`, digest `4fbb2152...`, every switch on from genesis, the genesis forward-compatibility fields in;
|
||||
`docs/plans/testnet-go.md` "The genesis, re-cut") on igneumd 58f96049... and igneum-miner 5625caee... (fork `testnet-genesis-2-node`
|
||||
e6dd3afd), `tools/fleet/base-unit-gate.sh` 72b02b48, run by the fleet lane on a one-shot RunPod RTX A5000.
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| both nodes agree | one sink, exec tips 0x142 and 0x142 |
|
||||
| blocks mined in 600 s | 322 chain blocks against the minimum 60 |
|
||||
| the UTXO side, last 30 blocks | identical on both nodes; 30 payload subsidies at the 18-decimal schedule, 0 wrong |
|
||||
| the execution layer | 491 rewards against the schedule at each blue block's own DAA, 0 wrong; the sum of the rewards equals eth_getBalance on both nodes: 3,929,111,342,592,592,592,602 wei = 3,929.11134259 IGN |
|
||||
| the start-up lines | `Base unit: 10^18`, digest `4fbb2152...`, `igneumd/2.1.0-e6dd3afd`, genesis `01294fd3...` executed, chain id 4462 |
|
||||
| the known-failed form | the 8-decimal schedule against the same chain: payloads wrong 30 of 30, rewards wrong 305 of 305, FAIL |
|
||||
|
||||
Consequences: the cut's four gates are green on one object (this gate, the fast-time line of 11:12 UK, the late-join gate of 14:36 UK, the
|
||||
suites); what the seeds' cut-over needs is the founder's go alone (the build-server lane's `wave1-0320.sh seeds` line is armed and dry-run clean on
|
||||
these values). No tier changes: the base-unit costs of docs/design/base-unit.md section 5 stand, a Windows node needs igneum-prove-host
|
||||
beside it, a miner that stops cleanly sends its leave, a silent key earns 72 percent on both ledgers.
|
||||
|
||||
## 8 October 2026, 10:43 UK: igneum-testnet-1 on the 0.3.24 pin 5b673577, the two-node 18-decimal gate (GPU form) PASS
|
||||
|
||||
The go object as landed (the 0.3.24 node pin 5b673577: the Devnet 3 object commit plus the 18-decimal re-cut; genesis `01294fd3...`, digest
|
||||
`b2e856ed...`; `docs/plans/testnet-go.md` "The go object"), the gate artefact igneumd a3b1a2c9... and igneum-miner cfa9f5ca..., the script
|
||||
`tools/fleet/base-unit-gate.sh` at 940d91de (`--nodnsseed` on both nodes), run by the fleet hand on a one-shot RunPod A5000 (pod 2 after a
|
||||
3090 whose GPU was dead for CUDA).
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| both nodes agree | one sink, one exec tip |
|
||||
| blocks mined in 600 s | 340 chain blocks against the minimum 60 (41.65 MH/s on the hive 0.3.24 CUDA worker) |
|
||||
| the UTXO side, last 30 blocks | identical on both nodes; 30 payload subsidies at the 18-decimal schedule, 0 wrong; 80/20 exact on 20 of 20 single-payee coinbases |
|
||||
| the execution layer | 512 rewards against the schedule at each blue block's own DAA, 0 wrong; the sum of the rewards equals eth_getBalance on both nodes: 4,097,208,879,629,629,629,622 wei = 4,097.20887963 IGN |
|
||||
| the start-up lines | both heads on digest `b2e856ed...`; no public seed dialled |
|
||||
| the known-failed form | the 8-decimal schedule against the same chain shape (420 s, 286 blocks): payloads wrong 30 of 30, rewards wrong 285 of 285, each by exactly 10^10; the sinks, tips and balances still equal between the nodes; FAIL |
|
||||
|
||||
The fast-time line of the object on the same pair (six keys, 10:33 to 10:43 UK): 15 of 16 checks true (the stamped object byte 7 on every mined
|
||||
block, class v4 at rung 0, the first lock at DAA 142 and lock 19 at the end, the bonus 72/28 on the silent key's 91 blocks and 80/20 on the
|
||||
voters' 352, the bridge identity exact for all six keys, a leave accepted and effective within the delay, no pause after it); the one false
|
||||
check was the harness's final read after its own miners had exited (fixed at b8074151); the re-run on b8074151 (10:47 to 10:57 UK) read
|
||||
SUMMARY PASS on every check: 614 blocks, 492 chain blocks, eleven epochs at class v4 rung 0, the first lock at DAA 141, the silent key 72/28 on
|
||||
89 blocks, the voters 80/20 on 377, the bridge identity exact for all six keys, a leave accepted and effective, one sink on six nodes. Consequences: unchanged from the
|
||||
7 October entries; the object on the go line reads the same numbers as the re-cut it came from.
|
||||
|
||||
## 8 October 2026, 16:18 UK: igneum-testnet-1 go object on the 0.3.25 line (acaf08b0), the three pod gates green, the seeds armed
|
||||
|
||||
The day's line (`docs/plans/testnet-go.md` rows 9n to 9v): Devnet 3 crossed its class v5 floor clean at 12:57 UK; the node lane
|
||||
re-cut the testnet object with class v5 from genesis (0d05e795, digest `1da30c10e164784ffbf5bf216ef3bf84a2d5da212317b1e535c9850fe14aba2f`,
|
||||
byte 6, genesis `01294fd3...` unchanged); the coordinator moved the go seeds to the 0.3.25 node code (ba294c98); the pod gates then
|
||||
found three defects in turn, each fixed on that line the same afternoon: the miner's class v5 state lookup wired to the devnet exec
|
||||
port on every network (f41f48a7), no state stream after the genesis seed block (6e04f7fc), and the executor's sync wait that no
|
||||
fresh chain could satisfy (acaf08b0, one rule with the cold-start deadlock: no guard waits on what only a block can produce).
|
||||
Pod: RunPod 7e2jbcma9apjne (tn-gate-0324c), RTX A5000 secure, driver 570.211.01, USD 0.27/h, rented 14:50 UK, the fleet hand;
|
||||
the earlier 0324c rent deadlocked on the fleet registry lock (killed by pid 15:48 UK) and left a stray 4090 pod destroyed at
|
||||
14:51 UK (about USD 0.48).
|
||||
|
||||
| Run | Pair | Result |
|
||||
|---|---|---|
|
||||
| Two-node 18-decimal gate, GPU form, 0d05e795 (relay 26790 to 28190) | igneumd a3b1... line's 0d05e795 `b76d8671...` | VOID, 0 blocks: the miner refused the genesis seed state (row 9v found) |
|
||||
| Fast-time line at b5925261 (class v4 network), 0d05e795 | same | PASS 16 of 16, nodes stamping 7 (the harness's v4 gap, row 9u) |
|
||||
| Fast-time line at c5fe28e4 (byte 6, `--exec-rpc` per miner), 0d05e795 | same | reproduced the genesis refusal on all six miners, no relay |
|
||||
| Two-node gate, known-failed of 6e04f7fc, ba294c98 (script e88aa875) | igneumd `74ae96c6...`, miner `bbabfa91...` | FAIL as designed: the miner reached 28190 by the script's flag, no genesis stream, tip 0 |
|
||||
| Two-node gate, 6e04f7fc | igneumd `18e7d223...` | NO BLOCK: the executor waited for a consensus sync a fresh chain never reaches |
|
||||
| Two-node gate form 1, acaf08b0 (script b38f1ad3, no relay, 600 s) | igneumd `9e4217f3...`, miner `bbabfa91...` | PASS: the genesis stream published in the first follower pass, block one ACCEPTED 70 s after start, exec tip 327, 30 of 30 subsidies at 10^18, 80/20 exact 16 of 16, 502 rewards 0 wrong, balance 4017.16194444 IGN on both, votes 15/15, 3.07 MH/s wall |
|
||||
| Form 2, GATE_EXPECT_DECIMALS=8, acaf08b0 (420 s) | same | FAIL as designed: checks 3 and 5 wrong by exactly 10^10 on every row, block one mined (tip 183) |
|
||||
| Fast-time line at c5fe28e4 (byte 6), acaf08b0 (600 s, leave at 240) | same | PASS 16 of 16: epochs e0 to e9 class 5 rung 0, first lock 5 at DAA 143, six bridges true, leave 6 to 5, blocks 580 chain 452, rejected 0, one sink on six, object_bytes {6: 574}, refusals 0 |
|
||||
|
||||
The seed-class go pair (build-server lane, build-1): `/srv/artefacts/0325-acaf08b0/seed/igneumd` `cb35df68...`, `igneum-miner`
|
||||
`0b7e9d73...`; its bare start prints byte 6, 10^18, the digest, `igneumd/2.1.0-acaf08b0`, "genesis ... executed" and "the state
|
||||
stream after genesis ... is published" with no sync wait; the third dry run on seed1, seed2 and seed3 rc 0 at 16:03 UK. The seeds
|
||||
are armed; the go line is in the runbook and runs on the founder's own word.
|
||||
|
|
|
|||
202
docs/design/class-rotation-four-layers.md
Normal file
202
docs/design/class-rotation-four-layers.md
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
# Class rotation, four layers: the hourly program, the weekly parameter era, the 180-day family epoch, the emergency miner vote
|
||||
|
||||
8 October 2026, 14:1x to 15:30 UK, branch `class-v6-rotation` (worktree `igneum-wt-rotation` from the box mirror's master bfe7607a), the rotation lane under the Counter ASIC coordinator. The founder's order (14:3x UK, verbatim intent): "layer 1 the hourly change; layer 2 a weekly or monthly change; layer 3 the 180 day change; layer 4 emergency miner vote change. All automated." No release and no hand for any of the four once shipped. Inputs: the five research lanes' documents on `rotation-research` (build-3, 2f986d70: `docs/analysis/rotation/layer1-hourly.md`, `layer2-weekly.md`, `layer3-family-bank.md`, `layer4-miner-vote.md`, `other-layers.md`), each recommendation kept or rejected in section 8 with its reason; `docs/design/class-v6-rotating-family.md` (the per-tier rows, the band, the three rings); the release record's rules 16 to 19 on `ship-docs-0321` (`docs/plans/release-rules.md` lines 22 to 25, read by `git show`); the node at the mirror's `release-0.3.25-node` c9ad753a. Status: a design document and a research-class prototype on a fork branch behind a params switch (section 9); nothing here touches the devnet, Devnet 3, the testnet object or any served number. Labels: **measured** (a card or a box on a named job, with the clock), **modelled** (arithmetic on cited figures), **claimed** (a vendor's or an author's figure), **approximate**.
|
||||
|
||||
Framing (the coordinator's word, 16:5x UK; Igneum 2.0 is the reference, `docs/plans/igneum-2.0.md` on the mirror): rotation is optional to the security argument, the four layers simplify to those with a distinct demonstrated benefit, and this document describes a CAPABILITY the chain has (what changes, from which chain value, under which rules, at what cost per boundary), never a resistance claim; it makes no claim that rotation strands any hardware, and where a chip row appears it says what a draw changes for a datapath or a firmware, labelled, and nothing about any product's life. The prototype of section 9 is kept as the no-rescue test's control.
|
||||
|
||||
The founder's bar is "error free and future proof", so every guarantee below is named as what it is and nothing more: a **test** (a unit test that fires on a known-failed case), an **in-node rule** (an acceptance rule every node applies to every draw, deterministically), a **census** (an offline run over drawn eras that bounds a failure fraction at a confidence, never a proof), or a **fast-time crossing** (a three-node network crossing the boundary on the box, which shows the code path once, not every future draw). A claim resting on none of the four is marked owed.
|
||||
|
||||
## 0. One page
|
||||
|
||||
| Layer | Boundaries a year | What is drawn | From which chain value | What it may touch | What it may never touch | What makes the boundary safe (named as what it is) | What still needs a release |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1. The hourly program | 3,600 DAA s: **8,766** | the program: the instruction stream, the register init, the load sites, the shadow block's instructions | the epoch's reference block: the last selected-chain block below `3,600 e - 600` (the node's `epoch_seed`, class v5's `C_w`) | the program only | the dataset derivation, the read atom, the layout, the week's table, the family set | in-node rules (a) (b) (c) (a') (c') (c''') and the per-site tests of ring B, the 256-attempt cap then the verified last resort; a wrong program refused at packcheck and by every node's PoW check (`check_header` regenerates the program): test, the class v4 harness's stale-miner case | a new generator (the 64-bit `program_rng` state is one: layer1-hourly.md item 2, ledger M7) |
|
||||
| 2. The weekly parameter era | 604,800 DAA s (7 days): **52.18** (monthly 30 days: 12.18, priced in 3.4) | the table the hourly draw reads: the op-mix weights (B = 4 on the seven injecting families, the lossy three at base), the shadow block shape in {64, 128, 256}, the stride multiplier M and rotation R, the index fold's constants (once the fold's form is in), the cross-lane shuffle pattern from a genesis-vectored set; NOT the mixer multiplier (a per-family axis), NOT W (a per-class pin, 4 words), NOT N (the ladder's signal), NOT the layout `pos` | the week's reference block: the selected-chain block at the greatest DAA at or below `604,800 w - 7,200` (two hours below the boundary), through the 1-hour VDF once live | the program's generator inputs only | the dataset derivation, the read atom, the dataset layout, the object digest, every activation height, the binary | ring A in the node (band membership, the lossy-share bound, the rotation class, the pattern index) in microseconds; the table judged by the first epoch's draw (over 32 attempts: the stream's next block, three blocks, then the genesis table, total and the same on every node): in-node rule with tests; the census bounds the redraw (0 of 3,000 band eras exhausted at 256, max attempt 59: measured, lane D 17:00 UK) | a wider band, a new pattern, a new base table |
|
||||
| 3. The 180-day family epoch | 15,552,000 DAA s: **2.03** | structure from the genesis bank (section 4): the live reserve subset, the dataset atom from the bank's atom list, the fold form, the shapes admissible that epoch | the family epoch's reference block: the era cut, the last selected-chain block below `15,552,000 n - 7,200` (the node's `era_seed`; the era VDF over the cut's day once live), the same block the class v5 dataset's state is read at | the family set, the atom (within the bank's list), the admissible shapes | anything outside the bank; the read atom outside the genesis list (W = 4; 8 as `admissible: false` until its rows) | the node's own lead-ahead self-test on the coming object (section 6.2: a fast-time crossing per node), rule 18's feed check and rule 19's fingerprint pairing (section 6.3: in-node rules with their CI checks), the per-vendor 5 percent rule and four-vendor fingerprints per bank entry at genesis (tests), the stale-kernel refusal (test) | a family beyond the bank, a read atom or a derivation not in the bank (bank refresh: `bank_version` in the digest, entries added `admissible: false`, the 90 percent tally in epoch n effective at n + 2) |
|
||||
| 4. The emergency miner vote | 0 expected; at most one bring-forward per scheduled epoch: **at most 2.03 extra** | nothing new: the vote names the NEXT scheduled family epoch's draw at a target height brought forward; the draw at that height is layer 3's from that height's reference block | the finality votes' carriers: a `FlipVote` item under its own tag in the coinbase finality section, read by Q2's block reading over the certified checkpoints | what layer 3 touches | what layer 3 never touches; and nothing can be delayed: a target at or past the scheduled height is dropped | two thirds of active weight AND half of total 30-day weight at every one of the last 240 of 720 consecutive checkpoint indices (in-node rule; tests on the fold); no vote counts while finality is paused; the flip at the first epoch boundary at least 14,400 DAA s after the tally closes; one bring-forward per family epoch | a trigger no bell can see (the detector is for people, section 5.1) |
|
||||
|
||||
The three numbers the 15:45 close quotes. **Boundaries a year**: 8,766 / 52.18 / 2.03 / at most 2.03 extra (the weekly cadence is the default; the monthly alternative 12.18 is priced in 3.4). **The detector's false-positive rate on today's fleet rows** (section 5.1; layer4-miner-vote.md section 2, measured rows): as a trigger it is unusable, as a bell it rings a few times a year: signal A (a hash-rate step over 1.5x in a day) fired on 2 of the 14 days of the two crossing weeks on the record (4 October 152 to 280 MH/s when PC 2 joined, 1.84x in minutes; 6 October's 38-pod wave 1,748 MH/s onto about 1.16 GH/s), about **50 cases a year** at that fleet's behaviour (arithmetic on measured days, approximate); signal B (a clique of 3 correlated keys held 6 windows) read 0 cliques and 1 edge on the 6 October honest baseline (r 0.94 on two same-model 5090s, 4 ids, epochs 40 to 45, measured, `tools/observer/README.md`), and on a real network one candidate clique per few hundred ids from multi-card rental hosts (derived, approximate): **a few cases a year at today's key count**, never a consensus input; signal C has no on-chain form. **The vote window**: 720 consecutive checkpoint indices at 30 DAA s each = **21,600 DAA s, 6 hours of UK clock time at the 1 s block**, the pass line read at the last 240 indices (2 hours), the flip at the first hourly boundary at least 14,400 DAA s (4 hours) after the tally closes: **a carried vote flips between 10 and 11 hours after its first qualifying index** (on the fast-time file the window is 4 indices and the delay 60 DAA s; section 9).
|
||||
|
||||
What this document does not claim: no layer moves the per-joule identity (`edge = (E_card + F) / (E_mem + k F)`, the research file's section 2): against the chip that stores the dataset every drawn value is firmware, the edge stays 3.6x at zero shadow, 2.8x at the sequencer core's k 0.56 and 2.1x at k = 1 on a 5090 at its knee (measured card, modelled chip; other-layers.md section 1), and the four layers decide which chip can be built and how long its tape-out lives. Layer 2's weekly cadence does not shorten layer 3's 180 days, so the close's amortisation unit for a chip project stands at 180 days; the week shortens the life of a FIXED-FUNCTION tape-out to a week, which the chip model prices at zero for the chip anyone builds. The read width stays pinned at 4 words (class v6 10.3) in every layer.
|
||||
|
||||
## 1. The reference-block rule, one definition for every layer
|
||||
|
||||
Every drawn value of every layer is a deterministic function of one chain block, the layer's **reference block**: for a boundary at DAA score `B` with the layer's lead `lead`, the reference block is the last selected-chain block whose DAA score is below `B - lead`, walking down from the header's selected parent (the node's `HeaderProcessor::epoch_seed` for the hour at lead 600 and `era_seed` for the era at lead 7,200, `consensus/src/pipeline/header_processor/processor.rs`; class v5's `C_w` is the same block for the hour). The draw's seed bytes are the VDF over that block where the VDF is live (the 10-minute class-group VDF for the hour, the 1-hour VDF over the cut's day for the era, spec 04 section 4.4) and the block's hash as the stand-in before (every network today). The week takes lead 7,200 like the era. Consequences, each named:
|
||||
|
||||
- **Every node computes the same draw or no draw** (in-node rule; the fork's test `rotation_era_draw_is_deterministic_and_moves_with_the_reference`): the reference block is on the selected chain `lead` before the boundary, so a node that has the chain has the draw; a node that has not reached it keeps the old table and its blocks past the boundary are refused by every node that has (the stale-kernel class), at its own cost.
|
||||
- **No accepted reorg replaces a week's or an era's reference block** (in-node rule resting on the merge-depth bound, 3,600 DAA s: a lead of 7,200 is two merge depths); the hour's lead of 600 is inside the bound, which is today's known behaviour (an epoch's seed block can move with a reorg before the boundary, and then it moves for every node together).
|
||||
- **The one thing that would break it** (layer2-weekly.md section 2, taken whole): a draw that reads anything but the reference block. The tip (the 2720d8d2 fork made two views), a state root or record count (class v5's stale-node refusal, 86 of 86), the signal tally at a window's edge, a node's own DAA reading or the wall clock (the three lost floors of 8 October), a file. The design rule, enforced by the types in the prototype: the draw functions take the reference block's bytes and the genesis constants and nothing else.
|
||||
- **The index never enters the draw bytes** (the era rule of igneum-pow: `E_n` commits to `n` through the VDF input), so a boundary's identity is its height, not its number. Test: `era_draw_deterministic_and_bounded` (igneum-pow) and the fork's `rotation_flip_moves_the_era_boundaries_and_rebases_the_schedule`.
|
||||
|
||||
## 2. Layer 1: the hourly program
|
||||
|
||||
What exists today is layer 1: `pow_epoch_blocks` 3,600 and `pow_epoch_lead` 600; the epoch seed from the reference block; the generator's draw with attempts `k = 0, 1, 2, ...`; the acceptance rules of sub-version 3 and class v5 ((a) stale source, (b) injecting write, (c) the dynamic test over 64 units, (a') the freshness fixpoint, (c') saturation per site, (c''') the distinct-item ratio floor 0.995 in the v5 tree); the cap `MAX_ATTEMPTS_V4` = 256 (exhaustion under 1e-45 per seed at two thirds rejection per attempt, modelled; 0 of 24,631 devnet seeds exhausted, max attempt 29, measured by the hash lane's census) and the verified last resort after it; the program id `program_id_class(generator, seed, attempt, class)` the miner's pack carries (packcheck) while the node regenerates the program from the seeds and refuses a mismatch as invalid PoW (`consensus/pow/src/igneum.rs`, `check_header`). Under rotation layer 1 changes in two ways only:
|
||||
|
||||
1. **Its inputs come from layers 2 and 3.** The week's table and the family epoch's structure enter the generator as the class, so the program id carries them and a kernel built against another week or family set is refused exactly as a kernel of another class is. Test: the stale-miner case of the class v4 harness, re-run across a week boundary and a family boundary (section 9).
|
||||
2. **The acceptance rule takes those inputs** (ring B): (c''') with the expectation divided by the width, the per-site largest-bucket bound in sigma and the per-site index-bit one-count in sigma on the same 2^20 pass (2.2 s per chosen candidate on a box core, measured), the window-bit test a refusal only once the index fold is in (on today's `load_index` it would redraw about 40 percent of epochs, lane D). In-node rules; the census's n (10,000 random and 3,000 band eras) bounds the failing fraction at 3.0e-4 and 1.0e-3 at 95 percent (measured, lane D).
|
||||
|
||||
Kept from layer1-hourly.md (section 8): the hourly period stands (600 s costs the iGPU tier 20.7 percent of the epoch under load and puts the VDF core at 100 percent duty, measured on PC 1; per block is impossible at a 0.6 to 1.1 s prepare); the known-ahead window is 600 s at every epoch length, already the compile deadline an FPGA faces. What layer 1 never touches: the dataset derivation, the read atom, the layout, the table, the families. The cost per boundary is today's: the program compile per epoch (the miner's "program and cache ready" line, 13 to 42 ms measured on the 5090 and the M5 Max at 1 GiB), the verifier +0 ms, no re-tune (the hourly draw moves neither rate nor watts outside the band, measured, class v6 section 2).
|
||||
|
||||
## 3. Layer 2: the weekly parameter era
|
||||
|
||||
### 3.1 The draw (layer2-weekly.md section 1, taken; the mixer multiplier removed from this document's earlier draft on its reason)
|
||||
|
||||
Week `w` is the DAA interval `[604,800 w, 604,800 (w + 1))`. One SplitMix64 stream seeded from `seed_words_from_bytes("igneum-week/" || V_w)` words 0 and 1 (`V_w` the VDF over the reference block's hash; the hash itself as the stand-in), drawn in a fixed order, every slot consumed whether used or not:
|
||||
|
||||
| Slot | Parameter | Band (genesis) | Why that band (measured rows) | The node's test at the boundary |
|
||||
|---|---|---|---|---|
|
||||
| 1 | The op-mix weights (add, xor, mul, mad, shfl, rotl, sub, mulhi, rotr, or) | each injecting family perturbed by `below(2B + 1) - B`, B = 4, around the base table; or, mul, mulhi never above base; shfl never above 8; renormalised by largest remainder | the lossy-capped band reads r 0.595, mean attempt 1.47, max 59, 0 exhausted of 3,000 eras; with the lossy three free 1.2 percent of eras exhaust (measured, lane D); shfl is the dearest op (29.4 pJ at the lock, measured) | ring A: the lossy-share bound `or + mul + mulhi` at most base + B; the rotation class |
|
||||
| 2 | The shadow block shape | {64, 128, 256} at 6,912 shadow instructions per iteration; the placement never moves | 64-instruction blocks 2.5 to 3.5 percent faster than 256 on the 5090 and the M5 Max (measured 6 October); 1,024 cost the M5 Max 17 percent | `is_family_shape` in the draw and the acceptance alike |
|
||||
| 3, 4 | The stride multiplier `M = low32(next()) OR 1` and the rotation `R = 1 + below(31)` | the 1.13.1 address draws, moved from the era to the week | the index fold is what makes R drawable: without it 7 of 16 drawn eras carry a site at abs z 130 to 511 at address bit R (measured, lane D) | ring A records the rotation class; ring B's window-bit refusal on every candidate |
|
||||
| 5, 6 | The index fold's constants | two draws, `1 + below(31)` and a 32-bit mask with the low `b` bits forced set | the fold's form is layer 1's open item; until it and its vectors are in, both slots are consumed and the genesis constants stand | the window-bit refusal; the known-failed case is lane D's 7 of 16 eras, which must read 0 of 16 with the fold |
|
||||
| 7 | The cross-lane shuffle pattern | `below(S)` over a genesis-enumerated, genesis-vectored set (the butterfly's 31 xor masks and shfla's 31 deltas) | a pattern outside the vectored set is never drawn; the AMD `ds_bpermute` row is owed (layer1-hourly.md) | ring A: the index inside S |
|
||||
| 8, 9, 10 | N, W, the mixer multiplier m | consumed, not read | N is the ladder's signal (an unconditional draw retires the Apple tier, -10 percent at 200,000, measured); W is a per-class pin (4; 8 after the owed rows; 16 never, +25 to +34 percent energy per hash on four cards, measured by floor lane 5); m is a per-family axis closed by adv-mixer-3's ladder and the x16 verifier row (11.4 ms loaded, over the 10 ms gate), the card pays nothing for it (x8 against x16 within 0.1 MH/s and 0.5 W, measured) so it is the verifier's budget and not a weekly knob | none |
|
||||
|
||||
### 3.2 The acceptance at the boundary and the fallback (in-node rule; tests)
|
||||
|
||||
Ring A on the drawn table in microseconds (band membership of every value, the lossy-share bound, the rotation class, the pattern index). Then the table is judged by the first epoch's program draw under it through ring B: if that epoch's program is not accepted within 32 attempts, the table is discarded and the next block of the week stream is drawn, every slot again in order, three blocks at most; after the third the fallback is the genesis table (the base weights, shape 256 x 27, M and R from the six-monthly era's own draw, the genesis fold constants, pattern 0) with no further check. Total, and the same on every node, because every input is the reference block and the genesis constants. Worst case 96 candidates, 211 s on a box core and 480 s on a 5 s core, inside the 7,200 DAA s lead (measured per-candidate cost, arithmetic). Odds: under the band the max attempt in 3,000 eras was 59 and the mean 1.47 (measured), so a block redraw fires in under 0.1 percent of weeks and the fallback in none seen; the exact count over 32 is owed from lane D's rows. The prototype's tests (section 9): a corner no draw can pass takes the base table at the cap on every seed (`rotation_era_draw_exhausts_to_base_table`, known-failed first); the genesis band accepts at attempt 1 on 2,000 references (`rotation_era_draw_accepts_at_attempt_1_under_the_band`).
|
||||
|
||||
### 3.3 Why a weekly boundary needs no state agreement (layer2-weekly.md section 2, taken whole)
|
||||
|
||||
The week reads one chain value (the reference block, through the VDF) and writes nothing. Untouched, with the record's reason for each: the dataset derivation (class v5's leaves under the day key, the item's 64 bytes, the mapping under the six-monthly `pos`): the read atom and the layout are what a stale node disagrees on; the object, its digest and every activation height: faults 1 and 2 of the crossing day (section 6.3) are exactly a digest and a height moving; the acceptance rule's code and the worker's class set: rule 19 and rule 16, the rule takes the table as INPUT and the worker carries every band value from genesis; the verifier's budget (m, W, the block count); the public feed's lock (rule 18 reads the same reference block the week does). A node that is behind reaches the reference block in order, computes the same table, accepts the same programs, with no message, no file, no sweep and no minute; the restart case of the fast-time harness already resyncs across a class boundary in 28.1 s (measured, cross-0324), and a week boundary is strictly less than that case. So the failure mode of a wrong week is a refused block at the drawing miner's own cost, never a two-sided chain: that is the sense in which layers 1 and 2 are "program only" boundaries.
|
||||
|
||||
### 3.4 Boundaries a year and the cost per boundary (the close's units)
|
||||
|
||||
| Cadence | Boundaries a year | A card with a lever (5090, 5070 Ti, 4070): Ember's re-tune per boundary | A card without one (9070 XT, every Apple machine) | Verifier | Chip | Label |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Weekly (default) | 52.18 | 11 to 12 minutes at stock watts, about 15 s of hashing lost and 0.05 kWh (about 1 p) per boundary: 52 a year is 13 minutes of hashing and 2.6 kWh, under GBP 1 a year per 5090; the measured band says the point does not move (within 0.1 MH/s and 0.5 W at the lock across the mixer draw, 0.6 W across the three weight tables, 3.5 percent of rate across the shapes), so a 3-minute confirm replaces the re-tune | the 3-minute baseline, nothing lost | +0.2 to +1.5 ms per era draw on the epoch build; the daily build unmoved | what a draw changes for a chip: a wired lane ratio, block shape or fold is wrong from the first draw that leaves its value (a fact about the draw, not a claim about any product); a programmable part takes the draw as firmware; its verification burden the same three rings as a node, 3 to 9 s a week typical, 211 to 480 s worst | measured per boundary (the hash lane's rows), multiplied |
|
||||
| Monthly | 12.18 | 3 minutes of hashing and 0.6 kWh a year | nothing | the same | the same, with 4.3x the use per draw-specific optimisation | measured, multiplied |
|
||||
|
||||
Seven days, not thirty (layer2-weekly.md section 3, taken): the era length moves nothing on joules for a programmable chip and moves a draw-specific optimisation's planning window one for one; the honest cost is a re-tune no card needs.
|
||||
|
||||
## 4. Layer 3: the 180-day family epoch and the bank
|
||||
|
||||
### 4.1 The bank at genesis (layer3-family-bank.md, taken; this document's earlier "twenty entries" corrected to its count)
|
||||
|
||||
The bank is what ships at genesis so that no family epoch ever needs a release: **18 op families** (class v4's ten: add, xor, mul, mad, shfl, rotl, sub, mulhi, rotr, or; the reserve R1 to R8 of `docs/plans/counter-asic-3-reserve.md`, ordered by hardware orthogonality: shfla, perm, popc and clz, bfe, shl and shr, sel, andn, mm8), **3 dataset atoms** (the mixer at x4 and x8, the derive program dr368), **1 fold form** with drawn constants, **3 block shapes** (64, 128, 256); x16, dr736 and W = 8 in the list as `admissible: false` from genesis (the ladder's flag shape, flipped only by the 90 percent path once measured). Each entry carries at genesis its emitter on every vendor, the conformance vectors of spec 1.15 with four-vendor fingerprints (Metal, CUDA, OpenCL on NVIDIA and AMD), the per-vendor 5 percent rule's measurement (class v6 4.2's table: shfla 1.91x per op on Apple, 1.53 NVIDIA; perm 1.13 / 1.30; mm8 emulated 1.6x per dot4; measured), and its price in chip k (shadow-k.md: mad 0.20, the ARX families 0.17 to 0.18, the fold 0.14, or 0.12, mul 0.082, prmt 0.056, mulhi 0.032; the shuffle, crossbar and tile rows owed). The entry rule is sub-version 3's acceptance under the entry's own shape, ring C's census (10,000 random and 3,000 band eras, 0 of 10^6 exhaustions per corner), the 5 percent rule, and the 10 ms verifier gate for atoms. Entries are never removed, so program ids stay decodable.
|
||||
|
||||
### 4.2 The draw (structure from the bank, never content)
|
||||
|
||||
The family epoch's reference block (the era cut) seeds one stream; five draws in fixed order: (1) the live reserve subset: during the reserve (epochs 1 to 8) entry `n` unlocks at epoch `n` with `W_new` = 4 points taken proportionally (spec 1.13.2, the slot consumed and unused); after the reserve, two entries live by a fixed cycle over the reserve whose phase the draw sets, so a retired family returns on a schedule a chip cannot wait out; (2) the atom from the bank's admissible atom list (the one draw of any layer that changes the dataset derivation); (3) the fold form's genesis constants (the week draws the values); (4) the admissible shapes; (5) the B = 4 band the week draws inside. The split with layer 2, this document's resolution of the two lanes' overlap: **the family epoch sets the structure (which families, which atom, which form, which shapes are admissible); the week draws the values inside it (the weights, the shape, M, R, the fold constants, the pattern).** 180 days aligned to the six-month era; the dataset floor's steps offset by 90 days (lane 3's schedule), so the chain has at most two boundary events a year of the costly class. On the chip timelines (layer3-family-bank.md section 4, claimed dates): 3 flips inside Kaspa's 22 months to the KS0, 6 inside Ethash's 36 to the E3, 7 inside Monero's 46 to the X5.
|
||||
|
||||
### 4.3 What a chip must cover
|
||||
|
||||
What the bank asks of a datapath (a capability statement, no claim about any product): to compute every bank entry (about 8 to 14 adders per lane, about USD 4 of N5 on a 14,000-lane array, class v6 4.2, modelled) and every atom in the bank, else a family's weight of the mix (4 points of 79) is computed by emulation at the measured vendor penalty (1.5x to 2.4x per op) on the day it goes live. The chip that survives any flip is the sequencer core (layer3-family-bank.md item 2): 3.1x the bare lane (6.9 against 2.2 pJ on ASAP7, synthesised), k 0.56 at the lock at N3 against the bare 0.18; so the layer's honest floor is a chip that is the GPU, unmoved against the memory system (GDDR7 2.8x at the knee, the SRAM die 4.7x, the M5 Max column 1.7x to 2.8x; modelled on measured cards), and the bank changes about 1.2x of edge over the bare floor and nothing for a programmable part. Under the 2.0 framing this is the layer's cost side; it is not offered as resistance.
|
||||
|
||||
### 4.4 What a layer 3 or 4 boundary requires that 1 and 2 do not
|
||||
|
||||
A family epoch can change the atom and the admissible shapes, so a node on the wrong dataset refuses every honest block: a two-sided chain, the DAA 198,000 class and this morning's crossing (section 6.3). The guards, each named: the node's own lead-ahead self-test (6.2, a fast-time crossing per node); rule 18's feed check and rule 19's fingerprint pairing (6.3, in-node rules with CI checks); the bank's conformance vectors (a test per entry per vendor); ban clearing (object-mismatch strikes not counted within one window of the flip, cleared at it; F23 untouched); the stale-kernel refusal (test). None is a proof that a future draw is right; the vectors are what say a family computes the same value everywhere.
|
||||
|
||||
## 5. Layer 4: the emergency miner vote
|
||||
|
||||
### 5.1 The trigger: the detector is a bell for people, never a consensus input (layer4-miner-vote.md section 2, taken; this document's earlier "6-sigma trigger" withdrawn on its numbers)
|
||||
|
||||
| Signal | What the chain can read | Its false-positive price on today's rows | Its false-negative price against a disciplined chip fleet | Verdict |
|
||||
|---|---|---|---|---|
|
||||
| A: a hash-rate step beyond the fleet-tier envelope | the difficulty's implied rate; the envelope from 98 measured rows over 43 tiers (14.9 MH/s on a 2060 to 416 on a B200; consumer 0.08 to 0.58 MH/W; the 5090 99 to 123 MH/s, n 849) | the 4 October crossing stepped 152 to 280 MH/s in minutes (1.84x, measured); the 6 October wave put 1,748 MH/s on about 1.16 GH/s; a rule on a 2x day step fires on every day of both crossing weeks; a 20 to 30 percent daily swing is ordinary rental churn (approximate) | a fleet ramping 5 percent a week is under every honest swing; doubling a 1 TH/s mainnet is USD 3,500 an hour of rentals, inside the market (approximate) | a gate (no flip vote is worth raising under a 1.5x step in 30 days), never a trigger |
|
||||
| B: share concentration and correlation of winning keys (`tools/observer/detector.mjs`: spread over 10 percent, band, early, nonce; a clique of 3 ids over r 0.8 held 6 windows) | blue-block share per key, residual-rate correlation, nonce law | the 6 October baseline: 6 pairs, max r 0.94 on two honest 5090s on one Windows schedule, 1 edge, 0 cliques (measured); a Vast 8-card host is a clique of 8 at r near 1 | a chip throttled into a card band (bandTol 30 percent), keys split, 10 percent per-unit jitter, randomised nonces: every test beaten for under 1 percent of its edge (modelled) | a bell |
|
||||
| C: an efficiency signature in timing | the early-epoch share, template-to-block latency, nonce order | all three are shapes of the honest card (compile, fetch, counter) | a chip is on time and its nonce order is firmware; joules per hash never reach the chain | no on-chain form; the off-chain instrument is the bounty and benchmark |
|
||||
|
||||
The number for the close: signal A about 50 cases a year at the record's fleet behaviour (2 of 14 crossing-week days, arithmetic, approximate); signal B a few a year at today's key count (derived from the one measured baseline, approximate); so an automatic flip on either would be griefable for under USD 1 an hour on devnet data and a consensus input the observer feeds. The signals decide when nodes SHOW a flip proposal with its evidence and when miners' signers offer their operators the vote; they never decide the vote.
|
||||
|
||||
### 5.2 The vote (layer4-miner-vote.md section 3, taken; this document's earlier veto, day-long window and frozen-table denominator withdrawn on the reasons in section 8)
|
||||
|
||||
- **The payload**: a `FlipVote` item under its own tag in the coinbase finality section (the Leave item's shape, tag 8, carried last so an older decoder stops cleanly; never a widened `Vote`, whose decoder dispatches on exact length): a BLS signature by a finality voter's key over `"igneum-flip-v1/" || chain_id || 0 || index || hash(C_i) || family_id || target_height`; relayed where votes are relayed. `family_id` is the next scheduled epoch's bank structure id; `target_height` an hourly epoch boundary. **A target at or past the scheduled height is invalid and dropped, so no vote can delay**; there is no veto because the scheduled epoch is a height and not a vote.
|
||||
- **The tally** (in-node rule; the prototype's `vote_carries` with its tests): over 720 consecutive checkpoint indices (6 hours; 4 on the fast-time file), by Q2's block reading (a key's vote at index j counts when a block in the past of C_i carries it), weighted by W2 at C_i: the vote passes when at every one of the last 240 indices the summed weight of keys whose latest flip vote names the same `(family_id, target_height)` is at least two thirds of active weight (W2 times participation, Q3's first test) and at least half of total 30-day weight. Why both: the active test means silence cannot block (a key that stops signing leaves the denominator within a presence window); the half-of-total floor means an eclipsed minority cannot pass alone. Memoised per seed block like the class signal, so a fork has its own answer. No vote counts while `finality_active` is false.
|
||||
- **The delay and the flip**: the first hourly boundary at least 14,400 DAA s (4 hours, the publish rule's own lead) after the tally closes; nobody fetches anything (the bank is in every binary that could vote); every node runs the lead-ahead self-test on the incoming structure and refuses to serve a template it cannot verify; Ember re-tunes once. UK clock: 6 hours of window plus 4 of delay, so a carried vote flips 10 to 11 hours after its first qualifying index at the 1 s block. The next scheduled epoch re-bases from the flip (the prototype's `era_index_with_flip`); one bring-forward per family epoch.
|
||||
- **Every node deterministic**: the carried votes are chain values (carriers in the certified checkpoints' past), so every node with the checkpoints computes the same flip height and a node that joins later reads it from the blocks it syncs; a node synced from a pruning proof cannot tally below its pruning point and takes the schedule (owed, the class signal's same gap).
|
||||
|
||||
### 5.3 The attack read
|
||||
|
||||
| Attack | What happens | The read |
|
||||
|---|---|---|
|
||||
| A chip fleet at a third of weight blocking by abstention | it blocks every bring-forward while it holds a third of active weight; it cannot delay the scheduled epoch (a height); if it goes silent it leaves the denominator within a presence window and, at a third, pauses finality (3.11.3), which costs it its own locks | the vote adds no new threshold to buy: a third of active weight is a third of the 30-day blocks, the same third that pauses finality; the damage is bounded by the schedule's cadence (180 days), which no vote shortens |
|
||||
| A chip fleet with majority weight | it is the finality problem itself (20 days of 100 percent hash to reach two thirds); it could carry a bring-forward, which only hurts a chip; it cannot delay | layer 4 is a lever for an honest two thirds and a cost to nobody else |
|
||||
| A griefing vote (a two-thirds coalition flipping early for the cost to others) | one bring-forward per family epoch; each costs every honest miner one bench pass (minutes) and nothing else; a floor step's rate cost (4 to 10 percent per hash on the 5090 at 2 to 8 GiB, measured) only where the family carries one | worst case twelve re-tunes a year by a majority that wanted them; the coalition pays the same |
|
||||
| A flip during a partition | no vote counts while finality is paused (C5, 3.9); a partition shorter than the lead heals before the flip; one longer has already paused finality on at least one side (3.7 item 9), so the vote is void there and the schedule stands; a vote carried before the split flips on both sides at the same height from the same reference block | the flip cannot be one-sided; today's Devnet 3 (14.7 MH/s, a handful of keys, two PCs on one schedule) could never pass one, which is correct: nothing on it has the weight to speak |
|
||||
| A replayed or forged vote | the tag separates it from a checkpoint vote; `chain_id` stops cross-network replay; `index || hash(C_i)` expires it with its checkpoint; two flip votes by one key at one index naming different targets are equivocation under 3.6's evidence shape; forging is forging BLS12-381 | in-node rules with the finality suite's shape; a pool cannot vote for a member (no verifier, no vote) |
|
||||
| A vote naming a height outside the window | ignored | in-node rule; test `rotation_flip_height_outside_the_window_is_ignored` |
|
||||
|
||||
## 6. The exposure: a flip is a boundary
|
||||
|
||||
### 6.1 The arithmetic
|
||||
|
||||
A boundary is a height at which every node must compute the same new value or refuse the other side's blocks. Boundaries a year: layer 1 8,766, layer 2 52 (12 monthly), layer 3 2, layer 4 at most 2 extra. The exposure is the failure class a disagreement can cause times the count:
|
||||
|
||||
| Boundary class | Failure if a node disagrees | Recovery | Boundaries a year | The exposure |
|
||||
|---|---|---|---|---|
|
||||
| Program only (layers 1 and 2) | the disagreeing miner's blocks are refused by every node with the reference block; the chain does not fork | automatic: the miner's next template after its node catches up | 8,818 | 8,818 chances a year of one miner losing one epoch's blocks; zero of a two-sided chain, because no agreement beyond the reference block is needed and every node already agrees on that |
|
||||
| Structure (layers 3 and 4: the atom, the admissible shapes, the family set) | a node on the wrong dataset refuses every honest block: a two-sided chain | the losing side's execution reset and a deep reorg; nothing automatic beyond the merge-depth bound | 2 to 4 | 2 to 4 chances a year of the costliest class; this is why layer 3's cadence is 180 days and not 7, and why layers 1 and 2 are forbidden the derivation and the atom |
|
||||
|
||||
The layers are split by exposure class, not by cadence: a weekly boundary is cheap because it is program only, and a family boundary is rare because it is not. Putting the atom under a weekly draw would be 52 chances a year of the two-sided class against 2.
|
||||
|
||||
### 6.2 What a layer 3 or 4 boundary requires: the node's own fast-time crossing self-test
|
||||
|
||||
Before every family boundary (scheduled or voted), from the reference block on, every node crosses the boundary against itself in fast time: it derives the coming structure from the reference block, generates the new epoch's first program and packchecks it against its own generator, runs the 96 vector lanes and the 2^24 fingerprint of each live family against the bank's table, builds the dataset head under the coming atom, captures and persists the day stream at the cut, and only then prints the family line and serves the new template at the boundary. A node whose self-test fails keeps the old structure (its blocks refused from the boundary, at its own cost) and reports the failure home with the reason shown (the plug, tune, play rule). This is a fast-time crossing per node: it shows this node can cross; the reference block and the vectors are what say every node agrees.
|
||||
|
||||
### 6.3 The four crossing faults and rules 16 to 19 (the release record on `ship-docs-0321`, `docs/plans/release-0.3.22.md` section 25 and `release-rules.md` lines 22 to 25, read today)
|
||||
|
||||
The four faults of the class v5 crossing on Devnet 3 (12:48 to 13:30 UK, 8 October), each a boundary where nodes had to agree on something beyond the chain's own blocks: (1) every fleet miner's worker ran generators 2 to 4 only, so the chain made no block for 9 min 26 s at DAA 68,400 (rule 16: every shipped worker prepares the live epoch before the cut; a package is checked by whether its worker can prepare the new class, never by its packs' presence); (2) container OOM kills and a proof map without a window (rule 17: memory read against the container's cap, never the host's; anything held per received item needs a window); (3) the re-walking node's chain-id refusal and its misbehaviour strikes (a mismatch between the network's own ids is a refusal, never a strike); (4) the public feeds read build-1's observer in a drifted one-peer cluster, so the public saw the chain dead from 12:59 UK while the chain ran (**rule 18, the feed check**: "every public feed names its source node and reads it only while that node's hash and state root at the current epoch's reference block equal the network's; on a mismatch the feed fails over to the next listed node and says so"). A fifth, behind **rule 19, the fingerprint**: a gate artefact built from uncommitted igneum-pow edits ("the pairing is a fingerprint, not a path": every node and miner binary carries the fingerprint of the igneum-pow tree it was built from; the build fails when it differs from the pinned freeze; the read-back prints it beside the commit; the handshake carries it and a node refuses a peer whose fingerprint differs; `tools/ci/pow-fingerprint-check.sh`). Under rotation: rules 16 and 19 are what make "the bank is in every binary" a checked fact rather than a hope (the worker prepares the coming structure, the fingerprint pairs the bank); rule 18 reads the same reference block the layers draw from, so a feed right about the lock is right about the week and the epoch; and layer 2 has none of the four faults' shape by construction (3.3), which is the research lane's proof that the weekly boundary needs no state agreement.
|
||||
|
||||
### 6.4 Two more faults of the crossing day, refused by construction and carried into the crossing test (the coordinator's word, 14:5x UK; the mechanics from the node lane's reading, 15:0x UK, cited here by lane)
|
||||
|
||||
| Fault | What happened (the node lane's mechanics) | The rule | Where it binds in the four layers | What the fast-time crossing asserts |
|
||||
|---|---|---|---|---|
|
||||
| (6) The epoch 20 boundary stall (Devnet 3, 13:03:41Z) | the class v5 check (`consensus/pow/src/igneum.rs:156`) refuses a header whose epoch state this node's executor does not hold; (a) the relay flow counts every PoW-check `Err` as a PoW cache strike, so the fleet's nodes, executors behind epoch 19's seed block, banned build-1's seed ("230 PoW cache strikes within an hour (limit 2)", 12:56Z), and in IBD the same refusal is `protocol/flows/src/ibd/flow.rs:1127`; (b) the template needs the same state, so at DAA 72,000 no mining node's executor had passed epoch 20's seed block and no epoch 20 template existed: the chain stood at the last epoch 19 block | a refusal for missing state is never a strike (the `Err` carries a missing-state kind the strike counter skips, the header is parked and retried, the peer keeps its standing); a node's template waits for its executor past the epoch's seed block and says so (a line, never silence). No fix exists yet; the record names it as the next node line's item | every boundary of every layer is a template-build event, the hour included (epoch 20 WAS an hourly boundary), so this is a layer 1 rule first and layers 2 to 4 inherit it; under class v5 every boundary reads state at its reference block | at the epoch cut a node whose executor is held below the cut prints the :156 line, never appears as "PoW cache strikes" or "banned" in its peer's log, and the chain advances on the peers whose executors passed the cut: the absence of a strike in the peer's log is the read; the harness's per-second `getBlockTemplate` keeps answering on every node at every boundary crossed (a failed template call at a boundary fails the run) |
|
||||
| (7) The cold-restart deadlock (13:25Z) | `igneum/exec/src/service.rs` `follow_once`: a node with nothing executed (its snapshot refused by the 0.3.25 digest stamp) hit the 6 October sink-age guard (sink older than 10 minutes: "waiting for consensus to sync before the executor starts"), retried every pass; with the chain stale (fault 6) every restarted node waited, class v5's check needs the executor, so none could validate or mine and no sink ever freshened: a network-wide deadlock | a node holding the chain from genesis replays whatever the sink's age. Fixed in place: f8da7515 on `release-0.3.25-node` (both mirrors; no digest change): `cold_start_replays(sink_age_ms, holds_chain_from_genesis)`, `Err` (the wait) only for a node that has synced nothing or whose retention root is above genesis, `Ok(true)` when the sink is stale but the node holds the chain from genesis, printing once "exec sync: the sink is N s old but this node holds the chain from genesis ... replaying from genesis whatever the sink's age"; test `cold_restart_tests::a_node_holding_the_chain_from_genesis_replays_whatever_the_sinks_age` (the pre-fix refusal asserted first) | layers 3 and 4 are the only boundaries that touch state, so their crossing test carries the cold-restart case; the prototype's fork branch is fast-forwarded onto f8da7515 | `--cold`: after the era boundary every node is stopped by its pid, left down over 10 minutes, its exec snapshot removed, and restarted from its kept datadir; on f8da7515 each node holding the chain from genesis prints the cold-restart line within its first follower pass and its executor tip climbs; on c9ad753a each prints the wait line every 60 s and the tip stays 0 (the known-failed binary); `--restart` (one node, a young sink) is the lighter case |
|
||||
|
||||
## 7. What each guarantee is (the founder's bar: never claim more)
|
||||
|
||||
| Claim | What it rests on | What that is |
|
||||
|---|---|---|
|
||||
| Every node draws the same value at every boundary | the reference-block rule; the stream's fixed order | in-node rule; tests in igneum-pow and the fork (section 9) |
|
||||
| No week's table leaves the band or exhausts | ring A; the three-block redraw and the genesis table | in-node rule; the fork's two known-failed tests; the census bounds the failing fraction at 1.0e-3 at 95 percent (measured), never a proof |
|
||||
| Layers 1 and 2 cannot split the chain | they never touch the derivation, the atom or the layout | a design rule the types enforce (the prototype's `EraDraw` has no derivation field) and the record's reading of the four faults (6.3) |
|
||||
| A family boundary is crossed clean | the self-test, rules 16 to 19, the vectors, the ban clearing | a fast-time crossing per node plus in-node rules; the three-node crossing on the box shows the path once |
|
||||
| A vote cannot carry one-sided or delay anything | the active and half-of-total tests; no vote while finality is paused; a target past the schedule dropped | in-node rules resting on the finality rule's own guarantee (sim v2: 0 conflicting locks in every partition scenario, measured in the simulator); the 720-index window against the sim is owed (layer4-miner-vote.md section 7) |
|
||||
| The detector's false-positive rate | the two crossing weeks and the 6 October baseline | measured days, arithmetic; the multi-card-host clique rate is derived and owed (one 8x Vast box for an hour measures it) |
|
||||
|
||||
## 8. The research lanes' recommendations, kept or rejected
|
||||
|
||||
| Lane | Recommendation (its five lines, shortened) | Kept or rejected | Why |
|
||||
|---|---|---|---|
|
||||
| layer1-hourly (34f95b6e) | (1) keep the hourly period; (2) close the 64-bit `program_rng` entropy cap with ledger M7's standard-hash stream; (3) add the warp-uniform block select, the register-file width draw and the full-permutation shuffle pattern; (4) never draw per-lane branches, the placement or N; (5) the refusals per failure mode | (1) KEPT; (2) KEPT as a generator change, so it rides a class cut and is named in section 10 as what a release still carries; (3) the shuffle pattern KEPT in the week's draw (slot 7) behind its AMD row; the block select and the register width REJECTED for this document (research, class v6 7c's layers 6 and 7; their censuses are owed); (4) KEPT; (5) KEPT | (2) is the one finding ProgPoW's audit made and it costs zero GPU; (3)'s two extra dimensions move nothing on the identity and their Apple compile footprint is unmeasured |
|
||||
| layer2-weekly (234c9067) | (1) five draws, never m, W, N or `pos`; (2) the three-ring acceptance with the 32-attempt judgement, three blocks, the genesis table; (3) no state agreement, the one thing that breaks it; (4) seven days; (5) the precedents | ALL KEPT; the mixer multiplier removed from this document's earlier draft of the week's draw | m is the verifier's budget (1.86x per doubling, measured) and a per-family axis; the lane's reading of the four faults is the proof section 3.3 needed; the prototype's chain path folds the week's reference into the era seed, which this round redraws `pos` as well (section 9 says so and what the generator entry must split) |
|
||||
| layer3-family-bank (fa5482d1) | (1) the bank's count and entry rule; (2) the sequencer core as the chip that survives; (3) the flip as a state-agreement boundary with the self-test, rule 18, rule 19, ban clearing; (4) 180 days aligned to the era, floor steps offset 90 days; (5) a release stays for a family beyond the bank, W = 8, a new derivation; bank refresh at n + 2 | ALL KEPT; this document's earlier "twenty entries" and "R0 derivation variant" corrected to the lane's 18 + 3 + 1 + 3 (there is no R0 in the spec) | the overlap with layer 2 (both draw B = 4 and the shape) resolved in 4.2: the epoch sets structure, the week draws values |
|
||||
| layer4-miner-vote (c2e8ecd0) | (1) the detector never flips anything; (2) opt-in bring-forward at two thirds of active weight, no veto; (3) its own tag, 720 checkpoints, the half-of-total floor; (4) the flip at the first boundary 14,400 DAA s after the tally; (5) the site line | ALL KEPT; this document's earlier veto, day-long window, frozen-table denominator and 6-sigma trigger withdrawn | a veto is a delay lever handed to the fleet the layer exists to remove; the active denominator is what stops silence blocking; the detector rang on two honest 5090s |
|
||||
| other-layers (2f986d70) | KEEP the floor's composition rule (the state read at the era cut only; a vote never lowers the floor); HOLD W in {4, 8} behind the PC 1 row; KEEP per-tier census and RISC Zero as genesis instruments only; KILL randomised epoch lengths, a sealed reserve class, VRF or beacon draws, per-block placement, per-tier shadow, the detector-armed hold, a drawn verifier budget, the kernel-fetch tell | ALL KEPT as verdicts; the Qubic read (a captured third blocks only the bring-forward; the 95 percent signal form is blocked from 5 percent of blocks) carried into 5.3 | a sealed class cannot be censused, and an uncensused class is the liveness risk the record measured (0.986 to 0.990 rejection on the per-load form) |
|
||||
|
||||
## 9. The prototype (research class; never a chain path without the switch)
|
||||
|
||||
On the fork branch `class-v6-rotation-node` (worktree `vendor/igneum-node-rotation` from the mirror's `release-0.3.25-node` at c9ad753a, the class v5 freeze tree `igneum-pow-v5` beside it under the untracked paths override) and `class-v6-rotation` (the harness and this document). What is built this afternoon, each piece named by what it shows:
|
||||
|
||||
- **Params**: `rotation_v6_activation_daa` (the switch; `u64::MAX` = never on every network; the digest arm entered only when set, so a binary carrying the field peers with one that does not), `rotation_era_daa` (layer 2: 604,800; 10,080 on the 60x file; 180 in the harness), `rotation_vote_window` (layer 4: 720 checkpoints; 4 in the harness), `rotation_vote_delay_daa` (14,400; 1,440 at 60x; 60 in the harness); each on `Params` and `OverrideParams`, the `From` and `override_params` lines, a key in `override-60x.json`. Layer 3 rides the existing 180-day era (`pow_era_blocks`, 420 in the harness).
|
||||
- **The schedule as pure functions** (`consensus/core/src/igneum/rotation.rs`): `weekly_index`, `weekly_reference_score`, `fold_era_seed`, `draw_era_params` with ring A's loop and the base table, `draw_family` from the bank, `vote_carries`, `flip_height_admissible`, `era_index_with_flip` and `era_start_with_flip` (the era clock `pow_era_index` and `pow_era_seed_score` now read the installed flip). Tests first, the known-failed ones first: `rotation_era_draw_exhausts_to_base_table`, `rotation_vote_one_of_three_does_not_carry`, `rotation_flip_height_outside_the_window_is_ignored`, then the accepting cases and the determinism cases (ten tests; the box's result in the SUMMARY).
|
||||
- **The chain path, behind the switch**: the week's reference block folded into the era seed the generator consumes (`fold_era_seed`), so the address draw changes weekly and a kernel of the old week is refused at packcheck and by every node's PoW check; the node's era line and family line at each boundary; the template unchanged in shape (the harness reads `eraIndex` and `eraSeed` from `getBlockTemplate` and the node's lines). Honest limit of this round: the fold redraws M, R AND the interleave `pos` (the week's slot list says M and R only; splitting the stream so `pos` stays with the era is the generator entry the next round adds in igneum-pow, a research build with the freeze check named on its start line); the weights, the shape and the pattern are drawn and accepted in the module and enter the program only through the folded seed until that entry exists.
|
||||
- **The vote**: the `FlipVote` item (tag 8, the Leave shape, carried last), its ingest with carriers, the tally at every lock (two thirds of active and half of total over the voters whose record has a carrier in the checkpoint's past and an index inside the window), the fold over the last N consecutive locks in `evaluate`, `install_flip` when it carries and the schedule admits the height, the node's flip line; the installed flip persisted in the finality state and re-installed on load (a restarted node would otherwise compute the plain schedule and refuse its peers' blocks: fault 7's class for any schedule state that is not a pure function of the chain); the miner's `IGNEUM_ROTATION_VOTE=<height>` (devnet only, the `IGNEUM_CLASS_SIGNAL` shape) appending the signed item to its vote submissions from a named epoch.
|
||||
- **The fast-time run** `infra/fast-time/class-v6-rotation.mjs` on build-3 through `tools/fast-time-remote.sh` at normal priority (nice, bounded cores): three nodes, three CPU miners, the 60x file with the week at 180 DAA, the era at 420, a vote from epoch 2 naming 660, window 4, delay 60; one run crosses hourly boundaries, week boundaries, the scheduled era boundary at 420 and the voted flip at 660, and passes only with zero refused blocks on every node, the sinks and counts equal, every node's era, family and vote lines equal, the program ids equal across the three miners at every epoch, a template served on every node at every boundary (fault 6) and, with `--restart`, one node restarted from its kept datadir after the era boundary re-synced to the others by the end (fault 7); the known-failed cases (`--voters 1 --expect flip` must report FAIL; `--expect off` must show no rotation line) first; the four cases run side by side on one box under one `fast-time-remote` run (`class-v6-rotation-cases.sh`, each on its own `--slot` of ports, suffix and data dir, at nice 19). Every process by pid file. The SUMMARY by 18:30 UK with the numbers.
|
||||
|
||||
### 9a. What the prototype's crossing showed (build-3, runs 1 to 4, 15:0x to 16:2x UK; the record under `infra/fast-time/out-v6r*`; every number from the harness's summary or the nodes' own lines)
|
||||
|
||||
The network: three nodes, three CPU miners, the 60x file with class v4 from genesis, the week at 180 DAA (the merge-depth lead 60), the family epoch at 420 (the era's own lead 30), a vote naming 720 from every voting miner's first checkpoint vote, the window 4 locks, the delay 60 DAA; the CPU bits 4x easier than the devnet's so twelve nice-19 miners on a box at load 20 to 40 make about 1.2 DAA a second; four cases side by side, 900 DAA each (16 hourly epochs, 5 week boundaries, 2 family boundaries).
|
||||
|
||||
| Case | Verdict | What it showed | Label |
|
||||
|---|---|---|---|
|
||||
| `--expect off` (the switch never) | PASS (run 2; run 4 the same on every check but the timeout line below) | the node prints its off line, no week or family line, 300 blocks on three equal sinks, 0 refused | measured, run 2 |
|
||||
| `--voters 1 --expect flip` (known-failed first) | FAIL as required (runs 1, 2, 4) | one key of three signs 41 to 50 of 120 weight at every lock ("short" on the tally line), no flip line on any node, the family epochs on the schedule (420, 840), 0 refused by any node, sinks equal, 900/900/900 | measured, run 4 |
|
||||
| `--voters 3 --expect flip` | the vote carried at lock 15 (daa 449; 11 contiguous passing locks, share 10,000 bps) on all three nodes at the same lock, the family epoch brought forward to 720 (the family line "from reference block ... (daa 690)" on every node), the schedule re-based (the next boundary 1,140, not 840), 0 refused by any node at every one of the 16 hourly, 5 weekly, the scheduled and the voted boundary, sinks equal, 900/900/900; every check PASS but `zero_rejected_by_miners`, whose one line is a miner's submit RPC timeout on the loaded box (run 4) | measured, run 4 |
|
||||
| `--voters 3 --expect flip --cold` | the cold-restart line of f8da7515 on all three nodes after 660 s down with the exec snapshots removed ("the sink is 669 s old but this node holds the chain from genesis ... replaying"), no wait line; the rest as the flip case (run 2's read; run 4's summary in the SUMMARY) | measured, runs 2 and 4 |
|
||||
|
||||
The three faults the runs found in the prototype, each fixed the same hour and each a rule for the design: (1) the flip tally excluded a key whose latest vote index was above the locking index (the miner votes ahead of the lock): the latest vote counts whatever its index; (2) a flip vote reaches other nodes only inside the block that first carries it, so on every other node a key's record stays at its first index: a carried vote STANDS until the key names another target, the window is the consecutive LOCKS that pass and the carrier-in-past test is the binding (an expiry of one weight window is the next round's item; the finality gossip could carry flip votes like checkpoint votes); (3) the brought-forward boundary lived in a process-wide static and a restarted node would have computed the plain schedule and refused its peers' blocks: the installed flip is persisted in the finality state and re-installed on load (fault 7's class for any schedule state that is not a pure function of the chain). Two facts about the test network, not the design: class v5 at genesis needs every node's executor (the state stream for the PoW check and the prepare), which the fast-time file cannot give, so the crossing runs on class v4; and on the 60x file the merge-depth lead is 60 DAA, so a week's reference block sits a third of a week below its boundary (3,600 of 604,800 on the devnet).
|
||||
|
||||
## 10. The "automated" claim for the site, stated honestly
|
||||
|
||||
Once shipped, the chain changes its own hash on four clocks with no release and no hand: every hour a new program, every week a new table for it, every 180 days a new family set from the bank written into the chain at genesis, and in an emergency the next family set brought forward by a vote the miners carry in the finality votes they already sign. Every change is drawn from a block the chain already agreed on, checked by the same rules on every node, and refused at the boundary by every node if a miner gets it wrong, so none of the four can split the chain by a bad draw; the team holds no key, no switch and no date, and the detector every node shows is a warning light, not a switch. What still needs a release: a family the genesis bank does not hold, a read width or a dataset derivation outside the genesis list, a wider band, a new generator (the 64-bit stream's successor is one); the automation rotates what genesis shipped, and nothing it did not.
|
||||
|
||||
## 11. Unverified and owed
|
||||
|
||||
- Lane D's count of band eras over 32 attempts (the week's redraw rate: under 0.1 percent by the max of 59, the exact figure owed).
|
||||
- The 720-index window against `sim/finality_v2.py` under the F2 eclipse and the bought-keys case (finality lane); the clique test's false-positive rate on an 8-card rental host (one Vast box for an hour); the 14,400 DAA lead against a family change on AMD and Apple (bench lane, PC 1); the pruning-proof node's tally (node lane).
|
||||
- The AMD `ds_bpermute` row for the shuffle pattern; the Apple compile footprint of layers 6 and 7.
|
||||
- The generator entry that splits the week's slots from the era's (section 9); the index fold's form and vectors; the 2,880-era harness case with a restart across a week boundary from a kept datadir.
|
||||
- Every chip figure is the chip model's; no chip has been measured.
|
||||
|
|
@ -89,7 +89,7 @@ Reading: layer 2 does not move the chip anyone builds, because a chip buys DRAM
|
|||
|
||||
### 3.3 Per tier
|
||||
|
||||
The hash lane's VRAM rows (12:0x UK, modelled from the measured 0.4 GiB working set plus about 0.5 GiB of driver and app): the dataset needs 3.2, 5.4 and 9.9 GiB of device memory at the floor, 2x and 4x; a 12 GB card falls off at about 9.5 GiB (year 15 on the 1.13.3 schedule), a 16 GB GPU at about 13.5 GiB (year 23), a 16 GB unified Mac at about 8 GiB (year 12), the 5090 at about 29 GiB (year 54). **The DRAM-read cost per hash on the NVIDIA cards is NOT size-independent at the knee, MEASURED (the hash lane's kit b, PC 1, 12:49 to 12:58 UK, the pinned class v3 program 73bcbfe8 at 2, 4 and 8 GiB against the 1 GiB control 137.65 MH/s at 312.2 W unlocked and 127.39 at 212.6 W at the 1,300 MHz lock; 250 batches per row, fingerprints PASS): unlocked 133.86 at 315.7 W (-2.8 percent), 132.42 at 317.2 (-3.8), 131.75 at 319.2 (-4.3); at the lock 121.14 at 210.3 W (-4.9 percent), 113.06 at 204.5 (-11.2), 109.39 at 201.6 (-14.1); MH/W at the lock 0.599, 0.576, 0.553, 0.543, which is 4 / 8 / 10 percent more energy per hash at 2 / 4 / 8 GiB.** The lane's earlier reading (2 MiB pages keep the TLB's reach past 8 GiB) holds unlocked, where the card hides most of the page-walk term in its slack; the latency-bound regime at the lock exposes it. **The genesis floor itself, 5.5 GiB, MEASURED on a rented 5090 at stock (the fleet hand, RunPod secure, driver 570.195, 15:19 to 15:23 UK, the ca3-ds55 kit's own worker, program 73bcbfe8 in both packs, 250 and 500 x 2^24, every row PASS with 96 of 96 vector lanes): the 1 GiB control 141.48 MH/s at 325.6 W (2.30 microjoules; memory.used peak 1,914 MiB) against ds55 136.56 at 305.3 W busy, 327.6 steady (2.24 busy, 2.40 on the steady watts; peak 6,522 MiB: the 5.9 GB dataset plus the 268 MB cache plus the context), fingerprints stable across both passes (ds55 23ced07a4d28b465 becomes the pin). So the 5.5 GiB floor costs 3.5 percent of the rate at the same watts, about 4 percent more energy per hash, and the non-power-of-two mapping (1,476,395,008 words, 92,274,688 items, loads as (src x words) >> 32) is not a cliff on sm_120. Caveat: this host's 5090 plateaued at 328 W on both packs (a host power cap; another host's 5090 pulled 443 W on class v5 genesis this afternoon), so the microjoules are capped-card numbers and the rate and fingerprints are the row.** Lane 3's reading of it for the schedule (2a61cb46, 15:25 UK): the step lands between the 2 and 4 GiB stock rows (2.8 and 3.8 percent), so the non-power-of-two floors of 5.5 / 8.5 / 11.5 cost nothing beyond the size and the multiply-shift mapping is safe to adopt at the v6 epoch; per tier the 5.5 GiB step costs a 5090 4 percent per hash at stock (measured) and about 9 at its knee (interpolated from the 2, 4, 8 GiB knee rows); the 8 GB tier's fate at that step is the RX 7600 reading from PC 1 (about 16:00 to 16:30 UK, an amendment; its 1.8 GB of headroom is the question), the 5.5 GiB knee row with it; the SRAM store at that step is 3 reticles, USD 1,500, its joules unmoved. So each step of the schedule costs a tuned 5090 about 4 to 5 percent per hash while the chip's joules do not move (floor lane 3: its ticket goes USD 1,500 / 2,500 / 3,000 at 5.5 / 8.5 / 11.5 GiB), and every chip edge against a card at its knee rises by 4 to 11 percent across the schedule; the honest sentence for the schedule decision is USD 1,000 of chip ticket per step for about 1 to 5 percent of the tuned 5090's energy and about a quarter of today's measured cards by count. **On the M5 Max it is not size-independent, measured by this lane at 10:40 UTC under the Mac's measure lock (Metal packbench, the hash lane's class v3 packs at 2^28 to 2^31 words, the same seed and era, 3 batches of 2^24, vectors 3 of 3 and fingerprints per pack): 26.48 MH/s at 1 GiB (footprint 1,664 MiB, the build 32 ms), 23.26 at 2 GiB (-12.2 percent; 2,688 MiB; 54 ms), 21.31 at 4 GiB (-19.5 percent; 4,736 MiB; 94 ms), 20.61 at 8 GiB (-22.2 percent; 8,832 MiB; 193 ms).** The Apple GPU's dependent random read costs more time as the working set grows past its page reach (approximate reading: a TLB-reach effect on unified LPDDR5X; the power channels were not sampled this run, so the joules per hash move by at least the rate's share), which is a real per-tier cost of layer 2 that the NVIDIA model does not show: at an 8 GiB floor the Apple tier mines 22 percent slower per card than at 1 GiB, before any memory limit. The table below carries it.
|
||||
The hash lane's VRAM rows (12:0x UK, modelled from the measured 0.4 GiB working set plus about 0.5 GiB of driver and app): the dataset needs 3.2, 5.4 and 9.9 GiB of device memory at the floor, 2x and 4x; a 12 GB card falls off at about 9.5 GiB (year 15 on the 1.13.3 schedule), a 16 GB GPU at about 13.5 GiB (year 23), a 16 GB unified Mac at about 8 GiB (year 12), the 5090 at about 29 GiB (year 54). **The DRAM-read cost per hash on the NVIDIA cards is NOT size-independent at the knee, MEASURED (the hash lane's kit b, PC 1, 12:49 to 12:58 UK, the pinned class v3 program 73bcbfe8 at 2, 4 and 8 GiB against the 1 GiB control 137.65 MH/s at 312.2 W unlocked and 127.39 at 212.6 W at the 1,300 MHz lock; 250 batches per row, fingerprints PASS): unlocked 133.86 at 315.7 W (-2.8 percent), 132.42 at 317.2 (-3.8), 131.75 at 319.2 (-4.3); at the lock 121.14 at 210.3 W (-4.9 percent), 113.06 at 204.5 (-11.2), 109.39 at 201.6 (-14.1); MH/W at the lock 0.599, 0.576, 0.553, 0.543, which is 4 / 8 / 10 percent more energy per hash at 2 / 4 / 8 GiB.** The lane's earlier reading (2 MiB pages keep the TLB's reach past 8 GiB) holds unlocked, where the card hides most of the page-walk term in its slack; the latency-bound regime at the lock exposes it. **The genesis floor itself, 5.5 GiB, MEASURED on a rented 5090 at stock (the fleet hand, RunPod secure, driver 570.195, 15:19 to 15:23 UK, the ca3-ds55 kit's own worker, program 73bcbfe8 in both packs, 250 and 500 x 2^24, every row PASS with 96 of 96 vector lanes): the 1 GiB control 141.48 MH/s at 325.6 W (2.30 microjoules; memory.used peak 1,914 MiB) against ds55 136.56 at 305.3 W busy, 327.6 steady (2.24 busy, 2.40 on the steady watts; peak 6,522 MiB: the 5.9 GB dataset plus the 268 MB cache plus the context), fingerprints stable across both passes (ds55 23ced07a4d28b465 becomes the pin). So the 5.5 GiB floor costs 3.5 percent of the rate at the same watts, about 4 percent more energy per hash, and the non-power-of-two mapping (1,476,395,008 words, 92,274,688 items, loads as (src x words) >> 32) is not a cliff on sm_120. Caveat: this host's 5090 plateaued at 328 W on both packs (a host power cap; another host's 5090 pulled 443 W on class v5 genesis this afternoon), so the microjoules are capped-card numbers and the rate and fingerprints are the row.** Lane 3's reading of it for the schedule (2a61cb46, 15:25 UK): the step lands between the 2 and 4 GiB stock rows (2.8 and 3.8 percent), so the non-power-of-two floors of 5.5 / 8.5 / 11.5 cost nothing beyond the size and the multiply-shift mapping is safe to adopt at the v6 epoch; per tier the 5.5 GiB step costs a 5090 4 percent per hash at stock (measured) and about 9 at its knee (interpolated from the 2, 4, 8 GiB knee rows); the 8 GB tier holds at that step on an exact NVIDIA 8 GB card (the RTX 4060, 6,116 MiB resident, measured 16:38 UK, 10.0p) and on a 12 GB card (the RTX 3060, 6,129 MiB), the RX 7600 row for the AMD 8 GB case still owed, the 5.5 GiB knee row with it; the SRAM store at that step is 3 reticles, USD 1,500, its joules unmoved. So each step of the schedule costs a tuned 5090 about 4 to 5 percent per hash while the chip's joules do not move (floor lane 3: its ticket goes USD 1,500 / 2,500 / 3,000 at 5.5 / 8.5 / 11.5 GiB), and every chip edge against a card at its knee rises by 4 to 11 percent across the schedule; the honest sentence for the schedule decision is USD 1,000 of chip ticket per step for about 1 to 5 percent of the tuned 5090's energy and about a quarter of today's measured cards by count. **On the M5 Max it is not size-independent, measured by this lane at 10:40 UTC under the Mac's measure lock (Metal packbench, the hash lane's class v3 packs at 2^28 to 2^31 words, the same seed and era, 3 batches of 2^24, vectors 3 of 3 and fingerprints per pack): 26.48 MH/s at 1 GiB (footprint 1,664 MiB, the build 32 ms), 23.26 at 2 GiB (-12.2 percent; 2,688 MiB; 54 ms), 21.31 at 4 GiB (-19.5 percent; 4,736 MiB; 94 ms), 20.61 at 8 GiB (-22.2 percent; 8,832 MiB; 193 ms).** The Apple GPU's dependent random read costs more time as the working set grows past its page reach (approximate reading: a TLB-reach effect on unified LPDDR5X; the power channels were not sampled this run, so the joules per hash move by at least the rate's share), which is a real per-tier cost of layer 2 that the NVIDIA model does not show: at an 8 GiB floor the Apple tier mines 22 percent slower per card than at 1 GiB, before any memory limit. The table below carries it.
|
||||
|
||||
| Tier | At the floor (today to year 4) | At a 4 GiB state-driven step | At 16 GiB | Label |
|
||||
|---|---|---|---|---|
|
||||
|
|
@ -443,7 +443,7 @@ The conditions, read off the surface, which are the economic-resistance statemen
|
|||
|
||||
**The sentence, as the external review words it (10.0f item 5), served verbatim, with one word made honest (the site audit lane's read, 16:5x UK: class v4 and v5 have eight registers per lane and the window is class v6's new core shape, so "retains" is read as "retains across every rotation"):** "Class v6 adopts the 64-register window and retains it across every rotation. Current modelling estimates a 2.2x to 2.4x energy-efficiency advantage for the strongest specialised designs assessed against the GPU tier (2.0x on the GPU's own node). The long-program and select-tree proposals were rejected. Economic resistance depends on development cost, deployment economics and productive hardware lifetime; family transitions receive an obsolescence benefit only where a loss of competitiveness is demonstrated; programmable multi-epoch designs are included in the assessment."
|
||||
|
||||
**Where the figures come from (the coordinator, 16:1x UK): the served sentence's numbers are read from the k lane's PLACED GATED rows (17:30 UK; 10.0i), not from 725d2945's close and not from the 16:0x synthesis; until they land the figures sit in 10.0i's bracket, and the serve slips to 18:30 if the rows are late.** The labels on its numbers as first written: "2.2x to 2.4x" is modelled (the GPU side measured: the RTX 5080 at its 1,100 MHz lock 2.06 microjoules per hash and the RTX 5090 at its 1,300 MHz lock 2.33, both on class v4, PC 1 and rented pods, 8 October 2026; the chip side the k lane's synthesised 8-lane sequencer core with the 64-register window on ASAP7, scaled to N3 on TSMC's headline factors, claimed; the chip's memory the chip model's GDDR7 board, modelled; the card's cost of the window measured at stock on a rented 5090 and 4090 at 16:4x UK, within 5 percent per load with the liveness chain, no spill). "2.0x on the GPU's own node" is modelled (the same core node-for-node, k 1.09). Against the 32-lane window core the adversary would build the same figures read about 2.4x to 2.6x a node ahead and 2.0x to 2.2x node-for-node (synthesised, pending the re-optimised row by 18:00 UK); the served sentence's range is kept as the review wrote it and the 32-lane rows sit beside it on the page as the pending row. The window's k is synthesis-derived and not a lower bound.
|
||||
**Where the figures come from (the coordinator, 16:1x UK): the served sentence's numbers are read from the k lane's PLACED GATED rows (10.0i), not from 725d2945's close and not from the 16:0x synthesis; the placement slipped to about 18:30 UK, so the sentence served at 18:30 carries 10.0i's tightened bracket, "estimates a 2.5x to 3.0x energy-efficiency advantage for the strongest specialised designs assessed against the GPU tier (2.1x to 2.6x on the GPU's own node)", and the placed row narrows it to one figure each on the next landing.** The labels on its numbers as first written: "2.2x to 2.4x" is modelled (the GPU side measured: the RTX 5080 at its 1,100 MHz lock 2.06 microjoules per hash and the RTX 5090 at its 1,300 MHz lock 2.33, both on class v4, PC 1 and rented pods, 8 October 2026; the chip side the k lane's synthesised 8-lane sequencer core with the 64-register window on ASAP7, scaled to N3 on TSMC's headline factors, claimed; the chip's memory the chip model's GDDR7 board, modelled; the card's cost of the window measured at stock on a rented 5090 and 4090 at 16:4x UK, within 5 percent per load with the liveness chain, no spill). "2.0x on the GPU's own node" is modelled (the same core node-for-node, k 1.09). Against the 32-lane window core the adversary would build the same figures read about 2.4x to 2.6x a node ahead and 2.0x to 2.2x node-for-node (synthesised, pending the re-optimised row by 18:00 UK); the served sentence's range is kept as the review wrote it and the 32-lane rows sit beside it on the page as the pending row. The window's k is synthesis-derived and not a lower bound.
|
||||
|
||||
The lines the page carries beside it, each labelled:
|
||||
- The three statements, separate (10.0g item 1): energy resistance (the figures above); economic resistance (the profitability surface of 10.0f item 2, lane 3's first cut, modelled: p* scales as the project cost over the share times the discounted life, and under 5 percent with the per-joule edge; the cheapest attractive project is a USD 20 M DRAM-board design taking the whole chain for three years at about IGN 0.02 to 0.03, at a third 0.055 to 0.10; the SRAM die at N2 0.22 to 0.73; a fixed-lane chip under rotation needs 4x the price of a programmable one; stated as the conditions under which development is attractive); response capability (a passed rotation boundary proves the rotation works, not that hardware dies; the schedule of 10.0d: hourly, weekly, 180-day family, emergency vote; measured per boundary).
|
||||
|
|
@ -471,6 +471,16 @@ The live-state analysis (`livestate.py`, 64 drawn programs, 1,024 waits): under
|
|||
|
||||
Two corrections this forces on the served numbers: (1) the honest adversary's base core is the GATED one, k 0.37 at N3 and 0.51 node-for-node, below the 0.56 and 0.78 of 14:0x (those are the GPU-shaped core a maker would not build); (2) placement adds more than the +30 percent estimated at 14:1x: the ungated placed core reads 11.3 pJ against 6.9 synthesised (+64 percent: wires and a 2.5 pJ clock tree). **So until the placed gated rows land (in flight on a rented pod, 17:30 UK) the served figures sit in a bracket, from the synthesised gated rows (4.5 and 6.2 pJ per lane-op: the GDDR7 board 3.3x to 2.9x at the lock at N3, 2.9x to 2.5x node-for-node) to the placed ungated row (11.3 pJ: 2.3x at N3 and 2.0x node-for-node for the base core, the window below it), with the placed gated figure expected near 6 to 8 pJ (approximate): about 2.6x to 3.1x at the lock at N3 and 2.3x to 2.7x node-for-node, the window about 0.3x under the base.** The placed gated rows replace this bracket as the served number when they land, and 10.0h's figures are read from them.
|
||||
|
||||
The row to serve at 18:30 UK (the k lane, 17:5x UK; the placement of the gated 64-register core slipped to about 18:30 on a floorplan timing repair, the other five placed rows by 21:00): on the gated 64-register core, synthesis-only, a model never a lower bound (the GDDR7 board at the 5090's 1,300 MHz lock, 2.33 microjoules; E_chip = 0.466 + 102,100 x e_chip; node factors claimed):
|
||||
|
||||
| Figure | Chip pJ per lane-op | E_chip microjoules | The edge at the lock | Label |
|
||||
|---|---|---|---|---|
|
||||
| node-for-node (N5, ASAP7 x0.70) | 4.3 | 0.905 | 2.6x | synthesised |
|
||||
| a node ahead (N3) | 3.1 | 0.783 | 3.0x | synthesised, scaling claimed |
|
||||
| two nodes ahead (N2) | 2.2 | 0.691 | 3.4x | synthesised, scaling claimed |
|
||||
|
||||
The placed figure runs 30 to 65 percent over synthesis on this flow (the ungated base came in 64 percent over, wires and a clock tree, which gating removes in part), so the placed gated core is expected at 7 to 9 pJ at ASAP7, which puts the served figures at 2.1x to 2.4x node-for-node and 2.5x to 2.8x a node ahead (approximate until the placed row). **So the served bracket of this section holds and tightens to its lower half, and the honest sentence until the placed row is: "estimates a 2.5x to 3.0x energy-efficiency advantage (2.1x to 2.6x on the GPU's own node)"**, the placed row narrowing it to one figure each. The GPU side of the window is measured (no spill, at most 5 percent per load); the connected-state and multi-family lanes' rows agree with this core within 5 percent.
|
||||
|
||||
#### 10.0j Amendment after the landing (16:4x UK): the multi-family adversary lane's first core, and a disagreement between two models that the placed rows settle
|
||||
|
||||
The multi-family adversary lane (a1a9876a88f5a72fc; synthesis only, ASAP7 TC, a gate-level random-input VCD; the SRAM macro energy modelled with a band; node factors claimed; for class v7, but it bears on the served window line): one in-order SIMD core with the 64-register window in a FakeRAM 64 x 256 macro per 8 lanes (one 256-bit access serves eight lanes), the imem in two 256 x 34 macros, every unit operand-isolated, a 5-phase single-port slot (throughput bought with lanes, not ports), every bank entry firmware. The genesis-only variant on the class v4 draw: 5.9 pJ per lane-op at ASAP7 (band 5.1 to 7.7), 4.1 at N5, 3.0 at N3; the card pays 10.3 pJ per op on the same draw at the 1,300 lock, so k = 0.40 node-for-node (N5), 0.29 a node ahead (N3), 0.22 / 0.16 at stock. Per family (k N5 / N3 at the lock): the add class 0.45 / 0.33, or 0.36 / 0.26, mul 0.32 / 0.23, mad 0.55 / 0.40, mulhi 0.12 / 0.09, shfl 0.083 / 0.060, the load with the fold 0.43 / 0.31. The whole-hash shadow 0.42 microjoules at N5 (0.31 at N3), so the GDDR7 board reads 2.6x against the 5090 at its lock node-for-node (2.9x a node ahead) and 2.3x / 2.6x against the 5080. **The lane's reading: a re-optimised core sits 15 percent under the k lane's 32-register flop core and 40 percent under its 64-register flop core at the same node, and the register-window knob buys the card nothing once the adversary puts the state in a macro.**
|
||||
|
|
@ -527,6 +537,30 @@ The advantage, separated (the chip at 0.06): the board over a Blackwell owner 0.
|
|||
|
||||
**RESPONSE capability:** rotation costs a chip versatility, not life. The 18-family bank costs a chip firmware plus 43 percent of its core cells and 11 percent of its shadow energy, with zero obsolescence credit on any transition in the bank (10.0m); a passed boundary proves the rotation works (10.0d). The window: its form is not the lever (the gated flop file and the macro file agree within 5 percent; the residual over 32 registers 0.3 to 0.4 pJ); its cost to the card is under 1 percent, measured on a 5090 (+0.6 percent) and a 4090 (-0.9 percent) at stock on 8 October (the connected-state lane, replacing "about 0, unmeasured"); and the connected-state class is KILLED (the connected window moves the chip's edge 1.10x and 1.08x against the 1.25x gate on the k lane's re-optimised core; necessity costs a clock-gated file nothing, since it pays per write, not per live register; only the window's width reaches the chip, +0.14 of k).
|
||||
|
||||
#### 10.0o Amendment (16:3x UK): the mixed FP32 candidate, KILLED on the GPU budget and the full-board score (`docs/analysis/class-v6/mixed-fp32.md` on class-v6-mixedfp at 255be026; all measured unless marked; for class v7)
|
||||
|
||||
The candidate: the class v6 shape unchanged, four FP32 families (fadd, fmul, ffma, fcvt) drawn in the shadow block beside the ten integer families behind `IGNEUM_FG_FP32` (harness only), every result xor-injected, every operand a masked bitcast with the exponent field confined to 96..159 so no input or result is ever denormal, NaN or infinite; round to nearest even, no contraction, no fast-math, written in the emitter for CUDA, OpenCL and Metal; two weights, fp12 (18 percent of the shadow FP on the seed) and fp24 (30 percent).
|
||||
|
||||
| Reading | The numbers | Label |
|
||||
|---|---|---|
|
||||
| Determinism | bit-identical CPU reference against CUDA on Ada and Blackwell: the pack self-test PASS on three rented cards for every pack, the 2^24 fingerprint equal on the CPU and all three cards for ctrl (5203e444a20bc754), fp12 (d9ddef1fa7a7895a) and fp24 (8fdedbb54ad3614f); Metal and the AMD OpenCL row owed | measured (PROVED on CUDA) |
|
||||
| Census (sub-version 3, build-4, 256 seeds no era and 256 across eras 0 to 7) | both candidates 256 of 256 both ways, 0 exhausted, r 0.65 to 0.81 against the record's 0.67 to 0.83; the bias instruments fire 7x to 19x the record ((c'') 54 and 88 refusals against 8, (c''') 15 and 19 against 1, the era window-bit test 27 and 35 percent of candidates against 14.5); the F8-form read finds hot items (271 and 740 reads against the control's 29) on 1 of 16 and 2 of 16 seeds: an IEEE result's exponent byte carries 3 to 5 bits of entropy and the xor lands it on address bits 23 to 30 | measured |
|
||||
| The verifier | the quiet core +5.6 percent (fp12), +8.2 (fp24); loaded, fp24 sits on the 10 ms line | measured |
|
||||
| The card at stock (the class v5 kit worker, 250 x 2^24, nvidia-smi 1 Hz) | the 5090 3.553 microjoules per hash on ctrl, 4.078 on fp12 (+14.8 percent, 140.7 MH/s held, the card at its 575 W cap), 4.034 capped on fp24 (+13.5 with the clock down 108 MHz); the 4090 4.759, 5.664 (+19.0) and 6.022 (+26.5); v5-genesis +1 percent on both. 52 pJ per FP family op on the 5090, four fifths of it the determinism tax (the four integer ops per operand); the 10 percent budget allows about 11 percent of the shadow FP on the 5090, 9 on the 4090 | measured |
|
||||
| The chip side (modelled on the k lane's method; its synthesised rows owed) | an FP32 FMA lane on its own is the family a chip undercuts least, k 0.4 to 0.5 at the lock against mad's synthesised 0.20 (the hypothesis's grain of truth), but the drawn op is the FMA plus its masking, which is ARX work on both sides, so the blended k of an ffma family op is 0.19 and an fadd's 0.18: the integer families' own | modelled |
|
||||
| The full board, E_GPU over E_adversary at the 5090's lock | 3.0x on the record, 3.2x under fp12 (3.2x and 3.4x a node ahead): the candidate raises the chip's edge about 7 percent while costing every card 15 to 26 percent | modelled on measured card rows |
|
||||
|
||||
**KILL.** The meaning for v7: FP32 is deterministic across CUDA and the CPU under the stated rules; the determinism tax is the whole economics (any FP form a GPU runs bit-exactly on random registers needs the operand confined, and confinement is integer work at integer k); the exponent-byte bias is a new instrument reading (the F8-form max-item column, 271 and 740 against 29, which no rule reads today) worth a rule in layer 4. The FP32 candidate joins the long program, the select tree, the wide read and the scratchpad in the suite as a negative control (10.0g item 2).
|
||||
|
||||
#### 10.0p Amendment (16:38 UK): the 8 GB and 12 GB tiers at the 5.5 GiB floor, the window on them, and proving beside mining (the fleet hand's v6-coexist rows on exact rented cards; the ds55 kit's own worker; measured; logs in the hand's run.log per row)
|
||||
|
||||
| Card | The 5.5 GiB dataset (ds55, the pinned class v3 program, 60 s rows) | The 64-register window (the v5 kit worker: hl-reg64c, the full chain; hl-reg64) | Proving beside mining (SP1 on the patched floor, the shard fees-v1-shards2 shard 0, 4.7 M cycles) | Label |
|
||||
|---|---|---|---|---|
|
||||
| RTX 3060 12 GB (driver 610, a 170 W limit) | 26.82 MH/s at 117.4 W, 6,129 MiB resident, the 5090's fingerprint 23ced07a4d28b465, PASS: **the 12 GB tier holds at the floor** | hl-reg64c 13.47 MH/s at 120.8 W (87 registers, 16 of 24 blocks per SM, fingerprint MATCH); hl-reg64 13.48 at 119.3 W (104 registers): per load (256 against 128 a hash) 3,448 against 3,433 M loads a second, **the window free per load, +3 percent of watts** | the proof alone 13.2 s VERIFIED at a 7,525 MiB peak and 122 W; together 6,129 + 7,525 = 13,654 MiB against 12,288: TIME-SHARING NEEDED (the live attempt filled the card to 11,893 MiB and the prover died in a device allocation after 34 s while the miner held 26.51 MH/s) | measured |
|
||||
| RTX 4060 8 GB (driver 570, a 115 W limit; the host's power sensor N/A, so no watts) | 18.84 MH/s, 6,116 MiB resident, the same fingerprint, PASS: **the 8 GB tier holds at the floor** (the question lane 3 left open in 3.3, answered on an exact 8 GB card) | hl-reg64c 9.51 MH/s (87 registers, 20 of 24 blocks); hl-reg64 9.57 (104 registers, 16 of 24): per load 2,435 against 2,412, **the window free per load** | the proof alone 8.2 s VERIFIED at 7,532 MiB; together 13,648 MiB against 8,188: TIME-SHARING NEEDED (the live attempt died in the server's tensor allocation at 7,811 MiB while the miner held 18.83 MH/s) | measured |
|
||||
|
||||
What it moves: layer 2's per-tier table (3.3) gains two measured rows at the genesis floor, the 8 GB and 12 GB tiers both holding with the dataset resident (6.1 GB) and the RX 7600 row still owed for the AMD 8 GB case; the window's cost to the card is now measured free per load on Ampere and Ada low tiers as well as on the 5090 and 4090 (10.0e, 10.0n); and the proving statement (10.0f item 4: proving is an opportunity for GPU owners) carries its memory condition: at the 5.5 GiB floor an 8 GB or 12 GB card cannot hold the miner and the SP1 prover at once (13.6 GB together) and time-shares them, while a 16 GB card and up co-resides. Not measured: the core-only beside row (the prove host has no core mode); watts on the 4060. A fault for the floor lane: the served sm_89 tarball (igneum-floor-sm89.tgz) ships the stock SDK server in home/.sp1/bin (the 24 GB gate) while bin/ holds the patched one; the hand copied bin/ over home on the pod, so the 4060 proof rows are on the patched server.
|
||||
|
||||
#### 10.0d The rotation schedule the close adopts (the rotation lane, `docs/design/class-rotation-four-layers.md` on class-v6-rotation at bd43f808, build-3, gate green, 14:4x UK; one line per layer; both of this document's constraints held: the 180-day family epoch not shorter, W = 4 not drawn)
|
||||
|
||||
| Layer | Boundaries a year | What it draws, from where | Exposure per boundary (this document's units) | Chip | Label |
|
||||
|
|
|
|||
245
docs/design/pool-vote-key-commitment.md
Normal file
245
docs/design/pool-vote-key-commitment.md
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
# Pool vote-key commitment: the member's key stays with the member, at protocol level
|
||||
|
||||
Design document, Igneum 2.0, "Pools, software and participation", first bullet. Written 8 October 2026 (evening, UK)
|
||||
by the adversarial seat, from the code and the specification as they stand on the box mirror's master (d6bee526) and
|
||||
the pool lane's branches (`pool-finish-22` bee1f5f2, the fork's `pool-tags-node` 7455b8d5). Design only: no consensus
|
||||
code changes here, and the pool lane's 0.3.20 branches are not touched. Status labels follow the specification's:
|
||||
Implemented (in code on a named branch), Designed (written, not run), Measured (a number with its log).
|
||||
|
||||
The pin this document serves, in one sentence: a miner's finality vote key is committed into every share and every
|
||||
block the miner's hardware produces, whichever pool distributes the work and collects the pay, so a pool's share of
|
||||
hashrate never becomes a pool's share of votes unless the miner hands over the key on purpose, and that hand-over is
|
||||
visible on chain.
|
||||
|
||||
## 1. The current state, from the code
|
||||
|
||||
### 1.1 Where the vote key is bound today
|
||||
|
||||
| Binding | Where | What it says | Status |
|
||||
|---|---|---|---|
|
||||
| The header carries the key | `vendor/igneum-node/consensus/core/src/header.rs:174`, `pub vote_key_hash: Hash` | 32 bytes, BLAKE2b under the domain `IgneumVoteKeyHash` of the 48-byte compressed G1 BLS key (spec 03 W1, table row W1). Zero only in genesis; any other header without it is `RuleError::MissingVoteKeyHash` | Implemented |
|
||||
| Proof of work commits the key | `consensus/core/src/hashing/header.rs:28`, `.update(header.vote_key_hash)` in the header hash | The pre-PoW hash (the `prehash` every job carries) is a hash over every header field but the nonce, `vote_key_hash` among them (spec 02 fork point a5). A nonce that solves one prehash solves no other, so a share or a block is work on exactly one key | Implemented |
|
||||
| The key is revealed once | `consensus/core/src/finality.rs:31` (`IGNK`), `:259` (`KeyReveal`, W1) | The first block under a key carries `IGNK || pubkey (48) || pop (96)` in the coinbase extra data; the node checks the proof of possession and that its hash equals the header's `vote_key_hash` | Implemented |
|
||||
| Weight reads the header | spec 03 W2 to W4; the node's finality module (`docs/fork-divergence.md`, "Finality v2") | A key's 30-day weight is the blue blocks whose header names it. Nothing in weight reads the coinbase beyond the reveal | Implemented |
|
||||
| The miner sets it | `vendor/igneum-node/igneum/miner/src/main.rs:480`, `raw.header.vote_key_hash = id.key_hash`; `:74`, the reveal into extra data | The solo miner writes its own identity into every template it hashes | Implemented |
|
||||
| A placeholder survives | `header.rs:257`, `placeholder_vote_key_hash(payout_script)` | Devnet v0's stand-in, a hash of the payout script; any non-zero value was accepted "until the finality layer lands". The finality layer landed; the function remains callable | Implemented, to retire (5.3) |
|
||||
|
||||
### 1.2 Where the pay is bound today
|
||||
|
||||
| Binding | Where | What it says | Status |
|
||||
|---|---|---|---|
|
||||
| The coinbase names the payee | `consensus/core/src/evm.rs`, `miner_address_extra_data` (`IGNA` + 20 bytes) | The execution layer credits 80 percent of a blue block's subsidy to the coinbase's `IGNA` address (`pool/src/payout.rs:1`), the other 20 percent to the proving pool escrow | Implemented |
|
||||
| The pool pays the pool | `pool/src/node.rs:32`, `extra_data(member, pool_address)`: the member's reveal plus the pool's `IGNA`; `:42`, `get_block_template(pool.pay_address, ...)`; `:47`, `raw.header.vote_key_hash = member.key_hash` | Pool v0 builds one template per member: the member's key in the header, the pool's address in the coinbase. The chain pays the pool; the pool's ledger (`pool/src/state.rs`, `pplns.rs`) splits it by PPLNS and pays balances at or above `min_payout` in rounds of at most 16 transfers (`payout.rs:198`) from the pool's own key (`payout-key.json`, `payout.rs:20`) | Implemented (pool-0, pool v0) |
|
||||
| The open pool pays by a split in the block | fork `pool-tags-node` 7455b8d5: `evm.rs` `IGNH` (the share chain's parent) and `IGNW` (the window's split, `count x (address 20 bytes, weight 4 bytes)`), `split_producer`, the switch `pool_split_activation_daa` (never by default); `pool/src/sidechain.rs`, `open.rs`, `p2p.rs` | A share is a real template at the share chain's target with the member's own key and the member's own `IGNA`; a block pays the PPLNS window (2,160 shares) by `IGNW`, computed by every node from the block alone; nobody holds a balance or an operator key (pool.md 10.4) | Implemented on the branch, gated on Devnet 3 (pool.md 10.5: 100 members on 10 daemons, 90 of 90 honest members paid, 0 of 864 honest blocks paying a withheld address, drop proof by `verify-share`); the switch never set on any live chain |
|
||||
|
||||
### 1.3 Where the pool's identity is bound today, and the member's defences
|
||||
|
||||
| Item | Where | Status |
|
||||
|---|---|---|
|
||||
| The member names itself by its key | `pool/src/protocol.rs:42`, `authorize {pubkey, pop, label, payout, binding}`; spec 9.3, the `binding` over the TLS exporter (O-9.7, closed on `pool-finish-22` 10.2) | Implemented |
|
||||
| The member refuses a template that names another key | spec 9.4.1 item 1; `job_refused` code `vote_key` (`protocol.rs`, pool.md section 2 "Member checks") | Implemented on the member side of the fork (`igneum/miner/src/pool.rs`) |
|
||||
| The member refuses a coinbase that pays a third party or lacks its reveal | spec 9.4.1 item 2; codes `payout`, `reveal` | Implemented |
|
||||
| The pool holds no key | spec 9.6 item 1; pool.md section 5 "Votes": every member voted through its own node, the pool held no key, 3 keys under one payout address on chain | Implemented, Measured on the devnet |
|
||||
| Custodial mode exists and is disclosed | spec 9.6 item 5, `welcome.vote_mode` in {`member`, `pool`}; a client defaults to refusing `pool` | Designed; pool v0 does not offer it (pool.md section 3) |
|
||||
| The litepaper says the opposite | `site/litepaper.html:702`, Governance: "Pools can decline, and vote keys stay with the pool"; ledger G6 (conceded 5 October 2026), O-9.1 | A stated line about the custodial case, written before pool v0 ran with member keys. Under 2.0 it flips (5.2) |
|
||||
|
||||
So the first half of the pin is already true in code: the header commits the key, proof of work commits the header,
|
||||
and the only pool that exists names its members' keys. What 2.0 adds is the second half (payment aggregation separate
|
||||
and verifiable by any node, the pool unable to substitute without the member's share failing even against a member who
|
||||
does not check), and the public words.
|
||||
|
||||
## 2. The design
|
||||
|
||||
### 2.1 Where the commitment lives: the header, not the coinbase
|
||||
|
||||
The member's vote key is committed in the header field `vote_key_hash`, as today, and nowhere else is required. Why
|
||||
the header and not a coinbase commitment:
|
||||
|
||||
1. Proof of work is over the header. A share is a nonce whose lane hash under the job's prehash is at or below the
|
||||
share target (spec 9.8 item 1), and the prehash is the header hash with the key in it. A coinbase commitment is
|
||||
also under proof of work, through `hash_merkle_root`, but only transitively: to check it the member needs the
|
||||
coinbase bytes and a Merkle path (mode B) or the whole body (mode A), while the header field is one 32-byte
|
||||
comparison on bytes the member hashes anyway. The cheapest check is the one every client will run.
|
||||
2. Consensus already enforces the field (non-zero, revealed once, equal to the reveal's hash) and weight already reads
|
||||
it. A coinbase commitment would be a second place to keep in step with the first.
|
||||
3. The key is per block, the coinbase is per body. Mode C (declared templates) and the open pool build the body
|
||||
themselves; the header field is the same in every mode, so the binding does not depend on which side built the
|
||||
body.
|
||||
|
||||
The coinbase keeps what is not needed per share: the one-time reveal (`IGNK`), the finality section (`IGNF`, the votes
|
||||
carried), the payee (`IGNA`), and on the open pool the share chain's parent (`IGNH`) and the window's split (`IGNW`).
|
||||
|
||||
### 2.2 The share is the commitment, and the pool cannot forge it
|
||||
|
||||
A share the pool accepts is work on a prehash. The prehash commits to `vote_key_hash`. So for a pool to credit a
|
||||
member's work while naming any other key in the block, the pool would need a nonce that solves a prehash the member
|
||||
never hashed, which is a fresh block's worth of work per block. There is nothing to add here for a conforming member.
|
||||
|
||||
The gap is the non-conforming member: a client that hashes whatever prehash it is given. Against it the pool can put
|
||||
its own key in the header and the member's work becomes the pool's weight. The design closes this at the protocol's
|
||||
own level rather than the client's:
|
||||
|
||||
1. The share carries the key. The `share` message gains `vote_key_hash` (32 bytes hex) beside `job_id`, `nonce`,
|
||||
`hash`, and the pool MUST verify the share against a prehash whose header carries that key: the pool rebuilds the
|
||||
prehash from the template it issued, and a template whose `vote_key_hash` is not the member's authorised key is a
|
||||
template the pool cannot issue a job on at all (`job` MUST carry `vote_key_hash`, and a conforming member checks
|
||||
it before any other field). Designed. Cost: 32 bytes on a line that is under 120 bytes today, one comparison.
|
||||
2. The authorised key is the only key. A session is one key (`authorize`); every job and every share of the session
|
||||
names it; a pool that issues a job under another key to that session is non-conforming on its face, and the
|
||||
member's log says so with the code `vote_key`. Designed (spec 9.4.1 item 1 already; the job field makes it
|
||||
checkable without the template).
|
||||
3. On the open pool there is no pool to forge: every share is a full template the member built on its own node with
|
||||
its own key and its own `IGNA`; the daemons check structure, seeds and proof of work (`open.rs`), and a share whose
|
||||
header names a key other than the one that signed the daemon session is refused before it enters the chain.
|
||||
Implemented on `pool-finish-22`; the session-key check is to confirm there (5.3).
|
||||
|
||||
### 2.3 Payment aggregation stays at the pool, separate and verifiable
|
||||
|
||||
Two shapes, both kept:
|
||||
|
||||
- The operated pool (pool-0, pool v0): the chain pays the pool's `IGNA`; the pool pays members by PPLNS from its own
|
||||
balance. The key is the member's, the money is the pool's until it pays. This is the Bitcoin pool shape; it is
|
||||
allowed and it stays, because a member without a node can join nothing else (pool.md 10.5, "A pool user without a
|
||||
node cannot join the open pool"). What makes it verifiable: every block on chain carries `(vote_key_hash k, IGNA A)`,
|
||||
so any node computes, per pool address A, the set of keys that found its blocks and each key's count, which is
|
||||
exactly each member's share of the pool's income under PPLNS at the block level. A pool that underpays a member by
|
||||
blocks is caught from the chain; a pool that underpays by shares is caught only by the member's own share ledger
|
||||
(spec 9.2, "Underpay shares"), which is the operated pool's known limit and the open pool's reason to exist.
|
||||
- The open pool (no operator): the block's coinbase carries the window's split (`IGNW`) and the chain pays the split
|
||||
directly to every member's own `IGNA` from `pool_split_activation_daa` on. Aggregation is the split; verification is
|
||||
every node's execution of the block; no balance exists anywhere. Implemented and gated on Devnet 3; the switch is
|
||||
never set until the P2 mechanism or the override sets it, as the fee switch was.
|
||||
|
||||
In both shapes the vote key and the payee are different fields written by different parties on purpose: the key is
|
||||
the member's and only the member's client writes it into a template it will hash; the payee is the pool's (operated)
|
||||
or the member's own (open). A pool that writes a member's key into its own hashers' templates gains nothing (the
|
||||
member's weight rises, not the pool's) and loses the blocks' weight for itself, which is why no pool does it.
|
||||
|
||||
### 2.4 The share sidechain's role
|
||||
|
||||
The share sidechain (pool.md 10.4; spec 09 section 9.12 on the branch) is the open pool: shares at a 10-second target,
|
||||
one parent each, heaviest work wins, a PPLNS window of 2,160 shares, the window's split stamped into every coinbase so
|
||||
a found block pays the window. Its role for this pin is twofold:
|
||||
|
||||
1. It is the only shape in which payment is verifiable from the chain alone at the share level: a member can prove
|
||||
a dropped share with `verify-share` (pool.md 10.5, "Drop proof"), and a block's split is computed by every node.
|
||||
2. It removes the custodian of money as well as of keys, so a home miner's whole relationship with pooling is its own
|
||||
node, its own key, its own address and a gossip socket.
|
||||
|
||||
What it is not: it is not required for the vote-key pin. The operated pool satisfies the pin with 2.2 alone. The
|
||||
sidechain is the stronger payment story, with a measured cost: every block's coinbase grew by 48 bytes per window
|
||||
entry (pool.md 10.5, "The network"), the stale rate was 9.6 percent at 2 shares a second with ten daemons on one host,
|
||||
and a public chain across the internet loses more to forks (the uncles row, 10.6, open).
|
||||
|
||||
### 2.5 What Stratum V2 job declaration supplies and what it does not
|
||||
|
||||
Stratum V2's Job Declaration Protocol (Braiins and the Stratum V2 working group; the lineage line of spec 09) lets a
|
||||
hasher build its own block template, declare it to the pool, and be paid for shares on it, so the pool no longer
|
||||
chooses the transactions. That is Igneum's mode C (spec 9.4.2) and it is in pool v0 as a required mode.
|
||||
|
||||
It supplies: transaction choice, and with it the hasher's own coinbase extra data (so on Igneum a declared template
|
||||
carries the member's reveal and votes by construction).
|
||||
|
||||
It does not supply: an identity in the header. Stratum V2 has no header field for a voter, because Bitcoin has no
|
||||
voter; the pool still sets the payout and, in the standard mining protocol without declaration, the whole template.
|
||||
Job declaration is optional for pools, pools can decline (ledger G6 is right about that), and a pool that declines
|
||||
puts the hasher back on the pool's template. On Igneum that template still carries the hasher's key in the header
|
||||
(2.2), so declining job declaration changes transaction choice and nothing about votes. The sentence for the site is
|
||||
in 5.2.
|
||||
|
||||
## 3. The attack list, as the adversarial seat tried it
|
||||
|
||||
Each row: the attack, what stops it in the design above, how it shows, and whether it survives.
|
||||
|
||||
| # | Attack | What stops it | How it shows | Survives? |
|
||||
|---|---|---|---|---|
|
||||
| A1 | Pool substitution: the pool names its own key in the member's header | The prehash commits the key; a conforming member refuses the job (`vote_key`); under 2.2 the job and the share name the key and a share on another key is unverifiable against the issued template | Every block under the pool's `IGNA` names one key; the explorer's concentration page (ledger X14) shows one key with the pool's whole hashrate | No, against a conforming member. Yes, against a non-conforming client by the client's consent: see A5 |
|
||||
| A2 | Key reuse across members: one key authorised by many sessions, so one voter holds many members' weight | Authorisation needs the proof of possession and the TLS-exporter binding, so only the secret's holder opens a session; several sessions under one secret are one operator's rigs (spec 9.6 item 3), which is the design. A pool cannot reuse a member's key without the secret | Nothing wrong on chain: one key, its own blocks | Not an attack: weight follows the secret's holder, as intended |
|
||||
| A3 | Split identities: an operator or a pool spreads its hashrate over many keys | W6: keys are free and weight is blocks, so splitting moves no weight; dust (W3, 100 blocks in 30 days) silences the small keys. A pool splitting its own rigs over many keys to look like many members fools a member count, not a weight table | Many keys with the same `IGNA` and the same uptime pattern; cosmetic | Not an attack on votes; a presentation issue for pool pages |
|
||||
| A4 | Withheld votes: the pool drops a member's votes from relay and carriage | Three roads (spec 9.7 item 5): the member's own node, `submitFinalityVote` on any node, and the pool's template; a member with a verifier signs from it, not from the pool's `checkpoint`; a member without a verifier does not vote at all | The carriage ratio per key against the pool's blocks (9.7 item 6, O-9.6, threshold open) | Survives only for a member whose only road is the pool, which the specification forbids from voting in the first place |
|
||||
| A5 | Custody by terms of service: a pool whose terms require the member's key, or a modified client that accepts `vote_mode: pool` | Nothing at protocol level stops a holder giving a key away, and the specification allows the disclosed custodial mode (9.6 item 5). What the protocol does: the custodial pool's blocks all name the pool's key, so its vote share equals its hash share and both are public; the official client refuses the mode by default and shows it before the first share | One key under the pool's `IGNA` with the pool's whole hashrate; the ledger's "pools hold their hashers' votes" (spec 03 3.7 item 3) is then true of that pool | YES. This is the cheapest surviving attack: it costs the pool a terms line and a client fork, and it is bounded only by members' willingness and by the public reading of it |
|
||||
| A6 | Block withholding by the pool: the pool drops a block a member found | The member holds the full block (mode A or C) and submits it to its own node before or beside `solution` (spec 9.5) | The block is on the chain whatever the pool did | Survives for a member with no node (mode A, no verifier): that member loses the block's weight and the pool loses the income, so the pool has no motive beyond harming the member |
|
||||
| A7 | Pay-to-third-party: the pool's template names an `IGNA` that is not the pool's announced address | Spec 9.4.1 item 2, code `payout` | A refusal in the member's log | No |
|
||||
| A8 | Induced equivocation: the pool feeds two checkpoint hashes at one index | The member signs only a hash its own verifier reports (9.7 item 2); a verifier-less member does not sign | None | No |
|
||||
| A9 | Session replay: a captured `authorize` replayed to open a session under a member's key | The `binding` over the TLS exporter (O-9.7, closed on `pool-finish-22` 10.2); a replayed `authorize` on another connection is refused | A refusal in the pool's log | No, since TLS landed; the test that a replay is refused is the one to keep green (5.3) |
|
||||
| A10 | Share theft between members: a member submits another member's share under its own session | The share names the key and the pool verifies it against the job it issued to that session; a nonce on another member's prehash hashes to a different value (`verify.rs`, "the same nonce on another template hashes differently") | `wrong_hash` | No |
|
||||
| A11 | The pool mines its own rigs under a member's key to inflate that member | The pool gives weight away and keeps no income advantage; the member's `IGNA` is not paid (the pool's is), so the member sees blocks under its key paid to the pool, which is the normal case | Nothing abnormal | Not an attack: a gift of weight |
|
||||
| A12 | Open pool: a daemon stamps a share chain parent or a split that favours itself | Every member checks the coinbase before hashing (`IGNH` the chain's tip, `IGNW` the window the member computes itself); a block with a wrong split is a block the honest members never hashed | The withholder's branch on Devnet 3: 0 of 864 honest blocks paid it | No |
|
||||
|
||||
The cheapest one that survives is A5, custody by consent. It is not defeated by cryptography because it is not a
|
||||
forgery; it is defeated by defaults (the client refuses), by visibility (one key per pool address on the explorer's
|
||||
concentration page), and by the pool market (a non-custodial pool offers the same income). The design makes custody a
|
||||
public, deliberate choice rather than the silent default it is on every chain with pooled voting; that is the whole of
|
||||
what "at protocol level" can mean for a key its owner may give away.
|
||||
|
||||
A note on what was not found: no path by which a pool gains weight from a member's work without that member's client
|
||||
consenting, before or after 2.2. The weight table reads headers, headers are under proof of work, and proof of work is
|
||||
per key.
|
||||
|
||||
## 4. The cost on the honest side
|
||||
|
||||
| Cost | Value | Status |
|
||||
|---|---|---|
|
||||
| Header bytes | 0 new: `vote_key_hash` is 32 bytes in the header today | Implemented |
|
||||
| Coinbase bytes | the reveal 148 bytes once per key (`IGNK` 4 + 48 + 96); `IGNA` 24 bytes per block; the finality section per block as today; on the open pool `IGNH` 36 bytes and `IGNW` 4 + 24 per window entry (48 bytes per entry measured on Devnet 3 with the hex framing) | Implemented, Measured for the open pool |
|
||||
| Share and job bytes (2.2) | 32 bytes hex (64 characters) on each, so a `share` of under 120 bytes becomes under 190 and a `job` of under 300 under 370 | Designed |
|
||||
| Verification per block | one 32-byte comparison on every block (W1), one BLS proof-of-possession check on a reveal block (once per key); weight accounting as today | Implemented |
|
||||
| Verification per share, pool side | one 32-lane group evaluation, 0.441 ms per group on one M5 Max core (spec 9.8 item 5, Measured), 1.35 ms in isolation and 2.1 ms under load on a rented 2 vCPU box (pool.md section 5); 2.2 adds a comparison | Measured |
|
||||
| The home miner's bandwidth, operated pool | one `share` per 10 s and one `job` per template: under 0.1 kbit/s for shares; the template is the cost, a few KB to a few hundred KB per second at 1 block per second in mode A (spec 9.5 sizes, Designed, O-9.5); mode B carries the header, the coinbase and a Merkle path; mode C carries nothing down and one template up per declaration | Designed, unmeasured at a public pool |
|
||||
| The home miner's bandwidth, open pool | one share per 10 s per member gossiped to every daemon it peers with: at 100 members, 10 shares a second of about one template each; the Devnet 3 gate ran 100 members on 10 daemons on one host, so the internet cost is unmeasured (10.6) | Measured on one host only |
|
||||
| The accepted-work penalty of home internet against a datacentre link | the second bullet of the 2.0 Pools section; unmeasured here; it is the stale rate's dependence on round-trip time at `stale_grace_ms` 2,000 ms and two block times, to be measured with a member behind a home connection against one beside the pool | Open (6) |
|
||||
|
||||
## 5. Migration note for the pool lane's branches
|
||||
|
||||
### 5.1 Rebase first
|
||||
|
||||
The pool lane's branches on the box mirror against `release-2.0.0` (b891444f, the version bump, 16:29 BST today):
|
||||
|
||||
| Branch | Ahead | Behind | Carries |
|
||||
|---|---|---|---|
|
||||
| `pool-finish-22` | 1 | 112 | the open pool's Devnet 3 hour and the daemon's reconnect |
|
||||
| `pool-mf-row-2` | 3 | 102 | the pool page rows |
|
||||
| `pool-finish` | 11 | 572 | pool-0, TLS, the share sidechain |
|
||||
| `pool-v0-rebase` | 4 | 925 | the pool-mode miner on the fork (the three commits the shipper needed for 0.3.20) |
|
||||
| `pool-v0` | 3 | 1,691 | the original v0 |
|
||||
| `release-0.3.20` | 0 | 539 | the release line the pool lane landed on |
|
||||
|
||||
The fork side: `pool-tags-node` 7455b8d5 (`IGNH`, `IGNW`, `split_producer`, the switch) and `pool-finish-node`'s own
|
||||
commits (the binding in `finality.rs`, the params field, the executor's split, the miner's TLS and rung). Every one of
|
||||
these is rebased onto `release-2.0.0` (and the fork's 2.0 line) before any of this document's items is applied; the
|
||||
version bump is rule 15's and a branch that carries 0.3.x strings is not landed on 2.0.
|
||||
|
||||
### 5.2 The words
|
||||
|
||||
- `site/litepaper.html` Governance, "Pools can decline, and vote keys stay with the pool" becomes: "Pools can decline
|
||||
job declaration; the vote key stays with the miner in every mode, named in the header of every block its hardware
|
||||
finds, and a pool that asks for custody says so and is shown as one key." Closes O-9.1 and the G6 cross-reference in
|
||||
the ledger.
|
||||
- spec 03, 3.7 item 3 ("Pools hold their hashers' votes") is rewritten as the custodial case only, with 9.6 item 5
|
||||
named.
|
||||
- spec 09, 9.5: `job` and `share` gain `vote_key_hash`; 9.4.1 item 1 reads "the job's `vote_key_hash` and the
|
||||
template's are the member's own key"; 9.8 item 5 adds the comparison.
|
||||
|
||||
### 5.3 The code, in order
|
||||
|
||||
1. The pool daemon: `vote_key_hash` on `job` and `share` (`pool/src/protocol.rs`), checked in `verify::check`
|
||||
against the job's template; the member side (`igneum/miner/src/pool.rs`) checks the job field before the template.
|
||||
A test with a known-pass (the member's key) and a known-fail (another key on the job) per the standing rule.
|
||||
2. The open pool: confirm the daemon refuses a share whose header key is not the session's key (`open.rs`), with the
|
||||
same pair of tests.
|
||||
3. Retire `placeholder_vote_key_hash` from the fork's template path once no caller remains (today the miner writes the
|
||||
real hash over it); keep the consensus rule that a zero hash is a `MissingVoteKeyHash`.
|
||||
4. The explorer's concentration page: keys per pool `IGNA` and the share of blocks each holds, so A5 is visible as the
|
||||
design intends (ledger X14).
|
||||
5. The replay test of A9 and the drop proof of A12 stay in the pool crate's tests on 2.0.
|
||||
|
||||
## 6. Open questions, named for main
|
||||
|
||||
| Id | Question | What closes it |
|
||||
|---|---|---|
|
||||
| Q1 | Does the share carry `vote_key_hash` (2.2 item 1, 64 more characters per share) or does the job alone (item 2) suffice, the share being bound through `job_id`? The share form lets a pool's log stand on its own as evidence; the job form is cheaper | A decision; the adversarial seat's preference is the share, for the evidence |
|
||||
| Q2 | The accepted-work penalty for home internet against a datacentre connection (the 2.0 bullet) has no measurement: the stale rate at 2,000 ms grace against round-trip time | One rented member behind a home-class link (a residential proxy or a PC on home broadband) against one beside the pool, an hour each, the stale rates per member |
|
||||
| Q3 | Mode A template bandwidth per member at 1 block per second on a public pool (O-9.5) | The same hour's bytes on the member's socket |
|
||||
| Q4 | The open pool across the internet: stale rate and uncles (pool.md 10.6) | The Devnet 3 gate re-run with daemons on three regions |
|
||||
| Q5 | Whether the custodial mode (`vote_mode: pool`) remains allowed at all under 2.0, or is removed from the specification so that A5 requires a non-conforming pool as well as a non-conforming client | A ruling; removing it does not stop A5 (a fork of the pool is as cheap as a fork of the client) but changes who is non-conforming |
|
||||
| Q6 | Minimum payouts on the operated pool: `min_payout_ign` is the pool's parameter; the 2.0 bullet asks for "practical" ones, which needs the fee-per-transfer at 2.0's fee level | The execution lane's fee number, then a floor in `share_scheme` |
|
||||
28
docs/design/proving-payment.md
Normal file
28
docs/design/proving-payment.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# The proving payment: the tip-share discrepancy resolved (Igneum 2.0, D4)
|
||||
|
||||
8 October 2026, 17:5x UK, the research lane, under the founder's Igneum 2.0 decision (`docs/plans/igneum-2.0.md`, D4: "the economics page's tip-share discrepancy resolved; explicit user-funded proving payment with congestion pricing, burn treated separately; hard cap and no development tax kept"). A design decision on text and spec; the one code change it implies is pinned below and is not made here.
|
||||
|
||||
## The discrepancy
|
||||
|
||||
The economics page's fee table says the priority fee (the tip) is 80 percent to the block's miner and 20 percent to the developer registrations, which is what the code on Devnet 3 does (`DEVELOPER_SHARE_PERCENT = 20`, the rest to the miner). Spec 5.2 says the 80 percent goes to "the block producer and provers ... in the proportion the proving protocol defines (forward reference)", spec 5.3 speaks of "the provers' part of the 80% tip share", open item O-5.7 leaves that proportion to phase 2, and the page's own "proving-fee market" paragraph says a card's second income includes "the provers' part of the priority fee". So the page contradicts itself and the spec contradicts the code: the provers are promised a share of the tip that no rule sizes and no code pays.
|
||||
|
||||
## The decision
|
||||
|
||||
1. **The tip stays whole to the block.** The priority fee splits 80 percent to the block producer and 20 percent to the developer registrations, exactly as the code does. No part of the tip reaches the provers. O-5.7 is closed by this decision: the provers' proportion of the tip is zero.
|
||||
2. **The provers are paid by an explicit, user-funded proving payment with congestion pricing: the proving base fee.** Every transaction already pays `pgas used x f_p`, where `f_p` is the proving base fee that spec 5.1 adjusts per chain block by the EIP-1559 step toward a target of half the proving budget (`B_p / 2`), never below the floor of 5.11. Today that payment is burned. From this decision it is the provers' payment: **90 percent of it is credited to the block's proving pool escrow (`PROVING_POOL_ADDRESS`) and paid out per shard by consensus proving cost under the rules of 5.3; 10 percent is burned.** The price is the congestion price by construction: `f_p` rises when blocks use more than half the proving budget and falls when they use less, so a proving demand spike raises what users pay provers per unit of proving work, which is the signal that brings capacity in (the operator simulation of D4 reads it as its pricing rule).
|
||||
3. **The burn is treated separately and listed in one place.** Burned: the execution base fee in full (anti-stuffing, unchanged), 10 percent of the proving payment, the unregistered developer share of the tip, and 10 percent of IGN-settled external jobs (5.4). Nothing else.
|
||||
4. **The hard cap and the absence of a development tax are untouched.** No new emission, no change to the 20 percent proving-pool share of the subsidy (2.5), no address that any team controls.
|
||||
|
||||
Why the 10 percent burn on the proving payment: the burn of the proving base fee was the rule that made wash pgas a guaranteed loss (5.1). With 90 percent of it routed to the block's provers, a miner who is also a prover of its own block could recover part of a stuffed block's proving payment; sortition (eight eligible provers per shard, 5.3) makes that recovery a share of the pool's weight at best, and the 10 percent burn plus the execution base fee burned in full keep stuffing a loss at every weight. The 90 and 10 mirror the external job split of 5.4, so a prover's two incomes carry one rule.
|
||||
|
||||
## What a prover earns, in one line
|
||||
|
||||
Per block: its shards' part of 20 percent of the block subsidy (2.5, 5.3) plus its shards' part of 90 percent of the block's proving payment (`pgas used x f_p`); per job: 90 percent of the external job fee (5.4). Nothing from the tip.
|
||||
|
||||
## The code pin (not made here)
|
||||
|
||||
`igneum/exec/src/executor.rs` (the proving base fee debit at 357 and 360 on Devnet 3): credit 90 percent of `pgas used x f_p` to `PROVING_POOL_ADDRESS` and debit the remaining 10 percent to no one, instead of debiting the whole to no one; the pool's per-shard payout (5.3) then carries it with no further change. Behind an activation constant on the devnet objects like `proving_v1_activation_daa`. Until it lands the economics page says "designed, not in the code" on that row, as it does for the external job split.
|
||||
|
||||
## Where the text changes
|
||||
|
||||
Spec 5.1 (the proving-cost gas row and the burn sentence), 5.2 (the 80 percent recipient and the forward reference), 5.3 (the provers' income), 06 O-5.7 (closed); the economics page's "Where fees go" table (the base-fee row split into the two dimensions, a proving-payment row, the duplicated tip row removed) and its "proving-fee market" paragraph.
|
||||
|
|
@ -1800,7 +1800,7 @@ Evidence: `docs/bench-log.md`, 4 October 2026 "difficulty rule: timestamp attack
|
|||
### P21. The SP1 proof is not what consensus checks in proving v0
|
||||
"Your proof records pay provers, and the node pays a record whose statement matches its own execution whether or not the SP1 proof behind it verifies. A prover can sign the native statement without proving anything."
|
||||
|
||||
Status: Decided (6 October 2026, 17:25 UTC, by the owner; decisions item 11): proving v0 (every producer verifies off the consensus path) through the public testnet; the in-consensus verifier is the execution engineer's plan item for after it; the litepaper sentence labelled Open stands. Was: Open, stated in spec 7.7 item 4 (4 October 2026). Branch `proving` of the fork, `igneum/exec/src/proving.rs`. Sweep (5 October 2026): stated; the in-consensus verifier is a build item (execution engineer).
|
||||
Status: Fixed in the node behind a named switch (8 October 2026, the enforced-proving lane, branches `enforced-proving` and `enforced-proving-node` on the 0.3.25 node line; `docs/spec/proving-enforcement.md`), after an external review the founder accepted at 16:1x UK: a valid SP1 proof is a condition of payment in consensus. Two rules on one floor, `Params::proof_rule_active_from()`: the body rule of 0.3.16 (a carried record whose proof is not held, does not verify or names another program id invalidates its block) and a new payment rule in the executor (`carried_payouts`, `carried_segment_payouts`: from the floor a record pays only when this node's verifier holds a VERIFIED verdict for its proof; otherwise it is carried unpaid and nothing is marked paid). The switch `verifier_in_consensus` (`OverrideParams` and `Params`, in the digest once set) is false on every compiled object: the live Devnet 3 object stays at never (unchanged by the rollout), igneum-testnet-1 keeps its own floor of 0 (the body rule has held there from block zero; the payment rule joins it on the same floor), the class v6 object carries it true from its block zero. Tests, named per refusal, known-failed first (spec section 3): the consensus side `proving_enforcement_tests::enforced_*` (no proof, a wrong proof, another program id, the honest acceptance, the finding with the switch off, the floor as a boundary) and the executor side `proving::tests::enforced_*` (no payment without a verified proof and the honest record paid once, a replayed record, a wrong network id, an altered payout address, a duplicate of a paid record, the v0 shape below the floor, the verdict source); the executor set green on build-2 at 16:08 UK (7 of 7), the consensus set and the crate suites in the spec's section 6. Cost: spec section 4 and 6. Fast-time case across the boundary: `infra/fast-time/proving-enforcement.mjs`. Was: Decided (6 October 2026, 17:25 UTC, by the owner; decisions item 11): proving v0 (every producer verifies off the consensus path) through the public testnet; the in-consensus verifier is the execution engineer's plan item for after it; the litepaper sentence labelled Open stands. Was: Open, stated in spec 7.7 item 4 (4 October 2026). Branch `proving` of the fork, `igneum/exec/src/proving.rs`. Sweep (5 October 2026): stated; the in-consensus verifier is a build item (execution engineer).
|
||||
Decision owner: the founder, decided 6 October 2026 (`docs/plans/ledger-decisions.md`, outcomes section).
|
||||
|
||||
Answer: Correct for v0, and by design for now. The consensus check on a carried record is the native-execution veto (spec 7.2 item 5): the statement must equal the node's own 328-byte shard statement for that shard and payout address, so no record can move state or pay for a wrong claim. The SP1 proof is verified off the consensus path by the proof pool's verifier (`igneum-prove-host --mode verify`), and a producer offers only verified records to its templates; a producer that includes unverified records (trust mode, test networks only) can pay a prover who did not prove. The damage is bounded to that prover's payout. What closes it: the aggregated segment record of design 5.4 verified in consensus, which needs the SP1 verifier inside the node (the SDK dependency the node does not carry today) or a bounded in-consensus verification budget. Until then the devnet runs v0 with every producer verifying.
|
||||
|
|
@ -1810,7 +1810,7 @@ Round 2 (5 October 2026, night): the public text now carries the v0 fact, labell
|
|||
### P22. The rewards and payouts are inputs to the shard proof, not outputs
|
||||
"The shard guest takes the segment's rewards and the prover payouts as data and commits the post-root after them. A host can feed any list and the proof still verifies."
|
||||
|
||||
Status: Open, blocked on the phase 2 consensus proof (design 7: the aggregator derives the rewards and payouts from consensus data it verifies, so they become outputs of the proof): next step that consensus-proof work, in phase 2 (Nov 2026 to Jan 2027 per the litepaper roadmap), no earlier date. Was: Open, stated in spec 7.7 item 6 (4 October 2026). Sweep (5 October 2026): stated; nothing runnable.
|
||||
Status: Open, staged (8 October 2026, the enforced-proving lane; `docs/spec/proving-enforcement.md` section 7 carries the staging table): the closure is four explicit stages, each a claim about one input with the native check that holds it until the next stage, never a self-contained proof of the whole state. Stage 0 (today): rewards and payouts are data in the shard statement and every node's native derivation vetoes a statement that differs; enforced proving adds that the record's proof must verify for that statement (ledger P21). Stage 1: the rewards list checked against a commitment the aggregator carries in its public values, the commitment itself recomputed natively from the mergeset. Stage 2: the payouts derived inside the aggregator guest from the carried records it verifies, `carried_payouts` the native check of the same derivation. Stage 3: the rewards derived inside the aggregator guest from the headers and blue sets it verifies, the consensus proof proper; only here do rewards stop being an input anywhere. Stages 1 to 3 are the phase 2 consensus-proof work (Nov 2026 to Jan 2027 per the litepaper roadmap); no served text says "the rewards and payouts are proven" before stage 3. The 2.0 plan's negative test (6) (incorrect rewards or consensus inputs: derivation authenticated) is PENDING in that sense: the executor test `enforced_a_statement_over_altered_rewards_or_payouts_is_vetoed_native_derivation_is_the_check` holds the native veto (every node's own derivation), and the proof-side closure is stage 3. Boundary sentence for every served text: a proof of execution is not a proof of authenticated consensus inputs, canonical history or data availability. Was: Open, blocked on the phase 2 consensus proof (design 7: the aggregator derives the rewards and payouts from consensus data it verifies, so they become outputs of the proof): next step that consensus-proof work, in phase 2 (Nov 2026 to Jan 2027 per the litepaper roadmap), no earlier date. Was: Open, stated in spec 7.7 item 6 (4 October 2026). Sweep (5 October 2026): stated; nothing runnable.
|
||||
|
||||
Answer: Correct, and already true of the rewards since devnet v4 (`BlockFixture.rewards`, `proving_pool_credit`): the shard statement is "from this pre-root, these transactions, these rewards and payouts, the post-root is X". The node checks the statement against its own execution, which used the rewards and payouts consensus derived, so a proof over a different list does not match any node's statement and pays nothing. Closing it in the proof itself means the aggregator deriving the rewards and payouts from consensus data it verifies (the mergeset's blue blocks and the carried records), which is the consensus-proof work of design section 7.
|
||||
|
||||
|
|
@ -2275,7 +2275,7 @@ Status: Fixed, stated (7 October 2026, night): every mention of the month is gon
|
|||
|
||||
Answer: The date was the plan of 3 October 2026 and the chain overtook it: the testnet genesis was fixed on 5 October, the three seeds and rpc.testnet.igneum.network are up, and the remaining work is the go checklist. Rows that quoted the month (X3, O-X.2's blocker note, overclaim item 75's replacement text) read the new sentence by reference to this row.
|
||||
|
||||
Evidence: `docs/plans/testnet-go.md`; `docs/igneum-testnet` notes (genesis 87617621..., seeds seed1 to seed3.testnet.igneum.network, public RPC). Checked by `tools/ci/ledger-text-check.mjs` (the X3 and X31 rows).
|
||||
Evidence: `docs/plans/testnet-go.md`; `docs/igneum-testnet` notes (genesis 01294fd3... since the re-cut of 7 October 2026, 87617621... before it; seeds seed1 to seed3.testnet.igneum.network, public RPC). Checked by `tools/ci/ledger-text-check.mjs` (the X3 and X31 rows).
|
||||
|
||||
### X32. The roadmap carried calendar months beside a testnet that is weeks away
|
||||
"After X31 the roadmap read phase 4 'Apr to Jul 2027' and phase 6 'Nov 2027' with phase 5 'weeks away' between them, and phases 1 to 3 carried 'Oct to Nov 2026', 'Nov 2026 to Jan 2027' and '20 nodes by Mar 2027'. A reader spots the contradiction at once."
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Generated by `tools/ledger/export-public.mjs` from `docs/fud-ledger.md`; a gate check fails when the two drift. One row per item: the claim or criticism, its status, what was done, and the evidence. Internal identifiers, times of day and team-member names are left out on purpose; the full ledger is published with the repository.
|
||||
|
||||
194 items. By status: Conceded, stated 52; Fixed 31; Decided 22; Fixed on a branch, pending merge 14; Answered by design 9; Answered with evidence 6; Fixed, stated 4; Closed by rule 3; Open 3; Spec fixed 2; Fixed, stated; restated 2; Answered by design, with a correction to our own text 1; Answered with evidence, stated 1; Answered by design for finality, Conceded for the lottery 1; Conceded, implemented, stated 1; Rule implemented and measured; launch month simulated 1; Answered by design, with the concession stated 1; Conceded, stated in the litepaper and the design doc 1; Answered with evidence at 1 block/s 1; Conceded, stated in the simulation report 1; Answered by design, with the dependency conceded. Update 7… 1; Conceded, stated in the litepaper, with the dial explained 1; Closed by spec 1; Conceded by decision, stated in the design doc 1; Answered by design, with the founder's edge conceded 1; Answered by design, with a metrics caveat 1; Closed by removal, 3 October 2026 1; Conceded, stated in the litepaper 1; Conceded, stated in the design doc 1; Measured on the live node line, and the overlay does NOT… 1; Conceded, stated in the simulation 1; Conceded in part, labelled, stated 1; Fixed in the node 1; Answered with evidence for the largest body the rules allow 1; Fixed in the proving code 1; Fixed in the spec 1; Rule fixed 1; Rule written 1; Fixed, logged 1; Answered with evidence for the test half 1; Fixed in the node and shipped, rule not yet activated on… 1; Simulation half run 1; Answered with evidence for all four 1; Written 1; Designed 1; Fixed and confirmed 1; Rolled out 1; Conceded by decision 1; Conceded, scheduled, stated 1; Conceded, contained by rule, stated 1; Fixed on a branch and verified locally 1; Answered with evidence and stated 1; Answered with evidence for PC 2 1; Answered by design and with evidence 1; Fixed in part, finding bounded, stated 1; Open, priced 1; Fixed as a genesis lever, measurement owed 1.
|
||||
194 items. By status: Conceded, stated 52; Fixed 31; Decided 21; Fixed on a branch, pending merge 14; Answered by design 9; Answered with evidence 6; Fixed, stated 4; Closed by rule 3; Open 3; Spec fixed 2; Fixed, stated; restated 2; Answered by design, with a correction to our own text 1; Answered with evidence, stated 1; Answered by design for finality, Conceded for the lottery 1; Conceded, implemented, stated 1; Rule implemented and measured; launch month simulated 1; Answered by design, with the concession stated 1; Conceded, stated in the litepaper and the design doc 1; Answered with evidence at 1 block/s 1; Conceded, stated in the simulation report 1; Answered by design, with the dependency conceded. Update 7… 1; Conceded, stated in the litepaper, with the dial explained 1; Closed by spec 1; Conceded by decision, stated in the design doc 1; Answered by design, with the founder's edge conceded 1; Answered by design, with a metrics caveat 1; Closed by removal, 3 October 2026 1; Conceded, stated in the litepaper 1; Conceded, stated in the design doc 1; Measured on the live node line, and the overlay does NOT… 1; Conceded, stated in the simulation 1; Conceded in part, labelled, stated 1; Fixed in the node 1; Answered with evidence for the largest body the rules allow 1; Fixed in the proving code 1; Fixed in the spec 1; Rule fixed 1; Rule written 1; Fixed, logged 1; Answered with evidence for the test half 1; Fixed in the node and shipped, rule not yet activated on… 1; Simulation half run 1; Answered with evidence for all four 1; Written 1; Designed 1; Fixed and confirmed 1; Fixed in the node behind a named switch 1; Rolled out 1; Conceded by decision 1; Conceded, scheduled, stated 1; Conceded, contained by rule, stated 1; Fixed on a branch and verified locally 1; Answered with evidence and stated 1; Answered with evidence for PC 2 1; Answered by design and with evidence 1; Fixed in part, finding bounded, stated 1; Open, priced 1; Fixed as a genesis lever, measurement owed 1.
|
||||
|
||||
| Id | Claim or criticism | Status | What was done | Evidence |
|
||||
|---|---|---|---|---|
|
||||
|
|
@ -143,7 +143,7 @@ Generated by `tools/ledger/export-public.mjs` from `docs/fud-ledger.md`; a gate
|
|||
| P19 | An over-budget proving transaction runs for free, every time, and blocks its sender | Fixed | Correct, medium. | [docs/bench-log.md](../docs/bench-log.md) |
|
||||
| P20 | The SP1 GPU client panics on shutdown and the compressed stage waited ten minutes | Fixed and confirmed | The buffered save closed the gap, the core proof finished at and the compressed stage started at; shard timings repeated within 0.3 s (core 9.1 s, compressed 10.5 s); a guest that returned 0 bytes on the second run was… | [docs/bench-log.md](../docs/bench-log.md) |
|
||||
| M23 | Forge timestamps inside the rules and the controller mines you a 10x difficulty for free | Fixed | Correct on every point, and measured first by our own attack run (`sim/difficulty/attacks/README.md`, scenarios 3 and 7): in the simulator a 50% forger took the block rate to 0.12 (earliest stamp) and 0.56 (latest) of… | [docs/bench-log.md](../docs/bench-log.md) |
|
||||
| P21 | The SP1 proof is not what consensus checks in proving v0 | Decided | Proving v0 (every producer verifies off the consensus path) through the public testnet; the in-consensus verifier is the execution engineer's plan item for after it; the litepaper sentence labelled Open stands. | none named |
|
||||
| P21 | The SP1 proof is not what consensus checks in proving v0 | Fixed in the node behind a named switch | , after an external review the founder accepted at: a valid SP1 proof is a condition of payment in consensus. | none named |
|
||||
| P22 | The rewards and payouts are inputs to the shard proof, not outputs | Answered by design | The design rule that contains it is in force, spec 7.7 item 6 and design 5.5: the rewards and payouts a shard statement carries are checked against every node's own consensus derivation, so a proof over any other list… | none named |
|
||||
| M24 | Your two-lane controller oscillates for an hour when a second miner joins mid-epoch | Rolled out | Rule v2 activated on the live devnet at DAA 33,000 by the height switch after a 12-node cloud rehearsal (settle 157 to 272 s, no swing); node 1, the seed, the observer and the three app machines crossed the height on… | [sim/difficulty/records/live-2026-10-04.csv](../sim/difficulty/records/live-2026-10-04.csv) |
|
||||
| D1 | Your users are a gate, not a fact | Conceded, stated | Correct on the count and on the definition. | [docs/bench-log.md](../docs/bench-log.md) |
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
BIN
docs/plans/igneum-2.0-plan.pdf
Normal file
BIN
docs/plans/igneum-2.0-plan.pdf
Normal file
Binary file not shown.
2292
docs/plans/igneum-2.0-plan.txt
Normal file
2292
docs/plans/igneum-2.0-plan.txt
Normal file
File diff suppressed because it is too large
Load diff
132
docs/plans/igneum-2.0-reference.txt
Normal file
132
docs/plans/igneum-2.0-reference.txt
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
INGEUM 2.0
Yes—if the programme delivers the independently verified results we defined, my judgement is that Igneum would belong in the top tier of GPU-first mining projects, with a credible case for technical leadership.
|
||||
That is different from automatically becoming the largest, most profitable or most trusted GPU network. You can engineer a leading system; adoption and sustained demand still have to follow.
|
||||
The distinction is between shipping the proposed changes and demonstrating that they achieve the objective. The latter is what would earn that position.
|
||||
Where successful execution would put you
|
||||
Area
|
||||
My assessment of the proposed end-state
|
||||
Keeping ordinary GPUs competitive
|
||||
A potential leadership position, provided independent hardware analysis confirms the result against programmable, multi-epoch competitors.
|
||||
Quality of the resistance evidence
|
||||
A major strength if you publish reproducible measurements, realistic adversarial designs and economics that do not assume chips automatically expire.
|
||||
Miner experience
|
||||
Potentially top-tier: straightforward installation, effective tuning, transparent earnings, reliable payouts and retained control.
|
||||
Usefulness of the hardware
|
||||
A compelling proposition if the same accessible hardware can earn from genuinely purchased proving work as well as securing the chain.
|
||||
Security and decentralisation
|
||||
A credible contender after independent review and failure testing—not something a feature list establishes.
|
||||
Miner profitability and market leadership
|
||||
Still dependent on demand, competition, liquidity and operating costs. These cannot be guaranteed by protocol design.
|
||||
The strongest potential advantage is the complete package—not one supposedly unbeatable hash.
|
||||
You would be competing with serious projects
|
||||
The space is not empty, and I would not claim Igneum had surpassed these projects merely because its design is newer.
|
||||
Ravencoin already expresses a closely related objective. Its KAWPOW approach is intended to keep consumer GPUs competitive, explicitly allows for future ASIC development, and does not envisage further algorithm forks as the normal defence. Igneum would need to demonstrate a stronger result or a more valuable overall offering—not just restate that goal.
|
||||
Ergo already has a GPU-oriented, memory-hard mining design, with implemented changes to pooling, emissions and difficulty adjustment. That gives you an operating system to benchmark against, rather than merely a competing whitepaper.
|
||||
Firo is actively improving commodity mining accessibility. Its September 2026 reference-miner release supports NVIDIA and AMD, charges no developer fee, and reports performance within roughly 1% of popular closed-source alternatives. That performance figure is Firo’s own report, but it illustrates the practical standard Ember would need to meet.
|
||||
Those are useful reference points, not an exhaustive league table. They also show why “GPU-friendly” is the entry requirement, not sufficient differentiation.
|
||||
What could make Igneum stand out
|
||||
Your proposed combination is more interesting:
|
||||
Competitive commodity mining + application execution + verifiable proving + an accessible operator application + meaningful miner control.
|
||||
The litepaper already frames mining, proving and Ethereum-compatible execution as separate but connected parts of the system. The opportunity is to make that combination work reliably and economically—not simply to have more features than another chain.
|
||||
For a miner, the strongest proposition would be:
|
||||
“My hardware remains useful and competitive, I can earn from more than the mining subsidy, and I do not have to surrender control to participate.”
|
||||
For a customer buying proofs:
|
||||
“I receive a correct result at a competitive price and reliable delivery time, without needing to care which independent operator produced it.”
|
||||
Deliver both, and Igneum would have a strong reason to exist beyond attracting miners from another coin.
|
||||
However, the proving-income distinction matters enormously. The current economics page describes internal proving payouts on a valueless devnet and says the external proving market is not yet built. Internal reward distribution demonstrates the mechanism; repeat purchases by outside customers would demonstrate demand.
|
||||
What separates “top-tier technology” from “the leading network”?
|
||||
I would look for three outcomes.
|
||||
Miners stay through difficult conditions. Not because the launch is temporarily lucrative, but because hardware compatibility, operating costs, payouts and control remain attractive.
|
||||
Customers repeatedly pay for the output. Not subsidised pilots or partnership announcements: useful work purchased at prices that support reliable service and operator margins.
|
||||
The network works without the founding team holding it together. Independent operators build the software, enforce the rules, survive failures and continue operating without emergency intervention.
|
||||
Those would be my tests for leadership. A small specialist-hardware advantage alone would not establish any of them.
|
||||
My direct answer
|
||||
The ceiling is category leadership. Successful, independently validated execution would put Igneum in serious contention for the top of the GPU-mining space on engineering and operator proposition.
|
||||
But I would not give it an unsupported “number one” ranking before comparative results and real-world adoption exist.
|
||||
The progression I would expect is:
|
||||
Demonstrated technical advantage → credible top-tier project → repeat customer demand and miner retention → potential category leader.
|
||||
You have a plausible route to the top. The decisive move is now to prove that ordinary operators can succeed on the system—not to make the design sound more ambitious.
|
||||
Yes—EVM-compatible applications with ZK-proven execution are the right default for Igneum. But I would position and build it as a GPU-secured L1 with a flexible proving layer, not simply “another zkEVM chain.”
|
||||
The architecture I would choose is:
|
||||
EVM for developers. A general-purpose zkVM for proving. Igneum’s own consensus for security. External customers for additional proving demand.
|
||||
Those choices fit together, but they are not the same decision.
|
||||
1. Separate the three architectural choices
|
||||
Decision
|
||||
My recommendation for Igneum
|
||||
Why
|
||||
What developers build against
|
||||
EVM-compatible execution
|
||||
Let builders use familiar contracts, languages and tools rather than requiring them to learn a new application platform.
|
||||
How execution is proved
|
||||
An established general-purpose zkVM, initially your SP1-based implementation
|
||||
Prove the EVM implementation without developing an entirely new proof system yourselves. Preserve a carefully versioned replacement path.
|
||||
Where the network obtains consensus and finality
|
||||
Your sovereign GPU-mined L1, subject to the security gates we discussed
|
||||
This preserves your actual objective: an independent network secured by accessible hardware, rather than a proving service attached to someone else’s settlement system.
|
||||
Your litepaper already points broadly in this direction: it identifies revm for EVM execution and SP1 behind a versioned proving interface. I would refine that architecture rather than restart it.
|
||||
A zkVM and a zkEVM are not competing choices here. SP1 proves programs compiled for RISC-V; one such program can implement EVM execution. Succinct’s RSP project demonstrates this composition using Reth and SP1, although that repository explicitly warns that it is not audited or production-ready.
|
||||
2. Why EVM is a sensible application layer
|
||||
I would not make attracting developers harder while you are already solving difficult mining, consensus and proving problems.
|
||||
EVM compatibility lets developers reuse familiar languages and infrastructure. Ethereum’s documentation identifies precisely that benefit: applications can use established tooling while gaining proof-based verification.
|
||||
For Igneum, my preferred developer experience would be:
|
||||
“Deploy familiar contracts, understand a small, clearly documented set of differences, and obtain verifiable execution.”
|
||||
That is a stronger starting point than asking developers to adopt a new language, wallet model, execution environment and security model simultaneously.
|
||||
However, compatibility needs to be demonstrated, not described as “everything runs unchanged.” Your ledger already acknowledges differences in block context, randomness and two-dimensional fees. Those can matter to application behaviour even where the bytecode executes successfully.
|
||||
I would therefore make compatibility testing a product deliverable: representative contracts, wallet fee estimation, indexing, failed transactions, receipts and application-specific assumptions.
|
||||
3. ZK-proven execution is also aligned with where the technology is going
|
||||
This is not a case of choosing an architecture whose only purpose is Ethereum rollups.
|
||||
The Ethereum Foundation’s current zkEVM programme is working towards proof-based verification of Ethereum’s own L1 execution, beginning with optional execution proofs and aiming later for mandatory proofs. Its approach explicitly involves general-purpose zkVMs.
|
||||
That supports your architectural direction:
|
||||
Keep a familiar application environment, while changing how execution is verified.
|
||||
It does not establish that Igneum’s implementation is secure or that customers will choose it. It does mean you can build on a substantial shared engineering direction rather than invent every component.
|
||||
My recommendation is to benefit from that work while concentrating your own effort on what is distinctive: accessible operators, distributed proving, reliable payments and the GPU-mined base layer.
|
||||
4. I would not turn Igneum into an Ethereum L2 by default
|
||||
Using EVM execution and ZK proofs does not require moving Igneum “onto Ethereum.”
|
||||
A conventional Ethereum ZK-rollup uses Ethereum to enforce state updates and make the necessary state-reconstruction data available. That is a different security and settlement arrangement from operating a sovereign L1.
|
||||
An L2 could be the better choice for a project whose primary objective was Ethereum settlement and an Ethereum-facing application. But it would not automatically be a better implementation of your objective: an independent, durable home for GPU operators.
|
||||
There is a real cost to choosing sovereignty: you must establish your own consensus security, data availability and credible cross-chain verification. Adding execution proofs does not make those responsibilities disappear.
|
||||
My preferred commercial relationship is:
|
||||
Serve Ethereum and other networks without requiring Igneum to become subordinate to one of them.
|
||||
Customers should be able to purchase supported proofs for their existing systems. Requiring every customer to migrate its application to Igneum would unnecessarily narrow the business.
|
||||
5. The proving business should be broader than your own zkEVM
|
||||
This is the most important strategic refinement.
|
||||
Make EVM the main application interface, but do not make EVM execution the only useful work your proving infrastructure can eventually support.
|
||||
A general-purpose zkVM gives you a potential route to additional verifiable workloads. It does not make every proof format interchangeable: each supported service still needs its own validated program, inputs, verification rules, performance measurements and delivery requirements. SP1’s general-purpose execution model supports that broader direction.
|
||||
I would start narrowly:
|
||||
First: reliably prove Igneum’s own execution.
|
||||
Next: support one external customer’s exact workload, with repeat paid jobs.
|
||||
Then: add further workloads where the existing operator fleet has a demonstrated advantage.
|
||||
Your economics page still describes the external proving market as unbuilt. That is an opportunity to shape correctly—not established demand that should already be included in revenue assumptions.
|
||||
Igneum should not need to win a contest for the largest application ecosystem before its operators can sell useful computation.
|
||||
6. The conditions that make this the right choice
|
||||
I would keep this architecture only while enforcing four requirements.
|
||||
Proofs must become a protocol guarantee
|
||||
Your ledger currently states that proof verification occurs outside the consensus path in proving v0; a modified producer can include a matching statement without the valid proof and cause an undeserved payout.
|
||||
Closing that gap is essential. Otherwise, the network is demonstrating proving activity rather than enforcing a permissionless proving economy.
|
||||
The hardware requirements must match the miner promise
|
||||
The litepaper currently distinguishes NVIDIA proving from AMD and Apple mining support. That is an important limitation to preserve in the product language.
|
||||
I would judge the proving stack on the complete pipeline: inputs, proving, aggregation, verification, payment, memory footprint and mining income forgone.
|
||||
A fast shard result is not sufficient when aggregation or memory pressure makes ordinary operators uncompetitive.
|
||||
Proof-system flexibility must not become arbitrary acceptance
|
||||
Keep the implementation replaceable, but pin the permitted program identities, verifier versions and security parameters in the protocol.
|
||||
My recommendation is one well-tested backend first, a clean replacement interface, and independent implementations where justified later—not several immature backends accepted interchangeably.
|
||||
The Ethereum Foundation’s security work emphasises that proving speed is insufficient without soundness analysis and appropriate security margins. That is directly relevant to deciding which optimisations and versions Igneum can safely adopt.
|
||||
Proving must not become the authority to control the chain
|
||||
I would preserve your separation between winning the mining lottery and producing proofs.
|
||||
But also test whether a concentrated proving provider could stall useful operation by withholding service. The design needs replacement operators, usable inputs, reassignment and explicit behaviour during proof delays.
|
||||
Separate responsibilities on paper are not enough; they must remain separable during a failure.
|
||||
7. What “zkEVM” should—and should not—mean in your positioning
|
||||
I would not lead with:
|
||||
“We are another zkEVM.”
|
||||
I would lead with:
|
||||
“A GPU-secured network for Ethereum-compatible applications and verifiable computation.”
|
||||
Then explain the proof architecture underneath it.
|
||||
Also keep three boundaries explicit:
|
||||
Proven execution is not automatically finality.
|
||||
EVM compatibility is not Ethereum security.
|
||||
ZK technology does not automatically make transactions private. Ethereum’s rollup documentation explicitly notes that published state-reconstruction data is public; privacy requires additional application or protocol design.
|
||||
And none of these choices, by itself, solves specialised-hardware competition. Your mining-resistance programme remains a separate obligation.
|
||||
My judgement
|
||||
Yes: stay with EVM-compatible, ZK-provable execution. I would not switch to a new application VM, develop a bespoke proof system, or convert Igneum into an Ethereum rollup just to follow a category label.
|
||||
The strongest architecture for your stated purpose is:
|
||||
A sovereign GPU-mined L1, familiar EVM applications, an established and versioned zkVM proving engine, and a proving service capable of serving customers beyond Igneum.
|
||||
EVM is the right front door. Verifiable computation is the broader opportunity. Keeping ordinary operators competitive is the differentiator you still have to prove.
|
||||
202
docs/plans/igneum-2.0.md
Normal file
202
docs/plans/igneum-2.0.md
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
# Igneum 2.0
|
||||
|
||||
Decided 8 October 2026, 17:45 BST. The testnet is held (go cut acaf08b0 never ran; the three seeds idle for D5). Devnet 3 is the network. The miner line restarts at v2.0.0. Everything below is a pin: a box, an owner, a pass condition. A pin closes only with a landed document and the pass condition met, never with a plan.
|
||||
|
||||
## The objective
|
||||
|
||||
Durable GPU competitiveness, not chip destruction. Four properties, all holding without assuming a future emergency algorithm change:
|
||||
|
||||
1. A specialised miner cannot remove much cost without losing substantial performance.
|
||||
2. Ordinary operators can obtain competitive hardware, software and access to rewards.
|
||||
3. Mining and proving stay economically sustainable as the network grows and issuance falls.
|
||||
4. The above hold with no rescue upgrade assumed.
|
||||
|
||||
A manufacturer with a profitable product is not the failure. An exclusive, durable advantage large enough to displace the accessible GPU fleet is. The target is contestable mining.
|
||||
|
||||
Positioning line (served text, every page): "A GPU-secured network for Ethereum-compatible applications and verifiable computation." Never "another zkEVM". Three boundaries stated wherever the proof architecture is explained: proven execution is not finality; EVM compatibility is not Ethereum security; ZK is not privacy.
|
||||
|
||||
## Standing rules carried in (do not re-decide)
|
||||
|
||||
- [ ] More layers is not more resistance. A smaller generator whose every accepted program is strong beats a larger one with occasional weak programs.
|
||||
- [ ] Rejected knobs (long programs, select trees, W=8/W=32, SM count, memory clock) stay out and stay as regression controls. Never resurrected under new names.
|
||||
- [ ] The adversary is re-optimised after every change. Synthesis k is never a lower bound; placed rows score.
|
||||
- [ ] GPU-cost budget is set before results (10 percent at the lock). No ratio is bought with honest GPU energy.
|
||||
- [ ] "Every chip dies within a family epoch" is out of the baseline economic model. Chip-arrival percentages are not published.
|
||||
- [ ] No ASIC detection in consensus. No hardware whitelists, attestation, per-address quotas or self-reported GPU bonuses.
|
||||
- [ ] Rotation is optional to the security argument. Seed delay is evaluated only as seed-selection protection.
|
||||
- [ ] Energy advantage, economic advantage and response capability are reported separately. Cohort = the discrete-GPU population, used cards included.
|
||||
- [ ] Mining resistance, proving competitiveness and system stability are three questions with three answers.
|
||||
|
||||
## D1. A frozen, reproducible baseline
|
||||
|
||||
Owner: coordinator (Counter ASIC 3.0) with the hash lane and the node lane.
|
||||
|
||||
- [ ] One exact generator, verifier, dataset policy, compiler configuration and measurement harness, pinned by digest and served.
|
||||
- [ ] Pending measurements closed: placed 64-register rows, PC 1 lock row, 5.5 GiB coexistence rows.
|
||||
- [x] Mixed FP32 branch: KILL 8 Oct 16:3x. Deterministic FP32 costs the cards 15 to 26 percent energy per hash against a 10 percent budget (four fifths of it the integer masking that keeps the FP unit deterministic) and the chip's edge grows to 3.0x to 3.2x because that masking is ARX work it pays at the floor. Document: docs/analysis/class-v6/mixed-fp32.md. Regression control, never resurrected.
|
||||
- [ ] Mining and proving measured together on the final configuration, not combined on paper.
|
||||
- [ ] Wall power alongside device telemetry; accepted work, rejected work, compile time, memory use, sustained thermals.
|
||||
- [ ] Reference GPU population published: several vendors, memory sizes, generations, used cards (5090, 5080, 4090, 3090, 9070 XT, RX 7600 8 GB, Arc, Apple).
|
||||
- [ ] Two tests per class: existing owner (power, wear, fees, alternative use) and new entrant (purchase, operating, resale).
|
||||
- [ ] Central measure served: cost per accepted unit of work = (annualised hardware + power + hosting, failures, fees) / annual accepted work.
|
||||
- Pass: an independent operator reproduces the baseline within declared tolerances from the served kit alone.
|
||||
|
||||
## D2. Two architectural experiments, not twenty knobs
|
||||
|
||||
Owner: class v6 invention lane (a) and the multi-family adversary lane (b).
|
||||
|
||||
- [x] (a) Reorganise existing work for unavoidable live state and resource coupling, counts held constant. RESULT 8 Oct 17:25: KILL as a class. Only the window width reaches the chip (+1.2 pJ per lane-op at N5); rearranging the dependency graph of the same ops moves neither side. Document: docs/analysis/class-v6/connected-state.md. The generator variant and liveness tool stay behind a flag.
|
||||
- [ ] (b) Attack memory sharing, recomputation and data-local execution against v6 (ProgPoW review threat: dataset split across processors, compute moved to the data). Price the cheapest combination of moving state, moving data, recomputing and local resources, not the expected architecture.
|
||||
- [ ] Cumulative memory complexity and bandwidth hardness mapped onto the actual evaluation across many hashes (shared datasets, partial caches, recomputation, multiple engines amortising setup). Which trade-offs are bounded, which rest on physical-design experiments.
|
||||
- [ ] Selective participation: distribution of the specialist's advantage across programs and epochs, not the mean; downtime, difficulty adjustment, re-entry included.
|
||||
- [ ] Cryptographic review of the template, nonce, expensive work and result binding: no expensive intermediate reused across cheap winning attempts.
|
||||
- Pass: the candidate improves against re-optimised adversaries across the declared population within the preset cost and verification limits. Otherwise v6 stands and the experiment is published as a failure.
|
||||
|
||||
## D3. A programmable adversary allowed to survive
|
||||
|
||||
Owner: multi-family adversary lane with the k lane (shadow k from RTL).
|
||||
|
||||
- [ ] Whole-system cost minimised across every published family, free to change lane count, register implementation, instruction storage, memory technology, scheduling and support hardware.
|
||||
- [ ] Physical implementation (placed), not logic synthesis. Uncertainty published with every row.
|
||||
- [ ] Three-year stress life for the programmable chip; survival across the family bank assumed.
|
||||
- [ ] Next-generation opponents in the matrix: programmable compute without graphics (Vortex class), chiplets and 3D packaging (UCIe), denser external memory (24 Gb GDDR7 boards), data-local hybrids, proof accelerators (PipeZK class), an operator combining mining and proving devices.
|
||||
- [ ] Dataset schedule (5.5 / 8.5 / 11.5 GiB) scored per step: adversary burden against commodity burden (cards excluded, mine-and-prove lost, replacement cost). A step that hurts ordinary operators more than an adaptable adversary is rejected. Default: epoch-defined bounded dataset with a conservative published support horizon.
|
||||
- [ ] State coupling of the dataset justified or dropped after sync, recovery, storage and adversarial state-growth costs.
|
||||
- Pass: the best supported cost and energy advantage sits inside the chosen competitiveness envelope, with uncertainty published. 1.5x energy is a research goal, not the pass criterion.
|
||||
|
||||
## D4. A five-year coexistence model
|
||||
|
||||
Owner: research lane (economics), with the k lane's rows as input.
|
||||
|
||||
- [ ] Replaces the capex wall. Mandatory stress case: development already paid for; the opponent covers manufacturing, deployment and operation only.
|
||||
- [ ] Growing and shrinking networks, reduced issuance, cheap and dear electricity, GPU replacement and resale on both sides, changing proving demand, private mining and hardware sales, several productive lifetimes, cheaper derivative chips.
|
||||
- [ ] Miners react: no fixed market shares.
|
||||
- [ ] Outputs: cost advantage, replacement economics, accessible supply, break-even electricity price per class, supplier and operator dependence.
|
||||
- [ ] Tariff advantage shown separately from hardware advantage (the 6.25x illustration).
|
||||
- [ ] Economics page: the tip-share discrepancy resolved; explicit user-funded proving payment with congestion pricing, burn treated separately; hard cap and no development tax kept.
|
||||
- [ ] Profit-maximising operator simulation: mine, internal prove, external prove, off; under a proving demand spike, a token price fall, a major prover leaving, a specialised entrant in either market. Pass if pricing and capacity rules restore service without an administrator.
|
||||
- Pass: the model names credible conditions for sustained commodity participation and names where it fails. A result needing a small network, token appreciation or scheduled ASIC death has not passed.
|
||||
|
||||
## D5. A no-rescue network exercise
|
||||
|
||||
Owner: node lane with the build-server lane (the three ex-testnet seeds are the start).
|
||||
|
||||
- [ ] Prerequisite: proof verification enforced in consensus (verifier_in_consensus, proof_rule_active_from) live on the exercise network.
|
||||
- [ ] No founder-operated mining, proving, aggregation or mandatory distribution infrastructure.
|
||||
- [ ] Cross epoch boundaries, interrupt signing, partition the network, remove major operators, hostile proof submissions, independently written clients.
|
||||
- [ ] A withholding concentrated prover: replacement operators, usable inputs, reassignment, explicit behaviour during proof delays.
|
||||
- Pass: specified behaviour with no emergency algorithm change and no privileged intervention.
|
||||
|
||||
## Pools, software and participation (launch requirements)
|
||||
|
||||
Owner: pool lane.
|
||||
|
||||
- [ ] Vote keys stay with the miner at protocol level: the member's retained voting key committed into its work, payment aggregation separate, verifiable, pool identity substitution resisted.
|
||||
- [ ] Non-custodial payouts, practical minimum payouts, local work verification, low-bandwidth participation (P2Pool as precedent, not code).
|
||||
- [ ] Accepted-work penalty measured for home internet against datacentre connections.
|
||||
- [ ] Optimisation work, compiler settings and safe tuning logic published; Ember reaches good operating points without third-party software.
|
||||
|
||||
## Architecture and product (review 2)
|
||||
|
||||
Owner: node lane (protocol), reference-apps lane (compatibility), second-prover lane (backend policy), site lane (positioning).
|
||||
|
||||
- [ ] EVM-compatible execution for developers (revm); SP1 as the one well-tested proving backend behind the versioned interface; sovereign GPU-mined consensus. Not an Ethereum L2.
|
||||
- [ ] Program identities, verifier versions and security parameters pinned in the protocol. One backend first; a second only where justified, never interchangeable immature backends.
|
||||
- [ ] Compatibility as a product deliverable: representative contracts, wallet fee estimation, indexing, failed transactions, receipts, application assumptions; the documented set of differences (block context, randomness, two-dimensional fees).
|
||||
- [ ] Hardware language kept honest: NVIDIA proving against AMD and Apple mining; the full pipeline judged (inputs, proving, aggregation, verification, payment, memory, mining income forgone).
|
||||
- [ ] Proving business ladder: own execution; one external customer's exact workload with repeat paid jobs; further workloads only where the fleet has a demonstrated edge. The external market stays out of revenue assumptions until built.
|
||||
- [ ] Every served page carries the positioning line and the three boundaries; "zkEVM" appears only under the architecture explanation.
|
||||
|
||||
## Versioning
|
||||
|
||||
- [ ] Miner v2.0.0 cut from the 0.3.26 line with the audit's window rebuild, the 2.0 served text and the baseline kit digest. Three-part versions, canary gate, one-box rollout, commit-string read-back, as before.
|
||||
- [ ] Node 2.0.0 follows once D5's prerequisite (enforced proving) is on Devnet 3.
|
||||
|
||||
## Leadership tests (review 3)
|
||||
|
||||
Owner: main. These are the tests the site may claim progress against, never completion without the evidence.
|
||||
|
||||
- [ ] Benchmarks published against operating systems, not whitepapers: Ravencoin KAWPOW (same stated objective), Ergo (GPU memory-hard, live pooling and emission changes), Firo's reference miner (NVIDIA and AMD, no developer fee, within about 1 percent of closed miners: the bar Ember meets or beats).
|
||||
- [ ] Miners stay through hard conditions (compatibility, operating cost, payouts, control), measured, not launched.
|
||||
- [ ] Customers repeatedly pay for proofs at prices that carry reliable service and operator margin. Pilots and announcements do not count.
|
||||
- [ ] The network runs without the founding team: independent clients, rule enforcement, failures survived, no emergency intervention (D5).
|
||||
- [ ] Served ranking language: "serious contention for the top of the GPU-mining space on engineering and operator proposition" is the ceiling; no "number one" claim before comparative results and adoption exist.
|
||||
|
||||
## Site reset
|
||||
|
||||
Owner: site lane.
|
||||
|
||||
- [ ] The served site starts at Igneum 2.0: no 0.x release history, no changelog before 2.0, no Counter ASIC 2.0 / 3.0 / 4.0 names, no testnet pages or links, no old-version evidence rows, no status-log history.
|
||||
- [ ] The pre-reset site is tagged site-pre-2.0 on the box mirror; the repo keeps everything, the site serves none of it.
|
||||
- [ ] Devnet 3, the explorer, the faucet and the reference apps stay: they are the network, not history.
|
||||
- [ ] Every page carries the positioning line; the miner page serves v2.0.0 when it is cut and nothing older.
|
||||
- [ ] The facts page is wiped: it restarts with only 2.0 facts, each traceable to a landed document.
|
||||
- [ ] The FUD ledger is written fresh against this brief: every entry names the pin it answers; the old ledger and its decisions stay in the repo as history and are not served or linked.
|
||||
- [ ] Copy sweep beyond the site: the litepaper and the apps (miner, wallet, Windows host, HiveOS card) are edited to the 2.0 reference where the text differs from it (positioning line, three boundaries, EVM + SP1 + sovereign consensus stated as three decisions, NVIDIA proving against AMD and Apple mining stated plainly, no rotation-as-defence claims, no chip percentages, no 0.x history, no testnet). Dataset state-coupling in the litepaper is marked "under evaluation (D3)" until justified or dropped.
|
||||
|
||||
## The four deliverables the site may frame itself around
|
||||
|
||||
| What we deliver | Why it matters |
|
||||
|---|---|
|
||||
| GPUs remain economically competitive against realistic specialised hardware | Miners invest without depending on emergency algorithm changes |
|
||||
| A straightforward, efficient miner with reliable payouts and retained operator control | Ordinary owners participate successfully, not only mining businesses |
|
||||
| Useful proofs that outside customers repeatedly purchase | Demand for a service, not enthusiasm for the coin |
|
||||
| Secure execution, finality and independently operated infrastructure | Developers and customers trust the network with meaningful activity |
|
||||
|
||||
Each row is served only with its evidence beside it. What stops number one: losing on customer acquisition, developer adoption, operator economics or reliability; and GPU competitiveness is a contested claim (Ravencoin states it already), so the site shows a better result, never a more ambitious description.
|
||||
|
||||
## Addendum from the complete plan (IGNEUM_2.0_Plan_Light.pdf, 8 October 2026, 37 pages)
|
||||
|
||||
The PDF is the complete reference and supersedes the .rtf where they differ; both are kept beside this file (igneum-2.0-plan.pdf, igneum-2.0-plan.txt). These pins were missing above.
|
||||
|
||||
### Evidence classes and status discipline (plan p. 5, 25, 27)
|
||||
- [ ] Every served claim carries one label: TEAM-REPORTED, MODELLED, PROPOSED, PENDING or EXCLUDED. Status words are distinct and never collapsed: designed, implemented, activated, team-tested, independently reproduced, independently reviewed.
|
||||
- [ ] Public wording ladder, each rung with what supports it: "designed for GPU competitiveness" (objective and rationale only); "team-tested" (published procedure, version, result); "independently reproduced" (an unaffiliated operator); "independently reviewed" (a scoped review with its unresolved findings); "top-tier contender" (comparative evidence plus operating and customer proposition); "category leader" (sustained adoption, demand, reliability, retention; never a numerical rank).
|
||||
- [ ] Claims never published: guaranteed chip death, a universal ASIC-efficiency ceiling, chip-arrival probabilities without a calibrated model, guaranteed profits, Ethereum security by compatibility, privacy from ZK.
|
||||
- [ ] Release evidence packet beside every material claim: source commit, exact parameters, test procedure, raw result, independent-reproduction status, review scope, unresolved limitations.
|
||||
|
||||
### Decision register additions (p. 7)
|
||||
- [ ] Also excluded, kept as controls: SM gating, wider reads, sealed classes, random epoch lengths, per-tier scoring, VRF draws. The long-program result stays rejected when instruction memory is modelled as shared SRAM, not flip-flops.
|
||||
- [ ] The one acceptance rule for any hash change: it passes only when the best adversarial implementation becomes worse relative to the best practical GPU implementation, within pre-agreed cost and verification limits.
|
||||
- [ ] Accepted work includes rejected submissions, downtime, epoch preparation, compilation, host power and network delays.
|
||||
|
||||
### Rotation mechanisms (p. 11)
|
||||
- [ ] Miner-voted bring-forward is specified as a mechanism: activation rule, coalition behaviour, partition handling, old-client behaviour. "No veto" is a mechanism, not a label.
|
||||
- [ ] Seed and VDF pipeline: the seed-selection property it protects is stated, and behaviour when finality is unavailable at a seed boundary is defined and tested.
|
||||
|
||||
### Independent hardware brief (p. 12)
|
||||
- [ ] The adversary's transition matrix, each row a required result: firmware update (throughput, energy, cost before and after each family transition); emulation (penalty for missing native operations; slower is not unprofitable); memory expansion or overprovisioning (board cost, bandwidth, power, survival through the dataset schedule); companion CPU, GPU or FPGA (complete-system economics including hybrid mining and proving); favourable-period mining (revenue after idle, re-entry, difficulty response); silicon revision (incremental cost, reused design work, realistic delay).
|
||||
- [ ] Evidence standard: realistic SRAM macros, ports, wiring, memory interfaces, switching activity, complete-board overhead; same-node and advanced-node results kept separate; uncertainty ranges published.
|
||||
- [ ] The lifetime rule: a capable programmable design survives several epochs; a retirement benefit is awarded only when the cheapest adaptation loses economic competitiveness.
|
||||
- [ ] The disclosure prize rewards a better adversary and a reproduced shortcut, not confirmatory results.
|
||||
|
||||
### Coexistence economics additions (p. 13)
|
||||
- [ ] Scenario axes include market structure: private supply, public hardware sales, multiple suppliers, concentration.
|
||||
- [ ] The USD 340 M and USD 23 M thresholds and the USD 20 to 75 M development range live in a sensitivity workbook with their assumptions, never as served boundaries.
|
||||
|
||||
### Miner and operator experience (p. 14)
|
||||
- [ ] Desktop-first, a mining console. Easy entry: install, hardware detection, compatibility explanation, safe default tuning. Honest economics: mining, internal proving and external proving income shown separately, net of the operator's entered electricity tariff. Control: power limits, pause and stop, job selection, explicit acceptance of software updates. Failure visibility: rejected work, failed proofs, queue delays, memory pressure, missed payments visible and actionable. Accessible payouts: non-custodial, practical for small operators. Safe execution: proving isolated from wallet secrets and signing authority; security assumptions published.
|
||||
- [ ] Hardware profiling lives in the client, never as consensus privilege.
|
||||
|
||||
### Proving correctness (p. 16)
|
||||
- [ ] The negative test set every validator passes: correct statement with an invalid proof (rejected, no reward); wrong program or verifier identity; wrong chain, epoch or statement binding (replay); changed payout identity; duplicate proof reward; incorrect rewards or consensus inputs (derivation authenticated, not only execution). Each mapped to a test in the enforced-proving set with its version.
|
||||
- [ ] Boundary served: a proof of execution is not a proof of authenticated consensus inputs, canonical history or data availability.
|
||||
|
||||
### Consensus and failure behaviour (p. 19)
|
||||
- [ ] D5 scenario rows: prolonged partition (no conflicting final histories inside the fault assumptions; liveness loss explicit); authority-set transition (verifiable continuity from the last certified history; a timeout alone is not evidence missing voters are gone); signing stops while mining continues (defined checkpoint, weight and recovery; no contradictory certificates); old voting keys compromised (its own analysis, distinct from new hashrate); finality unavailable at a seed boundary (a seed and mining path that does not depend on an unavailable certificate); partitions reconnect (deterministic recovery, no quiet reversal of an irreversible label).
|
||||
- [ ] The real integration tested together: ordering, finality, proof queues, voter tables, seed transitions.
|
||||
- [ ] User-facing rule everywhere: included, executed, proven and finalised are four states; a safe pause is shown as a pause.
|
||||
|
||||
### User verification (p. 20)
|
||||
- [ ] Light wallet: starting point, voter weights and authority changes authenticated. Execution proof: the permitted proof verified and its inputs authenticated, never an aggregator statement in its place. Payment receipt: proves the transfer with asset, recipient and amount, or is labelled "transaction-inclusion receipt". Oracle: deployer-installed trust anchors removed or disclosed, unchecked signatures named. Data availability: how state is obtained and reconstructed, explained.
|
||||
|
||||
### Operational independence (p. 21)
|
||||
- [ ] One machine-readable release manifest: network identity, source commits, mining class, dataset parameters, finality rule, program and verifier identities, activation state, fee schedule. Status pages generate from it; historical records are labelled.
|
||||
- [ ] Software release: reproducible builds, pinned source and binaries, explicit operator acceptance, a signing-key incident procedure. A fleet that auto-accepts a release key is operationally centralised.
|
||||
- [ ] Public infrastructure, mining, proving and aggregation continue without founder services and without unpublished files or hidden configuration.
|
||||
|
||||
### Programme order, mainnet prerequisites, funding (p. 24, 26)
|
||||
- [ ] Spend order: proof enforcement and finality boundaries first while closing the v6 evidence packet; hardware research and one narrow paid pilot in parallel; broad ecosystem expansion waits until the core path is secure, reproducible and useful.
|
||||
- [ ] Mainnet needs: complete proof enforcement, a resolved finality and recovery model, operator-control tests, the compatible-application test suite, a funded maintenance plan (engineering, audits, infrastructure, incident response), committed funding distinguished from adoption-dependent income; fair launch is a principle, not a funding strategy.
|
||||
- [ ] Acceptance scorecard served as a checklist with its "do not substitute" column (plan p. 25), never as a completion dashboard.
|
||||
- [ ] Brand kit stands: Unbounded, IBM Plex Sans, IBM Plex Mono; obsidian, ember, graphite, bone, molten.
|
||||
|
|
@ -11,7 +11,7 @@ What the pipeline proves today, from the code:
|
|||
- Proof stages: execute, core, compressed; the aggregator folds shard proofs by recursion into one block proof and chains it to the previous segment's (`proving/igneum-prove/core/src/agg.rs`; `--mode chain`, `aggregate`, `verify-segment` in the host). The on-chain wrap (Groth16 or Plonk over bn254) is a trait method that returns an error and is not run (`proving/igneum-prove/host/src/proof_system.rs`).
|
||||
- Verification: SP1's light verifier with the pinned verifying key (`--mode verify`); the node re-executes every carried record natively and drops a record whose result differs (litepaper, "How a block gets proven").
|
||||
- The job loop is the chain's own: every 10 s the app asks its node for shards assigned to its vote keys (`igneum_getAssignedShards`), exports, cuts, proves `--mode compressed`, signs and submits (`igneum_submitProofRecord`) (`app/igneum-app/src/prover.rs`). No job enters from outside.
|
||||
- Hardware, measured: NVIDIA only (SP1's CUDA prover is Linux x86_64; Windows runs it in WSL2). The fixed 4,717,439-cycle shard (`proving/fixtures/fees-v1-shards2.json`, shard 0) proves compressed on the patched server at threshold 2^26 in 13.2 s on an RTX 3060 12 GB (7,525 MiB peak) and 8.2 s on an RTX 4060 8 GB (7,532 MiB), 6.3 s on an RTX 4090 and a 5090 (8.0 GB) (`docs/analysis/prover-tiers-real-cards.md`, 6 October; the 8 and 12 GB rows re-measured 8 October, v6-coexist). At the 5.5 GiB dataset floor an 8 GB or 12 GB card cannot hold the miner and the prover at once (13.6 GB together) and time-shares them; 24 GB and 32 GB cards hold both.
|
||||
- Hardware, measured: NVIDIA only; the tier that serves the pilot is 16 GB and up (section 3) (SP1's CUDA prover is Linux x86_64; Windows runs it in WSL2). The fixed 4,717,439-cycle shard (`proving/fixtures/fees-v1-shards2.json`, shard 0) proves compressed on the patched server at threshold 2^26 in 13.2 s on an RTX 3060 12 GB (7,525 MiB peak) and 8.2 s on an RTX 4060 8 GB (7,532 MiB), 6.3 s on an RTX 4090 and a 5090 (8.0 GB) (`docs/analysis/prover-tiers-real-cards.md`, 6 October; the 8 and 12 GB rows re-measured 8 October, v6-coexist). At the 5.5 GiB dataset floor an 8 GB or 12 GB card cannot hold the miner and the prover at once (13.6 GB together) and time-shares them; 24 GB and 32 GB cards hold both.
|
||||
- Proof delivery on the devnet today: shards assigned by sortition to eight provers for a 10 s exclusive window, then open to anyone; no bond, no deadline beyond the record window of 600 chain blocks (`docs/spec/07-execution.md` 7.2, 7.7).
|
||||
|
||||
The profile that fits that edge, stated as constraints rather than a market claim:
|
||||
|
|
@ -48,10 +48,10 @@ One workload, one program id, one price, one clock. Everything below is the prop
|
|||
| Inputs | The customer supplies the SP1 stdin blob per job (for a range proof: the L1 head, the L2 block range and the witness their own tooling produces); Igneum does not reconstruct inputs for a foreign chain. Inputs are content-addressed (sha256 in the job) so a re-run is reproducible. |
|
||||
| Output | A compressed SP1 proof of that program over those inputs, plus the public values, returned to the address and endpoint the customer names. The customer's own pipeline aggregates and wraps for L1 as it does today; a wrap by Igneum is a phase-two item (section 4). |
|
||||
| Verification rule | The customer verifies every proof with SP1's verifier against the pinned vk before it is counted; a proof that fails verification is not a delivered job and is not paid. Igneum keeps the same check on its side before sending (`--mode verify` generalised to the customer's vk). |
|
||||
| Delivery time | 30 minutes from job receipt to proof returned, measured on the customer's clock. Reasoning: a range of OP Stack blocks is a few shards of our measured size (seconds each on one card) plus queueing across independent operators; minutes is the honest unit for a fleet, and 30 minutes leaves room for a reassignment after one failed card. |
|
||||
| Delivery time | 30 minutes from job receipt to proof returned, measured on the customer's clock, served by the 16 GB and larger tiers only. Restated against the day's measured pipeline (`docs/analysis/proving-pipeline-2026-10-08.md`, Devnet 3, 8 October, every paid shard): inputs 2.4 s median; proving 27 s median and 216 s at the slowest 5 percent; aggregation 23 s median (75 s on a 3090, memory-bound); verification and submission under 2 s; claim to submitted end to end 75 s median, 230 s at the slowest 5 percent, 302 s at the slowest 1 percent, 497 s at the maximum. Payment landed 171 s after submission at the median and 543 s at the slowest 5 percent, but payment is the customer's step and sits outside the delivery clock. The 30-minute bound is 3.6x the slowest segment observed, 6x the slowest 1 percent and 24x the median: the margin is there to absorb one failed attempt and a reassignment, not because a proof takes that long. The 12 GB tier is excluded by measurement: 313 claims, 0 paid, 6,765 card-seconds wasted (a 4090 claimant finishes the same segment first), and at the 5.5 GiB floor 8 GB and 12 GB cards cannot hold the miner beside the prover (`docs/analysis/class-v6/coexist-rows.md`). |
|
||||
| Price per job | Cost-based, quoted per billion cycles of the program's execution, with a floor per job. From the measured rows: a 4060 proves 4.7 M cycles in 8.2 s at 115 W, so one billion cycles is about 29 minutes of card time, about 0.06 kWh (USD 0.01 at USD 0.15 per kWh) plus about USD 0.005 of card amortisation (USD 300 over three years); a 5090 does the same in about 22 minutes at 330 W (USD 0.018 of power, USD 0.035 of amortisation). Pilot quote: USD 0.25 per billion cycles, minimum USD 2 per job, so the operator's margin is several times its cost on every card tier and the quote sits well under the USD 1.50 per billion PGU budgeting figure the SP1 community uses. Priced in dollars, paid on the customer's chain (route 4), never a fixed number after the pilot: the launch rule prices a job at or above the subsidy the card forgoes, which moves with network hash. |
|
||||
| Repeat cadence | One job per hour for rank 1 (a range proof an hour is a small slice of a chain's stream and leaves their existing supplier in place); one job per update for rank 2 (about one per sync period). |
|
||||
| Pass condition | 500 paid jobs over 4 weeks, at least 99 percent delivered inside 30 minutes and every one verified by the customer, paid at the quoted rate with no job disputed. Reasoning: four weeks crosses more than 600 hourly program epochs and the operators' own churn, so a pass is not one good week; 500 jobs resolve the on-time rate to a fifth of a percent and are enough for the per-card cost table to be read back against real power bills; "paid" means money moved on the customer's chain for every counted job, so a subsidised or waived job does not count. Fewer than 500 paid jobs, or any week under 99 percent, is a fail, published either way. |
|
||||
| Pass condition | 500 paid jobs over 4 weeks, at least 99 percent delivered inside 30 minutes and every one verified by the customer, paid at the quoted rate with no job disputed. Reasoning: four weeks crosses more than 600 hourly program epochs and the operators' own churn, so a pass is not one good week; 500 jobs resolve the on-time rate to a fifth of a percent and are enough for the per-card cost table to be read back against real power bills; "paid" means money moved on the customer's chain for every counted job, so a subsidised or waived job does not count. Fewer than 500 paid jobs, or any week under 99 percent, is a fail, published either way. Against the measured day the time bound is not the risk: every segment that reached submission did so inside 497 s. The risk is completion: 30.8 percent of claims failed or were refused on 8 October (900 chain failures, 629 of them the sm_89 floor-link class since fixed; 153 refusals), and with that class out 13 percent of claims were still lost to steals (4.0 percent of claims paid to a faster claimant) and late chains (54 "carried after the segment's deadline"), with no retry in the prover (it drops the export and claims afresh). A pipeline that loses 13 percent of first attempts and never retries delivers about 87 percent on time; 99 percent needs the two fixes in section 4 (a claim honoured for its window, a tier that claims only what it can finish) plus one retry on a second operator inside the 30 minutes, which the measured times allow four times over. The pass condition therefore gates on those fixes being live before the first counted job. |
|
||||
|
||||
## 4. What the pipeline is missing to serve it
|
||||
|
||||
|
|
@ -64,6 +64,8 @@ Checklist from the code, each item with the file or crate it touches. Nothing be
|
|||
- [ ] Aggregation and wrap. The aggregator guest is Igneum's segment statement (`proving/igneum-prove/core/src/agg.rs`, `aggregator/src/main.rs`); `wrap` is unimplemented (`proof_system.rs`). For the pilot the customer aggregates and wraps; phase two is a generic aggregation program and the bn254 wrap, so Igneum can deliver an on-chain-verifiable proof.
|
||||
- [ ] Payment. Route 4 (customer chain, payout contract keyed by miner address) is "Designed" with no code (`docs/spec/05-fees-and-economics.md` 5.4; `docs/commercial/prover-customer-brief.md` route 4); `pool/src/payout.rs` pays IGN inside Igneum's pool only. Needed: the payout contract on the customer's chain, the job-to-payout record, and a receipt the app shows; settlement in IGN waits for the proof bridge (spec 7.3) and is out of the pilot.
|
||||
- [ ] SLA. The chain has a 10 s exclusive window and open claiming with no bond and no job deadline (`docs/spec/07-execution.md` 7.2, items 3 and 4); the brief's bond and timeout are open (O-5.6). Needed for a customer: a deadline on the job, a reassignment rule when the first operator misses it, a retry budget, and a delivered-on-time log that produces the pass condition's numbers (the app's `/api/state` tile and the node's `igneum_getProvingStatus` are where the counters live today).
|
||||
- [ ] A claim honoured for its window. Measured 8 October: 137 segments (4.0 percent of claims) that one box had claimed were paid to another key; the 10 DAA-second exclusive window (`docs/spec/07-execution.md` 7.2, item 3) does not hold a slow claimant's segment for it, and 35 refusals read "segment already paid". Fix: the node's proof pool refuses a record for a segment inside another assignee's granted window until the window lapses, and the window is the claim's, not the assignment's (the node repository, the proof pool and `igneum_getAssignedShards`; the app's pick in `app/igneum-app/src/prover.rs` and the fleet agent `tools/fleet/box-prover.py` read the window back before they start). For an external job the same rule is the SLA's first half: the operator that took the job keeps it for the window, and reassignment happens only when the window lapses.
|
||||
- [ ] A tier claims only what it can finish. Measured 8 October: the RTX 3060 12 GB tier claimed 313 segments and completed none (6,765 card-seconds wasted; 4 OutOfMemory failures), because its chain time exceeds the window a 4090 claimant closes first, and at the 5.5 GiB floor it cannot hold the miner beside the prover. Fix: the claimant gates on `memory.total` and its own measured chain time before claiming (the pick logic in `app/igneum-app/src/prover.rs`, the claim loop in `tools/fleet/box-prover.py`, the tier profile in `tools/fleet/box-prover.sh` where THRESHOLD and MINER are already chosen from the card's memory): 12 GB and under mine at the floor and claim nothing; 16 GB and up prove. For the pilot the same gate decides which operators are offered a job: 16 GB and up only.
|
||||
- [ ] Memory profile shipped. The patched `sp1-gpu-server` that fits 8 and 12 GB cards (threshold 2^26) is a served artefact, not in the shipped app (litepaper, "Proving"); the served sm_89 tarball still carries the stock server in `home/.sp1/bin` (found 8 October, with the floor lane). Needed: the patched server in the app payload for every tier, and the time-sharing rule for 8 and 12 GB cards (mine or prove, never both at the dataset floor).
|
||||
- [ ] Reporting. A per-job record (program id, input hash, cycles, card, seconds, verified, paid) kept by the operator and summarised for the customer; today's RESULT lines go to a log only (`host/src/main.rs`).
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -13,7 +13,7 @@ Designed. Every transaction pays a base fee in both gas dimensions:
|
|||
| Execution gas | EVM execution, Ethereum's rule | Ethereum's EIP-1559-style base fee over the ordered sequence |
|
||||
| Proving-cost gas | Proving cycles the transaction will cost the provers | A second base fee `f_p`, adjusted per chain block by the same EIP-1559 step as `f_e`: toward a target of `B_p / 2` of proving gas used, denominator 8, never below the floor of section 5.11 (one definition, 5 October 2026, ledger P14; `next_base_fee` in `igneum/exec/src/executor.rs`, applied per chain block in `service.rs`). The unproven backlog does not move `f_p`; it halves `B_p` (design 4.3, the backlog rule), which raises `f_p` through the step. No smoothing over the difficulty window is implemented or specified: the two-dimension step is per chain block |
|
||||
|
||||
The base fee in both dimensions is **burned in full**. A miner cannot stuff blocks with its own transactions for free; wash gas loses its whole base fee (ledger E3). The proving-cost budget per block is a consensus constant set from measured prover throughput (phase 2 gate: one shard on a 12 GB card in about 20 s, Target, unmeasured, ledger P1), so a transaction that is cheap to run and brutal to prove cannot stall the provers for everyone. How the node folds the proving-cost dimension into the quoted gas price so `eth_estimateGas` keeps working is fixed in section 7.1 (ledger P5, closed 3 October 2026).
|
||||
The execution base fee is **burned in full**; the proving base fee is the provers' payment from 8 October 2026 (90% to the block's proving pool, 10% burned; `docs/design/proving-payment.md`, Igneum 2.0 D4), which keeps the anti-stuffing property below through the burn and the pool's sortition. A miner cannot stuff blocks with its own transactions for free; wash gas loses its whole base fee (ledger E3). The proving-cost budget per block is a consensus constant set from measured prover throughput (phase 2 gate: one shard on a 12 GB card in about 20 s, Target, unmeasured, ledger P1), so a transaction that is cheap to run and brutal to prove cannot stall the provers for everyone. How the node folds the proving-cost dimension into the quoted gas price so `eth_estimateGas` keeps working is fixed in section 7.1 (ledger P5, closed 3 October 2026).
|
||||
|
||||
## 5.2 Priority fee split
|
||||
|
||||
|
|
@ -21,7 +21,7 @@ Designed. The priority fee (tip) of every executed transaction splits:
|
|||
|
||||
| Share | Recipient | Rule |
|
||||
|---|---|---|
|
||||
| 80% | Block producer and provers | The producer of the block in which the first copy executed (section 2.6) and the provers of that block, in the proportion the proving protocol defines (forward reference) |
|
||||
| 80% | Block producer | The producer of the block in which the first copy executed (section 2.6). No part of the tip reaches the provers: O-5.7 is closed at zero (8 October 2026, `docs/design/proving-payment.md`); the provers' user-funded payment is the proving base fee (5.1, 5.3) |
|
||||
| 20% | Developer | Attributed per call frame by gas consumed, to the developer address registered for the called contract at deployment. A frame in an unregistered contract sends its share to the burn |
|
||||
|
||||
No part of the tip is burned by rule; the burn is the base fee (5.1) and the unregistered developer share.
|
||||
|
|
@ -34,7 +34,7 @@ Self-dealing: a developer who also mines the including block collects 80% plus 2
|
|||
|
||||
## 5.3 Proving pool
|
||||
|
||||
Designed. The 20% emission share (section 2.5) and the provers' part of the 80% tip share are paid per block as a fixed amount for that block, divided among the block's shards by consensus proving cost, so a stuffed block earns no more than an honest one. Shards are not claimed first-come and carry no bond: each shard is assigned by sortition to 8 eligible provers for a 10-s exclusive window, then open to anyone, and the first valid proof included in a block is paid (section 7.2, decided 3 October 2026, ledger P8, C9). The parameters 8 and 10 s are set on the phase 4 devnet (O-5.1). A withheld shard costs nothing to bond against because nothing waits on an assigned prover: an unproven block delays only its proof; execution and the 30-s lock do not wait for it (ledger P9). The bond, slashed on a bad or late proof, remains in the external job market (5.4), where a customer does wait; its size and timeout are Open (O-5.6).
|
||||
Designed. The 20% emission share (section 2.5) and 90% of the block's proving payment (`pgas used x f_p`, the congestion-priced proving base fee of 5.1; 8 October 2026, `docs/design/proving-payment.md`, the code pinned) are paid per block, the emission share as a fixed amount for that block and the proving payment as the block's own, divided among the block's shards by consensus proving cost, so a stuffed block earns no more than an honest one. Shards are not claimed first-come and carry no bond: each shard is assigned by sortition to 8 eligible provers for a 10-s exclusive window, then open to anyone, and the first valid proof included in a block is paid (section 7.2, decided 3 October 2026, ledger P8, C9). The parameters 8 and 10 s are set on the phase 4 devnet (O-5.1). A withheld shard costs nothing to bond against because nothing waits on an assigned prover: an unproven block delays only its proof; execution and the 30-s lock do not wait for it (ledger P9). The bond, slashed on a bad or late proof, remains in the external job market (5.4), where a customer does wait; its size and timeout are Open (O-5.6).
|
||||
|
||||
Proving v1 (section 7.8, 5 October 2026, Implemented behind `proving_v1_activation_daa`): from the switch, `proving_v1_aggregator_share_bps` of a block's fixed amount (a tenth, the founder's decision at 0.3.11) goes to the aggregator whose segment record attests the block, the rest to the shards as before; a block in a segment that stays unproven past `proving_v1_unproven_daa` pays no aggregator share.
|
||||
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ An item closes when its measurement is in `docs/bench-log.md` or its decision is
|
|||
| O-5.4 | Every genesis contract's upgrade and key policy (ledger G4); the development fund contract is gone (fund removed 3 October 2026) | Publish before launch | 4 |
|
||||
| O-5.5 | The proving-cost gas dimension: the metering table per opcode and precompile, and the per-block budget from the phase 2 measurement | Phase 2 benchmark; execution-layer specification | phase 2 |
|
||||
| O-5.6 | External job bond size and claim timeout (ledger P9); shards carry no bond since the sortition rule of section 7.2 | Set on the phase 4 devnet | 4 |
|
||||
| O-5.7 | The provers' proportion of the 80% tip share (section 5.2) | Defined by the chunked proving protocol | phase 2 |
|
||||
| O-5.7 | The provers' proportion of the 80% tip share (section 5.2) | Closed 8 October 2026: zero; the provers are paid by the proving base fee, 90% to the block's pool and 10% burned (`docs/design/proving-payment.md`, Igneum 2.0 D4) | decided |
|
||||
| O-5.8 | Developer registration format at deployment and the re-registration transaction (section 5.2) | Execution-layer specification | decision, execution engineer |
|
||||
| O-5.9 | Mining against proving under shocks: external proving pays 10x, the IGN price falls, a large operator leaves, assignments go unfulfilled, clients maximise profit (ledger E12); design R8 covers the fee switch only and has not run | R8 simulation extended with the five shocks, then the phase 4 devnet with profit-only prover clients: backlog depth, time to clear, `f_p` and `B_p` paths, income per card. Sweep 5 October 2026: the simulation half ran in `sim/economy` (scenarios b, d, e: external 10x with a 70% price fall, the 20% operator leaving, a 30% operator never fulfilling; no backlog, every block proven within 60 s, hash trough 82% and 75%); the devnet half with profit-only clients is fud-fixes row 126 | 4 |
|
||||
| O-5.10 | No single figure shows every payment route (emission, base fee, priority fee, external jobs at launch and after the bridge, the client dev fee) with currency, recipient, fee and burn (ledger E13) | Draw it, one route per row, in the litepaper Economics section and the customer brief; operator revenue never summed with protocol revenue | decision, execution engineer; before public repo |
|
||||
|
|
|
|||
116
docs/spec/proving-enforcement.md
Normal file
116
docs/spec/proving-enforcement.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# Enforced proving: a valid proof as a condition of payment in consensus
|
||||
|
||||
8 October 2026, the enforced-proving lane, branch `enforced-proving` (main repository) and `enforced-proving-node` (the fork, on the 0.3.25 node line at acaf08b0, which carries the hotfix pair f8da7515). Ordered after an external review the founder accepted (16:1x UK): ledger P21 says consensus validates a proof record's statement against native execution but does not require the SP1 proof itself to verify, so a modified producer can include an unverified record and cause a proving payout without the proof work. This document is the rule, the switch, the tests, the cost, the activation plan and the P22 staging statement. Every figure carries its label: measured (a box run with its RESULT line), stated (code read), or owed (with the clock time it is due).
|
||||
|
||||
## 1. The rule
|
||||
|
||||
A carried proof record pays only when the paying node has verified the record's SP1 proof itself. Two places hold it, the same floor for both:
|
||||
|
||||
1. **The body rule** (`consensus/src/pipeline/body_processor/body_validation_in_context.rs`, `check_carried_proofs`, the 0.3.16 rule): from the floor, every shard record (`IGNP`) and segment record (`IGNS`) a block's coinbase carries must come with proof bytes the node holds that verify for the record's statement under the pinned program id. A proof that does not verify, or that names another guest, makes the block invalid (`IgneumInvalidProofRecord`); proof bytes not held are `IgneumProofMissing`, retried once after a 20-second fetch from the sending peer and then dropped unmarked (`protocol/flows/src/v10/blockrelay/flow.rs`), so the block never enters the DAG.
|
||||
2. **The payment rule** (`igneum/exec/src/proving.rs`, `carried_payouts` and `carried_segment_payouts`): from the floor, a record passes every check of spec 07 7.7 item 3 as before (chain block, window, plan, signature under the network name, assignee, the native-execution veto, first per shard) and then one more: this node's verifier holds a VERIFIED verdict for its proof (`ProofPool::verified_for_payment`: the cached verdict of the body rule or of the relay-time verifier, else a verify now when the bytes are held and the keys exist). Without it the record is carried with `rejected = "proof not verified by this node (enforced proving): no payment"` and pays nothing, and nothing is marked paid, so the honest proof of the same shard can still pay.
|
||||
|
||||
Why both: the body rule stops a block carrying a bad proof from entering any honest node's DAG; the payment rule makes the money condition explicit on the node that computes the state, so a node whose body rule is off (the harness attacker, `IGNEUM_TEST_SKIP_PROOF_RULE=1`) still never pays an unverified record. The native-execution veto stays underneath: no record moves state or pays for a wrong claim whatever its proof.
|
||||
|
||||
Stated, not new: the verifier (`igneum/exec/src/nativeverify.rs`) is SP1's light verifier in-process on Unix (the shard and aggregator verifying keys embedded from `proving/igneum-prove/elf`), through the installed `igneum-prove-host` on Windows. Its verdicts are cached by proof hash, so a proof verified at relay time costs the body rule nothing, and the payment rule nothing again.
|
||||
|
||||
## 2. The switch
|
||||
|
||||
`Params::verifier_in_consensus: bool` (`consensus/core/src/config/params.rs`), with `OverrideParams::verifier_in_consensus: Option<bool>` for the override file (`infra/fast-time/override-60x.json` lists it, `false`). The one accessor every reader uses is `Params::proof_rule_active_from()`:
|
||||
|
||||
| Object | `verifier_in_consensus` | `proving_consensus_verify_daa` | `proof_rule_active_from()` | Meaning |
|
||||
|---|---|---|---|---|
|
||||
| Devnet 3 (the live object, compiled, no file) | false | never | never | the finding's shape: v0, every producer verifies off the consensus path; unchanged by this rollout |
|
||||
| igneum-testnet-1 (the 0.3.25 object) | false | 0 | 0 | the rule has held from the testnet's block zero under the two pinned program ids (stated, `TESTNET_PARAMS`); the switch adds nothing there |
|
||||
| mainnet, simnet, devnet defaults | false | never | never | off |
|
||||
| the class v6 object (section 5) | true | never | 0 | on from block zero under the ids the object pins, or the binary's embedded ids when it pins none |
|
||||
| the tests | true (or a floor) | as the test says | 0 or the floor | on |
|
||||
|
||||
What the switch changes, exactly: the body processor reads `proof_rule_active_from()` instead of the DAA field; the daemon installs the proof oracle with that floor and refuses to start with the rule set when the binary's embedded keys are not the object's pinned ids (an object that sets the switch and pins no ids pins the ids its release embeds, and the start line says so); the executor's `ProvingConfig::verified_payout_from` takes the same floor for the payment rule. In the digest once set (one field, the 0.3.15 rule for new fields), so no live digest moves on the rollout and a node with the switch on never peers with one that lets an unverified record pay. Unit test: `kaspa_consensus_core::config::params::tests::the_verifier_switch_is_off_on_every_compiled_object_and_on_from_genesis_when_set`.
|
||||
|
||||
## 3. The tests, known-failed first
|
||||
|
||||
The harness producer is a modified producer: it signs the CORRECT native statement for the shard and payout every time (the shape the veto cannot catch) and tries the seven refusals. The validator is an ordinary unmodified node. Each refusal is a named test with its assertion.
|
||||
|
||||
Consensus side (`consensus/src/pipeline/body_processor/proving_enforcement_tests.rs`, a `TestConsensus` on the devnet object with the switch on, a test oracle standing in for the executor's verifier, one verdict per proof hash):
|
||||
|
||||
| Refusal | Test | Assertion |
|
||||
|---|---|---|
|
||||
| (a) no proof | `enforced_a_record_with_no_proof_held_is_missing_and_the_block_is_not_inserted` | `Err(IgneumProofMissing)`: the block is never inserted; the relay drops it after the 20-s fetch |
|
||||
| (b) a wrong proof | `enforced_a_record_whose_proof_fails_to_verify_invalidates_the_block` | `Err(IgneumInvalidProofRecord)` carrying "does not verify" |
|
||||
| (c) a proof for another program id | `enforced_a_proof_for_another_program_id_invalidates_the_block` | `Err(IgneumInvalidProofRecord)` carrying "pinned id" |
|
||||
| honest | `enforced_a_verified_record_is_accepted_by_the_body_rule` | `Ok`; the payment (once) is the executor's test below |
|
||||
| the finding | `enforced_without_the_switch_and_with_the_floor_at_never_the_fake_record_is_accepted_the_p21_finding` | the live object's shape: the wrong proof passes the body rule. Kept as the named known-failed shape |
|
||||
| the boundary | `enforced_the_floor_is_a_boundary_below_it_the_fake_record_passes_at_it_the_block_is_invalid` | a floor of 1,000 lets the genesis child (DAA 1) pass with a wrong proof; a floor of 0 refuses it |
|
||||
|
||||
Execution side (`igneum/exec/src/proving.rs`, `tests::enforced_*`, the 30-block chain of the existing proving tests, block 21 one shard, the carrier at DAA 105 past the activation at 100, `verified_payout_from` 0):
|
||||
|
||||
| Refusal | Test | Assertion |
|
||||
|---|---|---|
|
||||
| (a) no proof, (b) a wrong proof, at payment | `enforced_an_unverified_proof_pays_nothing_and_the_verified_honest_record_pays_once` | with the verifier answering false: no payout, `rejected` says "proof not verified", nothing marked paid; with the verifier answering true for the honest proof hash and statement: the shard's 1,000,000 wei paid once, the same record carried again is "shard already paid" |
|
||||
| (d) a replayed record | `enforced_a_replayed_record_pays_nothing_the_second_time` | paid once at carrier 25; carried again at carrier 26: no payout, "shard already paid", one entry in the paid map |
|
||||
| (e) a wrong network id | `enforced_a_record_signed_for_another_network_pays_nothing` | signed under `igneum-devnet-951`: "bad signature", no payout |
|
||||
| (f) an altered payout address | `enforced_an_altered_payout_address_pays_nothing` | the address changed after signing: "bad signature"; re-signed for another address: the native-execution veto (the statement binds the payout); no payout either way |
|
||||
| (g) a duplicate of a paid record | `enforced_a_duplicate_of_a_paid_record_by_another_key_pays_nothing` | prover-b's own valid record for the shard prover-a was paid for: "shard already paid", one payout |
|
||||
| the finding, the floor | `enforced_below_the_floor_an_unverified_record_still_pays_the_v0_shape` | floor never: paid without a verified proof (the finding); one below the floor: paid; at the floor: no payment |
|
||||
| the verdict source | `enforced_the_pool_answers_verified_only_for_a_cached_ok_verdict` | no verdict: false; a refused verdict: false; a VERIFIED verdict: true |
|
||||
|
||||
Known-failed first: the two tests that encode the new condition (the "pays nothing without a verified proof" test and the floor test) fail on the tree before the `verified_payout_from` condition, and the consensus tests under `verifier_in_consensus = true` do not compile before the switch exists. The refusals (d), (e), (f) and (g) were already held by the signature (domain-separated with the network name and binding the payout address), the native veto and the paid map; their tests pass on the old tree too, which is stated here so nobody reads them as new protection. What is new is (a), (b) and (c) at payment on every object, and the named switch.
|
||||
|
||||
Results: section 6.
|
||||
|
||||
## 3a. The plan's six negative tests, mapped
|
||||
|
||||
The Igneum 2.0 plan (p. 16) names six negative tests every validator must pass. The map, each with its test name and the tree it is green on (the 0.3.25 node line at 019a12b1, on release-2.0.0-node as dd84ed6a and f6cd2f00; the proving-payment branch eaddbf83 for the last row):
|
||||
|
||||
| Plan test | Covered by | Where | State |
|
||||
|---|---|---|---|
|
||||
| (1) a correct statement with an invalid proof: rejected, no reward | `enforced_a_record_whose_proof_fails_to_verify_invalidates_the_block` (the block refused); `enforced_an_unverified_proof_pays_nothing_and_the_verified_honest_record_pays_once` (no payment, the honest proof pays once) | consensus; executor | green 16:15 UK |
|
||||
| (2) a wrong program or verifier identity under the pinned release configuration | `enforced_a_proof_for_another_program_id_invalidates_the_block` (the verdict); `nativeverify::tests::a_real_proof_verifies_and_a_wrong_statement_is_refused` (a real proof under the other pinned key: "pinned id"); `the_embedded_keys_match_the_manifest` (the release's keys are the manifest's); the daemon's start refusal when the embedded keys are not the object's pinned ids (exit 3, a process check, not a unit test) | consensus; executor; the daemon | green 16:15 UK; the start refusal stated |
|
||||
| (3) a wrong chain, epoch or statement binding, replayed or misbound work | `enforced_a_record_signed_for_another_network_pays_nothing` (the chain); `enforced_a_replayed_record_pays_nothing_the_second_time` (replay); `assignment_follows_the_window_and_records_check_against_native_execution` (a wrong block, a wrong shard, a stale record outside the window, a wrong statement); the epoch binds through the sortition's epoch seed and the chain block in the statement | executor | green 16:15 UK |
|
||||
| (4) a changed payout identity | `enforced_an_altered_payout_address_pays_nothing` (altered after signing: the signature; re-signed: the statement binds the payout) | executor | green 16:15 UK |
|
||||
| (5) a duplicate proof reward: exactly the permitted payment outcome | `enforced_a_duplicate_of_a_paid_record_by_another_key_pays_nothing`; the honest record paid once in (1)'s test | executor | green 16:15 UK |
|
||||
| (6) incorrect rewards or consensus inputs: derivation authenticated, not only execution over supplied inputs | `enforced_a_statement_over_altered_rewards_or_payouts_is_vetoed_native_derivation_is_the_check` (a statement whose post-root came from execution over other rewards or payouts is not the native statement and pays nothing: the native veto, every node's own derivation) | executor (proving-payment branch, on build-2 at 17:06 UK) | PENDING as the plan means it: the derivation is authenticated by every node's own execution, not inside the proof; the proof-side closure is P22's stages 1 to 3 (section 7), phase 2 |
|
||||
|
||||
The boundary sentence of the plan, carried in every served text that names the rule: a proof of execution is not a proof of authenticated consensus inputs, canonical history or data availability. What the rule proves today is that the carried record's statement is the native statement of this node's own execution and that an SP1 proof of that statement verifies; what consensus inputs the execution used, which history is canonical and whether the data is available are each the node's own reading, not the proof's.
|
||||
|
||||
## 4. The cost
|
||||
|
||||
The verifier's time per record on the node, measured on build-2 under the lease tool (section 6 carries the RESULT lines). The budget per block: at most 8 shard records and 2 segment records per block (`MAX_RECORDS_PER_BLOCK`, `MAX_SEGMENT_RECORDS_PER_BLOCK`), verified in parallel on the body processor's thread pool, each verdict cached by proof hash, so a proof verified at relay time costs the block nothing. Measured (section 6, build-2, one EPYC 9454P core at nice 19 under the lease tool, the box loaded): 0.668 to 0.710 s per record and about 30 MB per concurrent verify. What the switch costs a block, from those figures: nothing for a block that carries no records; nothing for a proof the relay verified before its block (the cached verdict); the worst case is a block whose ten proofs all arrive cold with it, about 0.7 s wall on ten cores of the body processor's pool (7 s of CPU) and about 300 MB peak. At the hold's rate of one block a second, a producer that fills every block with cold proofs costs a validator 0.7 s of wall per block on ten cores, which the pool absorbs; a validator with fewer cores serialises the ten verifies (7 s a block) and falls behind, which is why the relay-time verify is the design's budget and the block-time verify its backstop. Per tier: every node class holds the 300 MB (8 GB rig, 12 and 16 GB card nodes, pool nodes); a Windows node verifies through `igneum-prove-host` and the daemon refuses to start with the rule set and no host. The 10 ms gate of the overview is the hash's per-warp CPU-verify gate, not this check's: an SP1 compressed proof verify is 70x it, a different class of check, and the cache above is what keeps it off the block's critical path.
|
||||
|
||||
## 5. Activation
|
||||
|
||||
1. The live Devnet 3 object does not move: the switch is false, the floor never, the digest unchanged, and the tests say so.
|
||||
2. igneum-testnet-1 already runs the body rule from block zero under its two pinned ids; this rollout adds the payment rule on the same floor (0), which changes nothing a testnet node pays (every carried record on the testnet has passed the body rule), and is the first live network under the full condition.
|
||||
3. The class v6 object (`docs/design/class-v6-rotating-family.md`, no consensus code yet) carries `verifier_in_consensus: true`: on from its block zero. Until that object exists, a network that wants the rule before class v6 sets its own floor through `proving_consensus_verify_daa` in the override file, the way the 0.3.16 rule was designed to land, one weight window above the rollout so every node runs the binary first.
|
||||
4. Every node must run a binary whose embedded keys are the object's pinned ids before the floor; the daemon refuses to start otherwise.
|
||||
5. Shipping: consensus code on the release line the shipper names (release-0.3.26 is the hotfix pair; the next node line opens as release-0.3.27); the main-repository branch lands on the box mirror master through the gate on the coordinator's word.
|
||||
|
||||
## 6. Results and measurements
|
||||
|
||||
Filled from the box runs as they land; each line names the box, the command class and the time.
|
||||
|
||||
| Time (UK) | Box | What | Result |
|
||||
|---|---|---|---|
|
||||
| 16:08 | build-2, suite class, 12 threads, nice 10 (the bounded pool held 13 free cores; the 24-thread ask waited) | `cargo test --release -p igneum-exec --lib enforced_` | 7 passed, 0 failed (the seven executor tests of section 3), 229 s wall with the compile |
|
||||
| 16:11 | build-2, same class | `cargo test --release -p kaspa-consensus-core --lib the_verifier_switch` | 1 passed (the switch is off on every compiled object; Devnet 3 at never; the testnet floor 0; the digest moves once set; a file that omits it changes nothing) |
|
||||
| 16:15 | build-2, suite class, 12 threads | `cargo test --release -p kaspa-consensus-core -p igneum-exec -p kaspa-consensus --lib` (the full lib suites on the branch; the first consensus build stopped on a missing `ConsensusApi` import at 16:11, fixed at 16:12) | igneum-exec 64 passed 0 failed; kaspa-consensus 138 passed 0 failed, 3 ignored (the six `proving_enforcement_tests::enforced_*` among them); kaspa-consensus-core 171 passed 0 failed, 4 ignored; 172 s wall with the compile |
|
||||
| 16:34 | build-2 (AMD EPYC 9454P, 96 threads, 125 GB), `lease pool 1 --nice 19`, one core, the box at load 85 to 95 | the verify cost per record: `nativeverify::tests::a_real_proof_verifies_and_a_wrong_statement_is_refused` on a real compressed shard proof of the testnet join pass (build-1 `/srv/builds/tn-join-pass/prover/block-763-shard-0-compressed.bin`, 1,272,897 bytes), seven runs; `/usr/bin/time -v` for the memory | measured: 0.710, 0.683, 0.701, 0.671, 0.700, 0.687, 0.668 s one core (0.668 to 0.710 s, a loaded-box figure); peak resident 34.6 MB with the verify against 4.6 MB for the same binary without it, so about 30 MB per concurrent verify |
|
||||
|
||||
## 7. The staging statement for P22
|
||||
|
||||
P22: the rewards and the prover payouts are inputs to the shard proof, not outputs. The shard guest takes the segment's rewards and payouts as data and commits the post-root after them; a host can feed any list and the proof still verifies. Today the node's own derivation is the check: the statement must equal the node's native statement for that shard and payout, which used the rewards and payouts consensus derived, so a proof over another list matches no node's statement and pays nothing.
|
||||
|
||||
The closure is staged. Each stage is a claim about one input and the check that holds it; no stage claims a self-contained proof of the whole state.
|
||||
|
||||
| Stage | What becomes an output of a proof | What checks it until then | Status |
|
||||
|---|---|---|---|
|
||||
| 0 (today) | nothing: rewards and payouts are data in the shard statement (`BlockFixture.rewards`, `payouts`) | every node's native execution derives both and vetoes a statement that differs; enforced proving (this document) adds that the record's proof must verify for that statement | implemented |
|
||||
| 1 | the shard proof's rewards list is checked against a commitment the aggregator carries in its public values (the mergeset's blue blocks and their subsidies, hashed) | the aggregator's commitment is itself data; every node recomputes it from the mergeset it holds and vetoes a segment statement whose commitment differs (spec 7.8 item 5, the native block statement) | next: the consensus-proof work of design 7, phase 2 |
|
||||
| 2 | the payouts list is derived inside the aggregator guest from the carried records it verifies (the first valid record per shard, the pool credit split) | the node's `carried_payouts` is the native check of the same derivation; a segment statement whose payouts differ is vetoed | phase 2, after stage 1 |
|
||||
| 3 | the rewards are derived inside the aggregator guest from consensus data it verifies (headers, blue sets) | the node's own derivation vetoes; this is the consensus proof proper, and only here do rewards stop being an input anywhere | phase 2, the last step |
|
||||
|
||||
Until stage 3 lands, every stage's output is checked natively by every node, and the statement "the rewards and payouts are proven" is not made in any served text. The ledger entry P22 carries this table.
|
||||
|
||||
## 8. The fast-time harness case
|
||||
|
||||
`infra/fast-time/proving-enforcement.mjs` (section 6 says whether it ran): three nodes on one fast-time network, two honest under the floor set a few epochs ahead (`proving_consensus_verify_daa` in the override, the boundary), one attacker with the body rule off and the verifier in trust mode. The attacker reads each shard's native statement for its own payout address from its node (`igneum_getShardPlan(block, payout)`), signs it (`igneum-miner sign-record`) and submits it with proof bytes of the seven shapes through `igneum_submitProofRecord`; its templates carry the records. Below the boundary the honest nodes accept the attacker's blocks and the v0 rule pays (the finding, observed); from the boundary every honest node refuses the carrying block (`IgneumInvalidProofRecord` or the 20-s drop) and `igneum_getProofRecords` shows no paid entry for any of the seven. The honest-pays-once case needs a real shard proof of the harness's own chain, which a CPU prover makes in minutes; the unit tests hold it meanwhile and the testnet holds it live.
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Igneum public testnet: identity, parameters and reset policy (ADOPTED 5 October 2026)
|
||||
# Igneum public testnet: identity, parameters and reset policy (ADOPTED 5 October 2026; genesis RE-CUT 7 October 2026; the GO object re-cut on the 0.3.23 line at 18 decimals by the founder's word of 21:35 BST)
|
||||
|
||||
Every value in this file was proposed on the night of 4 October 2026 and ADOPTED by the owner on 5 October 2026,
|
||||
as proposed (sign-off recorded in `docs/plans/release-0.3.6.md`). The genesis below is the one the proposal
|
||||
|
|
@ -8,7 +8,15 @@ merged on 5 October 2026 into the fork's `release-0.3.6` (worktree `vendor/igneu
|
|||
sign-off added: the devnet and the simnet keep the prototype fee set until a height switch
|
||||
(`fees_v1_activation_daa`) or a `fees` object in the override file moves them; the testnet and the mainnet carry
|
||||
calibrated v1 from genesis (section 3). The devnet's switch is DAA score 210,000 (5 October 2026,
|
||||
`docs/plans/fee-switch-devnet.md`). The three seed nodes went up on 5 October 2026 from the final genesis below (`docs/plans/testnet-go.md`); nothing mines until the owner's go.
|
||||
`docs/plans/fee-switch-devnet.md`). The three seed nodes went up on 5 October 2026 from the 5 October genesis (`docs/plans/testnet-go.md`); nothing mines until the owner's go.
|
||||
|
||||
RE-CUT 7 October 2026 (the founder's approvals of 09:3x UK, `docs/plans/ledger-decisions.md` "Decisions (7 October 2026, 09:3x UK)"):
|
||||
one cut with 18 decimals (O-2.6, the 16-byte coinbase subsidy), `EmissionSchedule::TESTNET_1`, and every switch on from
|
||||
genesis. The genesis hash and the consensus digest below are the re-cut's; the seeds still hold the 5 October chain at
|
||||
height 0 and move to the re-cut binary only on the founder's go (`docs/plans/testnet-go.md`, the cut-over runbook). Fork branch
|
||||
`testnet-genesis-2-node` (release-0.3.18-node e69e8a39, the decimals branch df2fbd03, the vote-or-burn removal 420f9305), carried onto
|
||||
the 0.3.24 line as `testnet-genesis-3-node` and landed in `release-0.3.24-node`; the go object is 0d05e795 (8 October 2026: class v5 from
|
||||
genesis after Devnet 3's clean crossing, digest `1da30c10...`, the genesis hash unchanged).
|
||||
|
||||
## 1. Identity
|
||||
|
||||
|
|
@ -40,22 +48,33 @@ read methods plus `eth_sendRawTransaction`; `infra/seed-nodes/rpc/`).
|
|||
| Nonce, DAA score | 0, 0 | |
|
||||
| UTXO commitment | empty | |
|
||||
| Coinbase payload message | `igneum-testnet-1 \| 2026-10-05 \| coins here have no value \| resets are announced` | after the OP-FALSE script, as the devnet's `igneum-devnet`. FINAL 5 October 2026 (fork branch `testnet-infra`, 1c19441d): the proposal's "proposed, not final" was dropped before the first public node started, as `docs/plans/release-0.3.6.md` section 6 required. The message never changes again on `igneum-testnet-1` |
|
||||
| Hash | `87617621714af1bf33bd17f291f90a7e0bff760a669bba53083ea8c0f7cbd840` | computed 5 October 2026 by `print_genesis_hashes` (two runs: the merkle root first, then the header hash over it), pinned by `test_genesis_hashes` and `igneum_testnet_identity`; the three seeds started from it at height 0 the same day. The proposal's hash `52a3e6a9...` is void |
|
||||
| Merkle root | `44acfc40b1c6647011510f3c39ddb7606f979df92ad26a2914de56e44610efad` | same |
|
||||
| Coinbase payload subsidy field | 16 little-endian bytes, one IGN = 10^18 base units | the 18-decimal layout (O-2.6, `docs/design/base-unit.md` section 3); block 1 merges the genesis and reads this field at the network's unit |
|
||||
| Hash | `01294fd322704dc28fbef0e7a5ef86d6ee260ac5efaf88891cdba661b5fd58ac` | RE-CUT 7 October 2026, computed on igneum-build-1 by `print_genesis_hashes` (two runs: the merkle root first, then the header hash over it), pinned by `test_genesis_hashes` and `igneum_testnet_identity`. The 5 October hash `87617621...` (8-byte subsidy layout, the chain the seeds hold at height 0 until the cut-over) and the proposals `52a3e6a9...` and `494fc9a3...` are void |
|
||||
| Merkle root | `bcd0e8fb1099aeb3cc1370b50e6dec9f615e204374b2097f25b2438e7d22f49f` | same (5 October: `44acfc40...`) |
|
||||
| Consensus digest | `1da30c10e164784ffbf5bf216ef3bf84a2d5da212317b1e535c9850fe14aba2f` (the post-crossing re-cut 0d05e795 on `release-0.3.24-node`, 8 October 2026: class v5 from genesis at byte 6; the 0.3.18-line re-cut's `63faee44...` and the 0.3.24 pin's `b2e856ed...` are void) | exchanged in the p2p handshake; a peer with another digest is refused. Pinned by `igneum_testnet_identity`; the whole object as override-file JSON is `infra/seed-nodes/testnet-object.json` (`print_testnet_object`). The 5 October digest `b7d8c915...` and the re-cut's interim digests `9390d235...`, `80af8aa1...` and `4fbb2152...` are void. The devnet's `c562d70e...` is unchanged |
|
||||
|
||||
## 3. Consensus parameters
|
||||
|
||||
| Parameter | Testnet (adopted) | Devnet today | Why |
|
||||
|---|---|---|---|
|
||||
| Block rate | 1 per second | 1 per second | spec 02 |
|
||||
| Difficulty rule | Igneum dual-lane, v2 from genesis | dual-lane, v2 from DAA 33,000 | a fresh chain has no pre-switch history |
|
||||
| Finality parameters | `FinalityParams::MAINNET`: 30-day weight window (2,592,000 DAA), dust 100, presence 240, 8 aggregators, 30-day equivocation ban, min DAA 2,592,000, certificate fold 6 | `DEVNET`: 2-hour window, dust 5, presence 20, fold 3 | the testnet runs the rule the mainnet will run; the first lock needs 30 days of weight, which is the point of a testnet |
|
||||
| Difficulty rule | Igneum dual-lane, v2 and v3 from genesis (`difficulty_v3_activation_daa` 0) | dual-lane, v2 from DAA 33,000, v3 by the override file | a fresh chain has no pre-switch history |
|
||||
| Finality parameters | `FinalityParams::MAINNET`: 30-day weight window (2,592,000 DAA), dust 100, presence 240, 8 aggregators, 30-day equivocation ban, min DAA 2,592,000, certificate fold 6, leave delay 3,600 | `DEVNET`: 2-hour window, dust 5, presence 20, fold 3 | the testnet runs the rule the mainnet will run; the first lock needs 30 days of weight, which is the point of a testnet |
|
||||
| Finality rule v3 | from genesis | from the override file | fresh chain |
|
||||
| The DAA-second finality rule (C1) | from genesis (`finality_daa_rule_activation_daa` 0): a checkpoint every 30 s of chain time at any block rate | by the override file | re-cut 7 October 2026 |
|
||||
| The signed leave item (W7) | from genesis (`finality_leave_activation_daa` 0, delay 3,600 DAA s): a key that announces its departure is in no denominator an hour later | never until the 95 percent signal | the founder, 6 October 2026, question 4 |
|
||||
| The signing bonus, no burn | from genesis (`signing_bonus_activation_daa` 0, `signing_bonus_bps` 1,000): a tenth of a silent producer's subsidy share moves to the proving pool; nothing is ever destroyed; vote-or-burn is out of the tree (420f9305) | never | the founder, 7 October 2026, row 2 |
|
||||
| Consensus proof verification | from genesis (`proving_consensus_verify_daa` 0) under the shard program id `0x2b1a81cb...ef7a` and the aggregator id `0x474678f3...3896` of `proving/igneum-prove/elf/manifest.json` (pinned 2026-10-05T16:20:38Z); a node whose embedded keys differ refuses to start; a Windows node verifies through the installed `igneum-prove-host` and refuses to start without it | off | the founder, 6 October 2026, question 3 |
|
||||
| Program class | v4 from genesis, unconditional (`program_class_v4_activation_daa` 0, signal window 0) | v4 by miner signal | fresh chain |
|
||||
| The latency ladder | active from genesis at rung 0 (`latency_ladder_activation_daa` 0, window 86,400 DAA, seven windows at 90 percent to step): rungs 27, 35, 53 passes admissible; 88 inadmissible (quiet-core re-measure 7 October 2026, 08:46 UK: 10.85 ms cold with the SMT sibling loaded, over the 10 ms gate); 173 and 267 inadmissible | never | the founder, 7 October 2026, row 3 |
|
||||
| Base unit | 18 decimals (`base_unit_decimals` 18): one IGN is 10^18 base units, the EVM's wei, the bridge is the identity | 8 (sompi) | the founder, 6 October 2026, question 5, CONFIRMED 7 October 2026, 21:35 BST for the go object on the 0.3.23 line; `docs/design/base-unit.md` |
|
||||
| Per-block subsidy on the EVM side | from genesis (`subsidy_per_block_activation_daa` 0): each merged block credited the subsidy of its own DAA, as the UTXO coinbase pays it | at an upgrade height with 0.3.19 | the founder, 7 October 2026, 09:5x UK |
|
||||
| Genesis forward-compatibility (mission item 8) | from genesis: `sig_scheme` 0 (BLS12-381) with `sig_scheme_activation_daa` 0 (every vote item and key reveal carries the scheme byte; any other scheme refused until a program class the 95 percent signal moves to names it), `finality_succession_activation_daa` 0 (W5: a vote key hands its window weight and forfeit term to a successor once), the ladder's cache rung 512 MiB inadmissible until measured, behind its own switch (`latency_ladder_cache_rung_activation_daa` 0) | never | the founder, 7 October 2026, 10:1x UK; `docs/design/genesis-forward.md` |
|
||||
| Proving v0 payouts | from genesis | from the override file | fresh chain |
|
||||
| PoW schedule | epoch 3,600 DAA, lead 600, day 86,400,000 ms (the defaults) | same | |
|
||||
| Coinbase payload limit | 16,384 (the finality section) | same | |
|
||||
| Fees | `FeeParams::CALIBRATED_V1` from genesis (`fees_v1_activation_daa` 0; `docs/analysis/base-fee-floor.md`): `B_p` 120,000 pgas, `S_p` 30,000, intrinsic 300, modexp 10 + 1 per 10 bytes, floors 100 gwei per gas and 10,000 gwei per pgas | `FeeParams::PROTOTYPE` (`B_p` 30 M, 1 gwei), kept on the 0.3.6 node so the live chain does not change rules between builds; moves to v1 by the `fees_v1_activation_daa` height switch in the override file (`docs/plans/release-0.3.6.md`, section 5) | spec 05 section 5.10 |
|
||||
| Emission | the mainnet schedule: 31.69 IGN per block in year one, halving every two years, cap 4 billion | same | the testnet coins have no value whatever the schedule says |
|
||||
| Emission | `EmissionSchedule::TESTNET_1` at 18 decimals: 100 IGN a block at 1 bps, a monthly glide with a two-year half-life, a 90-day ramp from 10 percent, a tail of 1 percent of supply a year from the month the glide first pays under it (about year 11.4), no hard cap (`docs/analysis/tail-emission.md`) | `CURRENT`: 31.69 IGN per block in year one, halving every two years, cap 4 billion | the founder, 7 October 2026, row 1; the testnet coins have no value whatever the schedule says |
|
||||
|
||||
Everything else (mass limits, GHOSTDAG k, merge depth, pruning) is the devnet's set, unchanged.
|
||||
|
||||
|
|
@ -81,5 +100,6 @@ Everything else (mass limits, GHOSTDAG k, merge depth, pruning) is the devnet's
|
|||
| The public RPC and explorer | the RPC is `https://rpc.testnet.igneum.network` (5 October 2026); the explorer is not built |
|
||||
| The prover's table mirror and fixtures | `docs/analysis/base-fee-floor.md` section 4 |
|
||||
| The app's testnet build | branch `testnet-app` (5 October 2026): the packaged file's `network`, `peers`, `public_rpc`; the testnet's ports, seeds and node directory in `app/igneum-app/src/config.rs`; the release engineer cuts 0.4.0 from it at go |
|
||||
| The params digest in the handshake (X18) | separate work, before the testnet |
|
||||
| The params digest in the handshake (X18) | done: `1da30c10...` for the go object (0d05e795) |
|
||||
| The seeds on the re-cut genesis | NOT DONE: the three seeds hold the 5 October chain at height 0; the cut-over (new binary, wiped data directory, the digest line read back on each) runs on the founder's go, `docs/plans/testnet-go.md` |
|
||||
| Terms on the download page | `site/index.html#testnet-terms`, on branch `testnet-prep` with this file |
|
||||
|
|
|
|||
152
igneum-pow/src/blake2b.rs
Normal file
152
igneum-pow/src/blake2b.rs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
//! BLAKE2b (RFC 7693), the chain's own hash family (spec 01 section 0.6), written out here so the crate keeps its
|
||||
//! rule of no dependency outside the standard library. Used by class v5's state leaves (`crate::state`):
|
||||
//! `blake2b_512` for a leaf digest, `blake2b_256` for the sample order. Unkeyed, no salt, no personalisation.
|
||||
//! Checked against the RFC's "abc" vector and the empty-input vector in the tests.
|
||||
|
||||
const IV: [u64; 8] = [
|
||||
0x6a09e667f3bcc908,
|
||||
0xbb67ae8584caa73b,
|
||||
0x3c6ef372fe94f82b,
|
||||
0xa54ff53a5f1d36f1,
|
||||
0x510e527fade682d1,
|
||||
0x9b05688c2b3e6c1f,
|
||||
0x1f83d9abfb41bd6b,
|
||||
0x5be0cd19137e2179,
|
||||
];
|
||||
|
||||
const SIGMA: [[usize; 16]; 12] = [
|
||||
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||
[14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3],
|
||||
[11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4],
|
||||
[7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8],
|
||||
[9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13],
|
||||
[2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9],
|
||||
[12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11],
|
||||
[13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10],
|
||||
[6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5],
|
||||
[10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0],
|
||||
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||
[14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3],
|
||||
];
|
||||
|
||||
#[inline(always)]
|
||||
fn g(v: &mut [u64; 16], a: usize, b: usize, c: usize, d: usize, x: u64, y: u64) {
|
||||
v[a] = v[a].wrapping_add(v[b]).wrapping_add(x);
|
||||
v[d] = (v[d] ^ v[a]).rotate_right(32);
|
||||
v[c] = v[c].wrapping_add(v[d]);
|
||||
v[b] = (v[b] ^ v[c]).rotate_right(24);
|
||||
v[a] = v[a].wrapping_add(v[b]).wrapping_add(y);
|
||||
v[d] = (v[d] ^ v[a]).rotate_right(16);
|
||||
v[c] = v[c].wrapping_add(v[d]);
|
||||
v[b] = (v[b] ^ v[c]).rotate_right(63);
|
||||
}
|
||||
|
||||
fn compress(h: &mut [u64; 8], block: &[u8; 128], t: u128, last: bool) {
|
||||
let mut m = [0u64; 16];
|
||||
for (i, w) in m.iter_mut().enumerate() {
|
||||
*w = u64::from_le_bytes(block[i * 8..i * 8 + 8].try_into().unwrap());
|
||||
}
|
||||
let mut v = [0u64; 16];
|
||||
v[..8].copy_from_slice(h);
|
||||
v[8..].copy_from_slice(&IV);
|
||||
v[12] ^= t as u64;
|
||||
v[13] ^= (t >> 64) as u64;
|
||||
if last {
|
||||
v[14] = !v[14];
|
||||
}
|
||||
for s in SIGMA.iter() {
|
||||
g(&mut v, 0, 4, 8, 12, m[s[0]], m[s[1]]);
|
||||
g(&mut v, 1, 5, 9, 13, m[s[2]], m[s[3]]);
|
||||
g(&mut v, 2, 6, 10, 14, m[s[4]], m[s[5]]);
|
||||
g(&mut v, 3, 7, 11, 15, m[s[6]], m[s[7]]);
|
||||
g(&mut v, 0, 5, 10, 15, m[s[8]], m[s[9]]);
|
||||
g(&mut v, 1, 6, 11, 12, m[s[10]], m[s[11]]);
|
||||
g(&mut v, 2, 7, 8, 13, m[s[12]], m[s[13]]);
|
||||
g(&mut v, 3, 4, 9, 14, m[s[14]], m[s[15]]);
|
||||
}
|
||||
for i in 0..8 {
|
||||
h[i] ^= v[i] ^ v[i + 8];
|
||||
}
|
||||
}
|
||||
|
||||
/// Unkeyed BLAKE2b of `data` with an output of `out_len` bytes (1..=64), written into `out[..out_len]`.
|
||||
pub fn blake2b(out: &mut [u8], out_len: usize, data: &[u8]) {
|
||||
assert!((1..=64).contains(&out_len) && out.len() >= out_len);
|
||||
let mut h = IV;
|
||||
h[0] ^= 0x0101_0000 ^ out_len as u64;
|
||||
let mut t: u128 = 0;
|
||||
let n = data.len();
|
||||
// every full block but the last; the last block (possibly empty) is compressed with the final flag
|
||||
let full = if n == 0 { 0 } else { (n - 1) / 128 };
|
||||
for i in 0..full {
|
||||
let block: &[u8; 128] = data[i * 128..i * 128 + 128].try_into().unwrap();
|
||||
t += 128;
|
||||
compress(&mut h, block, t, false);
|
||||
}
|
||||
let mut last = [0u8; 128];
|
||||
let rest = &data[full * 128..];
|
||||
last[..rest.len()].copy_from_slice(rest);
|
||||
t += rest.len() as u128;
|
||||
compress(&mut h, &last, t, true);
|
||||
let mut bytes = [0u8; 64];
|
||||
for (i, w) in h.iter().enumerate() {
|
||||
bytes[i * 8..i * 8 + 8].copy_from_slice(&w.to_le_bytes());
|
||||
}
|
||||
out[..out_len].copy_from_slice(&bytes[..out_len]);
|
||||
}
|
||||
|
||||
/// BLAKE2b-512 of the concatenation of `parts`.
|
||||
pub fn blake2b_512(parts: &[&[u8]]) -> [u8; 64] {
|
||||
let mut data = Vec::with_capacity(parts.iter().map(|p| p.len()).sum());
|
||||
for p in parts {
|
||||
data.extend_from_slice(p);
|
||||
}
|
||||
let mut out = [0u8; 64];
|
||||
blake2b(&mut out, 64, &data);
|
||||
out
|
||||
}
|
||||
|
||||
/// BLAKE2b-256 of the concatenation of `parts`.
|
||||
pub fn blake2b_256(parts: &[&[u8]]) -> [u8; 32] {
|
||||
let mut data = Vec::with_capacity(parts.iter().map(|p| p.len()).sum());
|
||||
for p in parts {
|
||||
data.extend_from_slice(p);
|
||||
}
|
||||
let mut out = [0u8; 32];
|
||||
blake2b(&mut out, 32, &data);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn hex(b: &[u8]) -> String {
|
||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
||||
/// RFC 7693 appendix A ("abc"), the empty input, and a two-block input against the reference implementation's
|
||||
/// known values (the three-block "The quick brown fox" vector of the BLAKE2 test suite).
|
||||
#[test]
|
||||
fn rfc_7693_vectors() {
|
||||
assert_eq!(
|
||||
hex(&blake2b_512(&[b"abc"])),
|
||||
"ba80a53f981c4d0d6a2797b69f12f6e94c212f14685ac4b74b12bb6fdbffa2d17d87c5392aab792dc252d5de4533cc9518d38aa8dbf1925ab92386edd4009923"
|
||||
);
|
||||
assert_eq!(
|
||||
hex(&blake2b_512(&[b""])),
|
||||
"786a02f742015903c6c6fd852552d272912f4740e15847618a86e217f71f5419d25e1031afee585313896444934eb04b903a685b1448b755d56f701afe9be2ce"
|
||||
);
|
||||
assert_eq!(hex(&blake2b_256(&[b"abc"])), "bddd813c634239723171ef3fee98579b94964e3bb1cb3e427262c8c068d52319");
|
||||
assert_eq!(hex(&blake2b_256(&[b""])), "0e5751c026e543b2e8ab2eb06099daa1d1e5df47778f7787faab45cdf12fe3a8");
|
||||
// a 128-byte input is exactly one full block compressed as the last; 129 bytes takes two
|
||||
let one = [0x61u8; 128];
|
||||
let two = [0x61u8; 129];
|
||||
assert_ne!(blake2b_512(&[&one]), blake2b_512(&[&two]));
|
||||
assert_eq!(blake2b_512(&[&one[..64], &one[64..]]), blake2b_512(&[&one]), "parts concatenate");
|
||||
assert_eq!(
|
||||
hex(&blake2b_512(&[b"The quick brown fox jumps over the lazy dog"])),
|
||||
"a8add4bdddfd93e4877d2746e62817b116364a1fa7bc148d95090bc7333b3673f82401cf7aa2e4cb1ecd90296e3f14cb5413f8ed77be73045b13914cdcd6a918"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -164,7 +164,11 @@ fn program_class_header_lines(p: &Program) -> String {
|
|||
return String::new();
|
||||
}
|
||||
let mut s = String::new();
|
||||
if p.program_class() == ProgramClass::V4 {
|
||||
if p.program_class() == ProgramClass::V5 {
|
||||
s.push_str("// Program class v5 (proof of stored state and of following, docs/design/class-v5-stored-state.md): generator version 5,\n");
|
||||
s.push_str("// class v4 over a dataset whose every item is keyed by the window's execution state (IGNEUM_STATE_* below, leaves.bin);\n");
|
||||
s.push_str("// a worker that runs another class refuses this pack, and a job line names the class it wants (class=v5 era=<hex>).\n");
|
||||
} else if p.program_class() == ProgramClass::V4 {
|
||||
s.push_str("// Program class v4 (Counter ASIC 3.0, docs/plans/counter-asic-3-node.md): generator version 4, class v3 plus the\n");
|
||||
s.push_str("// latency-shadow block (IGNEUM_SHADOW_INSTRS x IGNEUM_SHADOW_REPS per iteration); a worker that runs another class\n");
|
||||
s.push_str("// refuses this pack, and a job line names the class it wants (class=v4 era=<hex>).\n");
|
||||
|
|
@ -183,6 +187,24 @@ fn program_class_header_lines(p: &Program) -> String {
|
|||
s
|
||||
}
|
||||
|
||||
/// The state lines of program.h (class v5): the window's reference block and state root, the leaf count, the FNV of
|
||||
/// `leaves.bin` and the file's name. Empty for every dataset without leaves, so no pinned pack changes.
|
||||
fn state_header_lines(ds: &DatasetSource) -> String {
|
||||
let Some(l) = ds.leaves() else { return String::new() };
|
||||
let mut s = String::new();
|
||||
s.push_str("// Class v5 state (docs/design/class-v5-stored-state.md): the window's reference chain block and the state root after it;\n");
|
||||
s.push_str("// leaves.bin holds IGNEUM_STATE_LEAVES leaves of 16 little-endian words, leaf(t) = leaves[t mod IGNEUM_STATE_LEAVES].\n");
|
||||
s.push_str(&format!("#define IGNEUM_STATE_BLOCK_HEX {}\n", jstr(&hex_bytes(&l.block))));
|
||||
s.push_str(&format!("#define IGNEUM_STATE_BLOCK_NUMBER {}\n", l.number));
|
||||
s.push_str(&format!("#define IGNEUM_STATE_ROOT_HEX {}\n", jstr(&hex_bytes(&l.root))));
|
||||
s.push_str(&format!("#define IGNEUM_STATE_LEAVES {}\n", l.n()));
|
||||
s.push_str(&format!("#define IGNEUM_STATE_RECORDS {}\n", l.records_total));
|
||||
s.push_str(&format!("#define IGNEUM_STATE_SAMPLED {}\n", l.sampled as u8));
|
||||
s.push_str(&format!("#define IGNEUM_STATE_LEAVES_FNV64 {}\n", hex64(l.fnv1a64())));
|
||||
s.push_str("#define IGNEUM_STATE_LEAVES_FILE \"leaves.bin\"\n");
|
||||
s
|
||||
}
|
||||
|
||||
/// The load class lines of program.h (empty for the lottery hash, so the pinned packs do not change).
|
||||
fn class_header_lines(p: &Program) -> String {
|
||||
if p.class.is_v2() {
|
||||
|
|
@ -758,28 +780,35 @@ pub fn emit_memhard_core_layout(mp: &MixParams, dialect: CoreDialect, layout: La
|
|||
s.push_str("}\n");
|
||||
}
|
||||
s.push_str(&format!("// Item t: 16 words. s = (K, t * MUL[i] + RC[i]); {ITEM_ROUNDS} rounds of (round program r, cache line s[0] & mask); round program {ITEM_ROUNDS}.\n"));
|
||||
s.push_str(&format!("{fn_} void mh_item({cptr} cache, {u} t, {lptr} s) {{\n"));
|
||||
s.push_str(&item_signature(shape.state, fn_, cptr, u, lptr));
|
||||
for i in 0..8 {
|
||||
s.push_str(&format!(" s[{i}] = {};\n", hex(k[i])));
|
||||
}
|
||||
for i in 0..8 {
|
||||
s.push_str(&format!(" s[{}] = t * {} + {};\n", 8 + i, hex(mul[i]), hex(c[i])));
|
||||
}
|
||||
if shape.state {
|
||||
s.push_str(&format!(" for ({u} i = 0u; i < 16u; ++i) s[i] ^= leaf[i];\n"));
|
||||
}
|
||||
for r in 0..ITEM_ROUNDS {
|
||||
s.push_str(&format!(" mh_round_{r}(s);\n"));
|
||||
s.push_str(&format!(" {{ {cptr} line = cache + ((s[0] & MH_CACHE_LINE_MASK) * 16u); for ({u} i = 0u; i < 16u; ++i) s[i] ^= line[i]; }}\n"));
|
||||
}
|
||||
s.push_str(&format!(" mh_round_{ITEM_ROUNDS}(s);\n"));
|
||||
s.push_str("}\n");
|
||||
return finish_memhard_core(s, layout, u, fn_, cptr);
|
||||
return finish_memhard_core(s, layout, u, fn_, cptr, shape.state);
|
||||
}
|
||||
s.push_str(&format!("{fn_} void mh_item({cptr} cache, {u} t, {lptr} s) {{\n"));
|
||||
s.push_str(&item_signature(shape.state, fn_, cptr, u, lptr));
|
||||
for i in 0..8 {
|
||||
s.push_str(&format!(" s[{i}] = {};\n", hex(k[i])));
|
||||
}
|
||||
for i in 0..8 {
|
||||
s.push_str(&format!(" s[{}] = t * {} + {};\n", 8 + i, hex(mul[i]), hex(c[i])));
|
||||
}
|
||||
if shape.state {
|
||||
// class v5: the window's state leaf of item t, before the first mixer (docs/design/class-v5-stored-state.md)
|
||||
s.push_str(&format!(" for ({u} i = 0u; i < 16u; ++i) s[i] ^= leaf[i];\n"));
|
||||
}
|
||||
s.push_str(&format!(" for ({u} r = 0u; r < {ITEM_ROUNDS}u; ++r) {{\n"));
|
||||
if m == 1 {
|
||||
s.push_str(" mh_mixer(s, 0x9E3779B9u * (r + 1u));\n");
|
||||
|
|
@ -798,22 +827,48 @@ pub fn emit_memhard_core_layout(mp: &MixParams, dialect: CoreDialect, layout: La
|
|||
));
|
||||
}
|
||||
s.push_str("}\n");
|
||||
finish_memhard_core(s, layout, u, fn_, cptr)
|
||||
finish_memhard_core(s, layout, u, fn_, cptr, shape.state)
|
||||
}
|
||||
|
||||
/// The tail of the memhard core: `mh_word` (and the era layout helpers) after `mh_item`.
|
||||
fn finish_memhard_core(mut s: String, layout: Layout, u: &str, fn_: &str, cptr: &str) -> String {
|
||||
/// The `mh_item` signature: under a state shape (class v5) the item takes its 16-word leaf (`leaves + 16 (t mod n)`).
|
||||
fn item_signature(state: bool, fn_: &str, cptr: &str, u: &str, lptr: &str) -> String {
|
||||
if state {
|
||||
format!("{fn_} void mh_item({cptr} cache, {cptr} leaf, {u} t, {lptr} s) {{\n")
|
||||
} else {
|
||||
format!("{fn_} void mh_item({cptr} cache, {u} t, {lptr} s) {{\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// The tail of the memhard core: `mh_word` (and the era layout helpers) after `mh_item`. Under a state shape
|
||||
/// `mh_word` takes the leaves and their count and derives item t's leaf as `leaves + 16 (t mod nLeaves)`.
|
||||
fn finish_memhard_core(mut s: String, layout: Layout, u: &str, fn_: &str, cptr: &str, state: bool) -> String {
|
||||
if state {
|
||||
s.push_str("// Class v5 (docs/design/class-v5-stored-state.md): leaf(t) = leaves[t mod nLeaves], 16 words per leaf (leaves.bin).\n");
|
||||
s.push_str(&format!("{fn_} {cptr} mh_leaf({cptr} leaves, {u} nLeaves, {u} t) {{ return leaves + ((t % nLeaves) * 16u); }}\n"));
|
||||
}
|
||||
if layout.is_linear() {
|
||||
s.push_str("// dataset[w] without the dataset: derive item w >> 4 and take word w & 15.\n");
|
||||
s.push_str(&format!(
|
||||
"{fn_} {u} mh_word({cptr} cache, {u} w) {{ {u} s[16]; mh_item(cache, w >> 4u, s); return s[w & 15u]; }}\n"
|
||||
));
|
||||
if state {
|
||||
s.push_str(&format!(
|
||||
"{fn_} {u} mh_word({cptr} cache, {cptr} leaves, {u} nLeaves, {u} w) {{ {u} s[16]; mh_item(cache, mh_leaf(leaves, nLeaves, w >> 4u), w >> 4u, s); return s[w & 15u]; }}\n"
|
||||
));
|
||||
} else {
|
||||
s.push_str(&format!(
|
||||
"{fn_} {u} mh_word({cptr} cache, {u} w) {{ {u} s[16]; mh_item(cache, w >> 4u, s); return s[w & 15u]; }}\n"
|
||||
));
|
||||
}
|
||||
} else {
|
||||
s.push_str(&layout_helpers(layout, u, fn_));
|
||||
s.push_str("// dataset[w] without the dataset: derive item mh_t(w) and take word mh_j(w).\n");
|
||||
s.push_str(&format!(
|
||||
"{fn_} {u} mh_word({cptr} cache, {u} w) {{ {u} s[16]; mh_item(cache, mh_t(w), s); return s[mh_j(w)]; }}\n"
|
||||
));
|
||||
if state {
|
||||
s.push_str(&format!(
|
||||
"{fn_} {u} mh_word({cptr} cache, {cptr} leaves, {u} nLeaves, {u} w) {{ {u} s[16]; mh_item(cache, mh_leaf(leaves, nLeaves, mh_t(w)), mh_t(w), s); return s[mh_j(w)]; }}\n"
|
||||
));
|
||||
} else {
|
||||
s.push_str(&format!(
|
||||
"{fn_} {u} mh_word({cptr} cache, {u} w) {{ {u} s[16]; mh_item(cache, mh_t(w), s); return s[mh_j(w)]; }}\n"
|
||||
));
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
|
@ -872,12 +927,23 @@ pub fn metal_memhard_layout(mp: &MixParams, layout: Layout) -> String {
|
|||
s.push_str(" mh_cache_segment(cache, gid);\n");
|
||||
s.push_str("}\n");
|
||||
s.push_str("// One thread per 64-byte item (dataset words / 16 threads).\n");
|
||||
s.push_str(
|
||||
"kernel void igneum_build(device const uint* cache [[buffer(0)]], device uint* dataset [[buffer(1)]],\n",
|
||||
);
|
||||
s.push_str(" uint gid [[thread_position_in_grid]]) {\n");
|
||||
s.push_str(" uint s[16];\n");
|
||||
s.push_str(" mh_item(cache, gid, s);\n");
|
||||
if mp.shape.state {
|
||||
s.push_str("// Class v5: the window's leaves (leaves.bin, IGNEUM_STATE_LEAVES x 16 words) in buffer 2, their count in buffer 3.\n");
|
||||
s.push_str(
|
||||
"kernel void igneum_build(device const uint* cache [[buffer(0)]], device uint* dataset [[buffer(1)]],\n",
|
||||
);
|
||||
s.push_str(" device const uint* leaves [[buffer(2)]], constant uint& nLeaves [[buffer(3)]],\n");
|
||||
s.push_str(" uint gid [[thread_position_in_grid]]) {\n");
|
||||
s.push_str(" uint s[16];\n");
|
||||
s.push_str(" mh_item(cache, mh_leaf(leaves, nLeaves, gid), gid, s);\n");
|
||||
} else {
|
||||
s.push_str(
|
||||
"kernel void igneum_build(device const uint* cache [[buffer(0)]], device uint* dataset [[buffer(1)]],\n",
|
||||
);
|
||||
s.push_str(" uint gid [[thread_position_in_grid]]) {\n");
|
||||
s.push_str(" uint s[16];\n");
|
||||
s.push_str(" mh_item(cache, gid, s);\n");
|
||||
}
|
||||
s.push_str(&build_store(layout, CoreDialect::Metal, "dataset", "gid"));
|
||||
s.push_str("}\n");
|
||||
s
|
||||
|
|
@ -1043,7 +1109,7 @@ fn generated_by(seed: &str) -> String {
|
|||
format!("// Generated by igneum-pow export (generator v{GENERATOR_VERSION}) for seed \"{seed}\". Do not edit by hand.\n")
|
||||
}
|
||||
|
||||
fn hex_bytes(b: &[u8]) -> String {
|
||||
pub fn hex_bytes(b: &[u8]) -> String {
|
||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
||||
|
|
@ -1157,11 +1223,20 @@ pub fn cuda_kernel_at(p: &Program, memhard: Option<&MixParams>, dataset_log2: u3
|
|||
s.push_str(" uint32_t seg = blockIdx.x * blockDim.x + threadIdx.x;\n");
|
||||
s.push_str(" if (seg < nSegments) mh_cache_segment(cache, seg);\n");
|
||||
s.push_str("}\n");
|
||||
s.push_str("__global__ void igneum_build(uint32_t* ds, const uint32_t* cache, uint32_t nItems) {\n");
|
||||
if p.class.state {
|
||||
s.push_str("// Class v5: the window's leaves (leaves.bin, IGNEUM_STATE_LEAVES x 16 words) and their count.\n");
|
||||
s.push_str("__global__ void igneum_build(uint32_t* ds, const uint32_t* cache, const uint32_t* leaves, uint32_t nLeaves, uint32_t nItems) {\n");
|
||||
} else {
|
||||
s.push_str("__global__ void igneum_build(uint32_t* ds, const uint32_t* cache, uint32_t nItems) {\n");
|
||||
}
|
||||
s.push_str(" uint32_t t = blockIdx.x * blockDim.x + threadIdx.x;\n");
|
||||
s.push_str(" if (t < nItems) {\n");
|
||||
s.push_str(" uint32_t s[16];\n");
|
||||
s.push_str(" mh_item(cache, t, s);\n");
|
||||
if p.class.state {
|
||||
s.push_str(" mh_item(cache, mh_leaf(leaves, nLeaves, t), t, s);\n");
|
||||
} else {
|
||||
s.push_str(" mh_item(cache, t, s);\n");
|
||||
}
|
||||
s.push_str(&build_store(layout, CoreDialect::Cuda, "ds", "t"));
|
||||
s.push_str(" }\n");
|
||||
s.push_str("}\n");
|
||||
|
|
@ -1228,11 +1303,20 @@ pub fn cuda_kernel_at(p: &Program, memhard: Option<&MixParams>, dataset_log2: u3
|
|||
s.push_str(" return cudaGetLastError();\n");
|
||||
s.push_str("}\n");
|
||||
s.push('\n');
|
||||
s.push_str("cudaError_t igneum_launch_build(uint32_t* ds, const uint32_t* cache, uint32_t nItems) {\n");
|
||||
s.push_str(" if (nItems == 0u) return cudaErrorInvalidValue;\n");
|
||||
if p.class.state {
|
||||
s.push_str("cudaError_t igneum_launch_build(uint32_t* ds, const uint32_t* cache, const uint32_t* leaves, uint32_t nLeaves, uint32_t nItems) {\n");
|
||||
s.push_str(" if (nItems == 0u || nLeaves == 0u) return cudaErrorInvalidValue;\n");
|
||||
} else {
|
||||
s.push_str("cudaError_t igneum_launch_build(uint32_t* ds, const uint32_t* cache, uint32_t nItems) {\n");
|
||||
s.push_str(" if (nItems == 0u) return cudaErrorInvalidValue;\n");
|
||||
}
|
||||
s.push_str(" uint32_t block = 256u;\n");
|
||||
s.push_str(" uint32_t grid = (nItems + block - 1u) / block;\n");
|
||||
s.push_str(" igneum_build<<<grid, block>>>(ds, cache, nItems);\n");
|
||||
if p.class.state {
|
||||
s.push_str(" igneum_build<<<grid, block>>>(ds, cache, leaves, nLeaves, nItems);\n");
|
||||
} else {
|
||||
s.push_str(" igneum_build<<<grid, block>>>(ds, cache, nItems);\n");
|
||||
}
|
||||
s.push_str(" return cudaGetLastError();\n");
|
||||
s.push_str("}\n");
|
||||
s.push('\n');
|
||||
|
|
@ -1590,11 +1674,20 @@ pub fn opencl_kernel_at(p: &Program, memhard: Option<&MixParams>, dataset_log2:
|
|||
s.push_str(" uint seg = (uint)get_global_id(0);\n");
|
||||
s.push_str(" if (seg < nSegments) mh_cache_segment(cache, seg);\n");
|
||||
s.push_str("}\n");
|
||||
s.push_str("__kernel void igneum_build(__global uint* ds, __global const uint* cache, uint nItems) {\n");
|
||||
if p.class.state {
|
||||
s.push_str("// Class v5: the window's leaves (leaves.bin, IGNEUM_STATE_LEAVES x 16 words) and their count.\n");
|
||||
s.push_str("__kernel void igneum_build(__global uint* ds, __global const uint* cache, __global const uint* leaves, uint nLeaves, uint nItems) {\n");
|
||||
} else {
|
||||
s.push_str("__kernel void igneum_build(__global uint* ds, __global const uint* cache, uint nItems) {\n");
|
||||
}
|
||||
s.push_str(" uint t = (uint)get_global_id(0);\n");
|
||||
s.push_str(" if (t < nItems) {\n");
|
||||
s.push_str(" uint s[16];\n");
|
||||
s.push_str(" mh_item(cache, t, s);\n");
|
||||
if p.class.state {
|
||||
s.push_str(" mh_item(cache, mh_leaf(leaves, nLeaves, t), t, s);\n");
|
||||
} else {
|
||||
s.push_str(" mh_item(cache, t, s);\n");
|
||||
}
|
||||
s.push_str(&build_store(layout, CoreDialect::OpenCl, "ds", "t"));
|
||||
s.push_str(" }\n");
|
||||
s.push_str("}\n");
|
||||
|
|
@ -1726,6 +1819,7 @@ pub fn program_header(p: &Program, day: &str, ds: &DatasetSource) -> String {
|
|||
s.push_str(&format!("#define IGNEUM_OP_MIX {}\n", jstr(&p.op_mix())));
|
||||
s.push_str(&program_class_header_lines(p));
|
||||
s.push_str(&class_header_lines(p));
|
||||
s.push_str(&state_header_lines(ds));
|
||||
s.push_str(&scratch_header_lines(p));
|
||||
s.push_str(&era_header_lines(p));
|
||||
s.push_str(&hot_header_lines(p));
|
||||
|
|
@ -1762,7 +1856,11 @@ pub fn program_header(p: &Program, day: &str, ds: &DatasetSource) -> String {
|
|||
s.push_str("#ifndef IGNEUM_NO_CUDA\n");
|
||||
s.push_str("// Defined in kernel.cu. All launch on the default stream and return cudaGetLastError().\n");
|
||||
s.push_str("cudaError_t igneum_launch_cache_fill(uint32_t* cache, uint32_t nSegments);\n");
|
||||
s.push_str("cudaError_t igneum_launch_build(uint32_t* ds, const uint32_t* cache, uint32_t nItems);\n");
|
||||
if p.class.state {
|
||||
s.push_str("cudaError_t igneum_launch_build(uint32_t* ds, const uint32_t* cache, const uint32_t* leaves, uint32_t nLeaves, uint32_t nItems);\n");
|
||||
} else {
|
||||
s.push_str("cudaError_t igneum_launch_build(uint32_t* ds, const uint32_t* cache, uint32_t nItems);\n");
|
||||
}
|
||||
if p.has_hot() {
|
||||
s.push_str("cudaError_t igneum_launch_hot_fill(uint32_t* hot, uint32_t nSegments);\n");
|
||||
}
|
||||
|
|
@ -1960,6 +2058,19 @@ pub fn program_json(p: &Program, day: &str, ds: &DatasetSource) -> String {
|
|||
s.push_str(&format!(" \"era_seed_bytes\": {},\n", jstr(&hex_bytes(era))));
|
||||
}
|
||||
}
|
||||
if let Some(l) = ds.leaves() {
|
||||
s.push_str(" \"state\": {\n");
|
||||
s.push_str(&format!(" \"block\": {},\n", jstr(&hex_bytes(&l.block))));
|
||||
s.push_str(&format!(" \"block_number\": {},\n", l.number));
|
||||
s.push_str(&format!(" \"root\": {},\n", jstr(&hex_bytes(&l.root))));
|
||||
s.push_str(&format!(" \"leaves\": {},\n", l.n()));
|
||||
s.push_str(&format!(" \"records\": {},\n", l.records_total));
|
||||
s.push_str(&format!(" \"sampled\": {},\n", l.sampled));
|
||||
s.push_str(&format!(" \"leaves_fnv1a64\": {},\n", jhex64(l.fnv1a64())));
|
||||
s.push_str(" \"leaf_derivation\": \"leaves[i] = Blake2b-512('igneum-sd1/' || root || i_le32 || record_i) as 16 little-endian words; item t XORs leaves[t mod leaves] into its 16 initial words before the first mixer\",\n");
|
||||
s.push_str(" \"file\": \"leaves.bin\"\n");
|
||||
s.push_str(" },\n");
|
||||
}
|
||||
if !p.class.is_v2() {
|
||||
let c = p.width_counts();
|
||||
s.push_str(&format!(" \"load_class\": {},\n", jstr(&p.class.name())));
|
||||
|
|
@ -2255,6 +2366,8 @@ pub fn vectors_json(
|
|||
/// A program pack: the files `--export-pack` writes, as (name, text).
|
||||
pub struct Pack {
|
||||
pub files: Vec<(String, String)>,
|
||||
/// Binary files beside the texts: `leaves.bin` of a class v5 pack (empty for every other pack).
|
||||
pub binaries: Vec<(String, Vec<u8>)>,
|
||||
pub bases: Vec<u32>,
|
||||
pub outs: Vec<[u64; 32]>,
|
||||
pub vectors: PackVectors,
|
||||
|
|
@ -2266,6 +2379,9 @@ impl Pack {
|
|||
for (name, text) in &self.files {
|
||||
std::fs::write(dir.join(name), text)?;
|
||||
}
|
||||
for (name, bytes) in &self.binaries {
|
||||
std::fs::write(dir.join(name), bytes)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -2319,7 +2435,11 @@ pub fn export_pack(epoch: &Epoch, day: &str, source: &str) -> Pack {
|
|||
files.push(("memhard.h".to_string(), cuda_memhard_header(p, mp)));
|
||||
files.push(("memhard.metal".to_string(), metal_memhard_for(p, mp)));
|
||||
}
|
||||
Pack { files, bases, outs, vectors: v }
|
||||
let binaries = match ds.leaves() {
|
||||
Some(l) => vec![("leaves.bin".to_string(), l.bytes())],
|
||||
None => Vec::new(),
|
||||
};
|
||||
Pack { files, binaries, bases, outs, vectors: v }
|
||||
}
|
||||
|
||||
/// The dataset mode a pack was written in, from its program.json text (no JSON parser needed).
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
|
||||
pub mod accept;
|
||||
pub mod bind;
|
||||
pub mod blake2b;
|
||||
pub mod derive;
|
||||
pub mod emit;
|
||||
pub mod generator;
|
||||
|
|
@ -31,11 +32,13 @@ pub mod memhard;
|
|||
pub mod mm8;
|
||||
pub mod packcheck;
|
||||
pub mod seed;
|
||||
pub mod state;
|
||||
pub mod verify;
|
||||
|
||||
pub use bind::{block_init_words, day_bytes, pow256_from_lane, target64_from_le256};
|
||||
pub use accept::{check as accept_program, AcceptReport, Reject};
|
||||
pub use generator::{generate, generate_from_seed_bytes, generate_from_seed_bytes_program_class, generate_from_seed_bytes_program_class_shadow, v4_class_at, v4_counted_ops, v4_rung_reps, Instr, LoadClass, Op, Program, ProgramClass, GENERATOR_VERSION, GENERATOR_VERSION_V3, GENERATOR_VERSION_V4, V3_CLASS, V4_CLASS, V4_SHADOW_INSTRS, V4_SHADOW_REPS};
|
||||
pub use generator::{generate, generate_from_seed_bytes, generate_from_seed_bytes_program_class, generate_from_seed_bytes_program_class_shadow, v4_class_at, v4_counted_ops, v4_rung_reps, v5_class_at, v5_rung_reps, Instr, LoadClass, Op, Program, ProgramClass, GENERATOR_VERSION, GENERATOR_VERSION_V3, GENERATOR_VERSION_V4, GENERATOR_VERSION_V5, V3_CLASS, V4_CLASS, V4_SHADOW_INSTRS, V4_SHADOW_REPS, V5_CLASS, PROGRAM_SUBVERSION_V4};
|
||||
pub use memhard::{cache_log2_words, dataset_log2_words, days_since_genesis, growth_doublings, Cache, MemhardCpu, MixParams, Shape};
|
||||
pub use seed::{fnv1a64, seed_words, SplitMix64};
|
||||
pub use state::{StateLeaves, StateStream};
|
||||
pub use verify::{hash_warp, interpret_warp_init, verify_block, DatasetMode, DatasetSource, Epoch};
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ struct Args {
|
|||
prehash: String,
|
||||
epoch_hex: Option<String>,
|
||||
day_hex: Option<String>,
|
||||
/// Class v5: the window's state stream file (`--state <file>`, the IGSD1 format of `igneum_pow::state`), whose
|
||||
/// leaves every item of the dataset is keyed by.
|
||||
state: Option<String>,
|
||||
class: LoadClass,
|
||||
/// Days since genesis for the cache growth rule of a class with `growth` (0: the genesis cache).
|
||||
days: u64,
|
||||
|
|
@ -101,7 +104,8 @@ fn usage() -> ! {
|
|||
\x20 --class C load class: v2 (default), mx4, mx8 (class v3: mixer x8, cache growth), dr<len> (Counter ASIC 3.0 item 2: the per-day derivation program, dr736 = the x8-equivalent), w4, w16, w64, w64x4, p4,p16,p64[xN], <class>m<mult>[g]\n\
|
||||
\x20 also: w4, w16, w64, w64x4, p4,p16,p64[xN], <class>m<mult>[g], <class>+sh<S>x<R> (latency-shadow block of S ALU instructions x R passes per iteration, Counter ASIC 3.0 item 8)\n\
|
||||
\x20 --days N days since genesis for the cache growth rule of a class with it (default 0: the 2^26-word cache)\n\
|
||||
\x20 --program-class v2|v3|v4 the program class of the seam (v3 = generator 3 on V3_CLASS, v4 = generator 4 on V4_CLASS = mx8+sh256x27, the chain's own derivation; --era-hex records the era seed)\n\
|
||||
\x20 --program-class v2|v3|v4|v5 the program class of the seam (v3 = generator 3 on V3_CLASS, v4 = generator 4 on V4_CLASS = mx8+sh256x27, v5 = generator 5 on V5_CLASS = mx8+sh256x27+state, the chain's own derivation; --era-hex records the era seed)\n\
|
||||
\x20 --state <file> class v5 (or any --class ...+state): the window's state stream (IGSD1 file, igneum-day-stream --out), whose leaves key every item\n\
|
||||
\x20 --shadow-reps N class v4 at a rung of the latency ladder: the shadow block's pass count (0 = the class's own 27; docs/design/latency-ladder.md), with --program-class v4\n\
|
||||
\x20 --era E era layout over --class: igneum-era-test/<n> or <n>:<64 hex> (the 32-byte era seed E_n)\n\
|
||||
\x20 --era-widths 4[,16,64] the width set the era draws from, in bytes (default 4: pinned; more lets the era draw it)"
|
||||
|
|
@ -116,6 +120,7 @@ fn parse() -> Args {
|
|||
day: "2026-10-03".into(),
|
||||
out: None,
|
||||
closed_form: false,
|
||||
state: None,
|
||||
dataset_log2: DEFAULT_DATASET_LOG2,
|
||||
warps: 20,
|
||||
nonce: 0,
|
||||
|
|
@ -150,6 +155,7 @@ fn parse() -> Args {
|
|||
"--class" => a.class = LoadClass::parse(&val()).unwrap_or_else(|| usage()),
|
||||
"--days" => a.days = val().parse().unwrap_or_else(|_| usage()),
|
||||
"--program-class" => a.program_class = Some(ProgramClass::parse(&val()).unwrap_or_else(|| usage())),
|
||||
"--state" => a.state = Some(val()),
|
||||
"--era-hex" => a.era_hex = Some(val()),
|
||||
"--shadow-reps" => a.shadow_reps = val().parse().unwrap_or_else(|_| usage()),
|
||||
"--era" => a.era = Some(parse_era(&val()).unwrap_or_else(|| usage())),
|
||||
|
|
@ -216,6 +222,33 @@ fn main() {
|
|||
fn epoch_of(a: &Args, mode: DatasetMode) -> (Epoch, String) {
|
||||
let (mut e, label) = epoch_of_class(a, mode);
|
||||
stamp_era(&mut e, a);
|
||||
// class v5: the leaves of --state, built for the dataset's size; a state class without --state is refused here
|
||||
// rather than at the first derivation
|
||||
if e.program.class.state {
|
||||
let Some(path) = &a.state else {
|
||||
eprintln!("class {} keys every item by the window's state: give --state <stream file> (igneum-day-stream --out)", e.program.class.name());
|
||||
std::process::exit(2);
|
||||
};
|
||||
let stream = igneum_pow::state::StateStream::read_file(std::path::Path::new(path)).unwrap_or_else(|err| {
|
||||
eprintln!("{err}");
|
||||
std::process::exit(2)
|
||||
});
|
||||
let leaves = igneum_pow::state::StateLeaves::from_stream(&stream, e.dataset.log2_words);
|
||||
eprintln!(
|
||||
"state stream {}: chain block {} {}, root {}, {} records, {} leaves{}",
|
||||
path,
|
||||
stream.number,
|
||||
igneum_pow::emit::hex_bytes(&stream.block),
|
||||
igneum_pow::emit::hex_bytes(&stream.root),
|
||||
stream.records.len(),
|
||||
leaves.n(),
|
||||
if leaves.sampled { " (sampled)" } else { "" }
|
||||
);
|
||||
e.dataset = e.dataset.with_leaves(std::sync::Arc::new(leaves));
|
||||
} else if a.state.is_some() {
|
||||
eprintln!("--state given for a class without state leaves ({}); use --program-class v5 or --class <class>+state", e.program.class.name());
|
||||
std::process::exit(2);
|
||||
}
|
||||
(e, label)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
use crate::derive::{run_round, DeriveProgram, SoaState, DERIVE_REGS, SOA_LANES};
|
||||
use crate::generator::LoadClass;
|
||||
use crate::seed::{day_key, fnv1a64_words, SplitMix64};
|
||||
use crate::state::StateLeaves;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub const CACHE_LOG2_WORDS: usize = 26;
|
||||
pub const CACHE_SEGMENT_LOG2_LINES: usize = 6;
|
||||
|
|
@ -50,11 +52,14 @@ pub struct Shape {
|
|||
/// program, which replaces the `mixer_mult` applications of `M_r` in every mixer slot when non-zero. 0 for
|
||||
/// version 2 and class v3 (the fixed mixer).
|
||||
pub derive_len: u32,
|
||||
/// Class v5 (`docs/design/class-v5-stored-state.md`, 7 October 2026): the item derivation XORs the window's state
|
||||
/// leaf `leaf(t)` into the 16 initial words before the first mixer (`crate::state`). `false` for every other class.
|
||||
pub state: bool,
|
||||
}
|
||||
|
||||
impl Shape {
|
||||
/// Version 2: one mixer application per round, a 2^26-word cache.
|
||||
pub const V2: Shape = Shape { mixer_mult: 1, cache_log2_words: CACHE_LOG2_WORDS as u32, derive_len: 0 };
|
||||
pub const V2: Shape = Shape { mixer_mult: 1, cache_log2_words: CACHE_LOG2_WORDS as u32, derive_len: 0, state: false };
|
||||
|
||||
/// The shape of a load class on day 0 of the chain (and on every day for a class without the growth rule).
|
||||
pub fn for_class(class: &LoadClass) -> Shape {
|
||||
|
|
@ -68,6 +73,7 @@ impl Shape {
|
|||
mixer_mult: class.mixer_mult(),
|
||||
cache_log2_words: if class.growth { cache_log2_words(days_since_genesis) } else { CACHE_LOG2_WORDS as u32 },
|
||||
derive_len: class.derive_len as u32,
|
||||
state: class.state,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -200,6 +206,46 @@ pub struct MixParams {
|
|||
pub shape: Shape,
|
||||
/// The per-day derivation program when `shape.derive_len != 0`, else `None`.
|
||||
pub derive: Option<DeriveProgram>,
|
||||
/// Class v5: how many mixer blocks the AP-F4-1 rule redrew before this one (0 on every other class, and on most days).
|
||||
pub redraws: u32,
|
||||
}
|
||||
|
||||
/// Class v5's mixer-draw rule (AP-F4-1): the NAF sum of the 16 multipliers at least this.
|
||||
pub const MIXER_NAF_SUM_MIN: u32 = 163;
|
||||
/// Class v5's mixer-draw rule: every multiplier's NAF weight at least this.
|
||||
pub const MIXER_NAF_WORD_MIN: u32 = 4;
|
||||
/// Class v5's mixer-draw rule: at least this many distinct rotation amounts among the eight.
|
||||
pub const MIXER_DISTINCT_ROT_MIN: usize = 4;
|
||||
/// Class v5's mixer-draw rule: redraws before the last block stands as drawn (never reached at 6.1e-4 per try).
|
||||
pub const MIXER_REDRAW_CAP: u32 = 64;
|
||||
|
||||
/// The non-adjacent-form weight of a 32-bit word: the number of non-zero digits of its NAF, the adders a
|
||||
/// shift-and-add multiplier by that constant needs (the M1 metric of the weak-day census).
|
||||
pub fn naf_weight(mut x: u64) -> u32 {
|
||||
let mut w = 0;
|
||||
while x != 0 {
|
||||
if x & 1 == 1 {
|
||||
w += 1;
|
||||
// the digit is +1 or -1: take x to the nearest multiple of 4
|
||||
if x & 3 == 3 {
|
||||
x += 1;
|
||||
} else {
|
||||
x -= 1;
|
||||
}
|
||||
}
|
||||
x >>= 1;
|
||||
}
|
||||
w
|
||||
}
|
||||
|
||||
/// Whether a mixer block passes class v5's draw rule (AP-F4-1).
|
||||
pub fn mixer_block_admissible(rot: &[u32; 8], mul: &[u32; 16]) -> bool {
|
||||
let sum: u32 = mul.iter().map(|&m| naf_weight(m as u64)).sum();
|
||||
let words = mul.iter().all(|&m| naf_weight(m as u64) >= MIXER_NAF_WORD_MIN);
|
||||
let mut distinct = rot.to_vec();
|
||||
distinct.sort_unstable();
|
||||
distinct.dedup();
|
||||
sum >= MIXER_NAF_SUM_MIN && words && distinct.len() >= MIXER_DISTINCT_ROT_MIN
|
||||
}
|
||||
|
||||
impl MixParams {
|
||||
|
|
@ -221,8 +267,28 @@ impl MixParams {
|
|||
for c in rc.iter_mut() {
|
||||
*c = rng.next() as u32;
|
||||
}
|
||||
let mut redraws = 0u32;
|
||||
if shape.state {
|
||||
// Class v5 (docs/design/class-v5-stored-state.md section 11, AP-F4-1, the attack-pass lane's weak-day census):
|
||||
// a mixer block whose multipliers are cheap on an adder datapath (NAF sum under 163, a word under NAF weight 4)
|
||||
// or whose rotations repeat (under 4 distinct amounts) is redrawn from the next stream values, so no day is a
|
||||
// weak day for a per-day LUT-recompute FPGA (the worst calendar day of the census, chain day 29,337, was 1.121x).
|
||||
// About 6.1e-4 of days redraw. The derive program's draws (none under v5) come after, as before.
|
||||
while !mixer_block_admissible(&rot, &mul) && redraws < MIXER_REDRAW_CAP {
|
||||
for r in rot.iter_mut() {
|
||||
*r = 1 + rng.below(31) as u32;
|
||||
}
|
||||
for m in mul.iter_mut() {
|
||||
*m = (rng.next() as u32) | 1;
|
||||
}
|
||||
for c in rc.iter_mut() {
|
||||
*c = rng.next() as u32;
|
||||
}
|
||||
redraws += 1;
|
||||
}
|
||||
}
|
||||
let derive = if shape.is_derived() { Some(DeriveProgram::draw(&mut rng, shape.derive_len)) } else { None };
|
||||
Self { key, rot, mul, rc, shape, derive }
|
||||
Self { key, rot, mul, rc, shape, derive, redraws }
|
||||
}
|
||||
/// Parameters for a day string: the key is `seed_words("day/" + day)`.
|
||||
pub fn for_day(day: &str) -> Self {
|
||||
|
|
@ -374,7 +440,7 @@ impl Cache {
|
|||
/// are the smaller cache's segments word for word.
|
||||
pub fn fill_log2(key: [u32; 8], log2_words: u32) -> Cache {
|
||||
assert!((10..=30).contains(&log2_words), "cache log2 words must be in 10..=30");
|
||||
let shape = Shape { mixer_mult: 1, cache_log2_words: log2_words, derive_len: 0 };
|
||||
let shape = Shape { mixer_mult: 1, cache_log2_words: log2_words, derive_len: 0, state: false };
|
||||
let mut words = vec![0u32; shape.cache_words()];
|
||||
for seg in 0..shape.cache_segments() {
|
||||
Self::fill_segment(&mut words, seg, &key);
|
||||
|
|
@ -505,8 +571,21 @@ impl HotTable {
|
|||
/// (`mp.shape.mixer_mult`) round `r` applies `M` with keys `round_key(r m + j)` for `j = 0 .. m - 1` before its
|
||||
/// one cache read; the final mixer applies `M` with keys `round_key(8 m + j)`. `m = 1` is version 2.
|
||||
pub fn derive_items(ts: &[u32], mp: &MixParams, cache: &Cache, out: &mut [[u32; 16]]) {
|
||||
derive_items_leaves(ts, mp, cache, None, out)
|
||||
}
|
||||
|
||||
/// [`derive_items`] with the state leaves of class v5 (`docs/design/class-v5-stored-state.md` section 2): under a
|
||||
/// shape with `state`, `leaf(t)` is XORed into the 16 initial words of item `t` before the first mixer, and the leaves
|
||||
/// are required; under any other shape they must be absent. A mismatch is a programming error and panics: a dataset
|
||||
/// built without the state it needs would be wrong on every item, which is the class's point.
|
||||
pub fn derive_items_leaves(ts: &[u32], mp: &MixParams, cache: &Cache, leaves: Option<&StateLeaves>, out: &mut [[u32; 16]]) {
|
||||
match (mp.shape.state, leaves) {
|
||||
(true, None) => panic!("class v5 item derivation needs the window's state leaves and was given none"),
|
||||
(false, Some(_)) => panic!("state leaves given to an item derivation whose shape has no state"),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(prog) = &mp.derive {
|
||||
return derive_items_program(ts, mp, prog, cache, out);
|
||||
return derive_items_program(ts, mp, prog, cache, leaves, out);
|
||||
}
|
||||
// The item loop lives in its own function, one instance per cache size the growth rule can reach with the line
|
||||
// mask a constant, never inlined into the callers. Inlined into `MemhardCpu::fetch` it ran at 1.33 ms per unit
|
||||
|
|
@ -515,19 +594,19 @@ pub fn derive_items(ts: &[u32], mp: &MixParams, cache: &Cache, out: &mut [[u32;
|
|||
// constant with the loop still inlined, all stayed at 1.33; the out-of-line instances read 0.60 to 0.62). Any
|
||||
// other cache size (tests) takes the instance with the run-time mask.
|
||||
match cache.log2_words {
|
||||
26 => derive_items_mask::<{ (1u32 << 22) - 1 }>(ts, mp, cache, out),
|
||||
27 => derive_items_mask::<{ (1u32 << 23) - 1 }>(ts, mp, cache, out),
|
||||
28 => derive_items_mask::<{ (1u32 << 24) - 1 }>(ts, mp, cache, out),
|
||||
29 => derive_items_mask::<{ (1u32 << 25) - 1 }>(ts, mp, cache, out),
|
||||
30 => derive_items_mask::<{ (1u32 << 26) - 1 }>(ts, mp, cache, out),
|
||||
_ => derive_items_mask::<0>(ts, mp, cache, out),
|
||||
26 => derive_items_mask::<{ (1u32 << 22) - 1 }>(ts, mp, cache, leaves, out),
|
||||
27 => derive_items_mask::<{ (1u32 << 23) - 1 }>(ts, mp, cache, leaves, out),
|
||||
28 => derive_items_mask::<{ (1u32 << 24) - 1 }>(ts, mp, cache, leaves, out),
|
||||
29 => derive_items_mask::<{ (1u32 << 25) - 1 }>(ts, mp, cache, leaves, out),
|
||||
30 => derive_items_mask::<{ (1u32 << 26) - 1 }>(ts, mp, cache, leaves, out),
|
||||
_ => derive_items_mask::<0>(ts, mp, cache, leaves, out),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`derive_items`] with the cache line mask as a constant (`LINE_MASK = 0`: the cache's own run-time mask). Kept
|
||||
/// out of line on purpose (see [`derive_items`]).
|
||||
#[inline(never)]
|
||||
fn derive_items_mask<const LINE_MASK: u32>(ts: &[u32], mp: &MixParams, cache: &Cache, out: &mut [[u32; 16]]) {
|
||||
fn derive_items_mask<const LINE_MASK: u32>(ts: &[u32], mp: &MixParams, cache: &Cache, leaves: Option<&StateLeaves>, out: &mut [[u32; 16]]) {
|
||||
let n = ts.len();
|
||||
debug_assert!(out.len() >= n);
|
||||
debug_assert!(LINE_MASK == 0 || LINE_MASK == cache.line_mask);
|
||||
|
|
@ -539,6 +618,13 @@ fn derive_items_mask<const LINE_MASK: u32>(ts: &[u32], mp: &MixParams, cache: &C
|
|||
for i in 0..8 {
|
||||
s[8 + i] = t.wrapping_mul(mp.mul[i]).wrapping_add(mp.rc[i]);
|
||||
}
|
||||
if let Some(l) = leaves {
|
||||
// class v5: the window's state leaf of item t, before the first mixer
|
||||
let leaf = l.leaf(t);
|
||||
for i in 0..16 {
|
||||
s[i] ^= leaf[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
for r in 0..ITEM_ROUNDS {
|
||||
for j in 0..m {
|
||||
|
|
@ -570,7 +656,7 @@ fn derive_items_mask<const LINE_MASK: u32>(ts: &[u32], mp: &MixParams, cache: &C
|
|||
/// cache reads of the batch are issued together, as in the fixed-mixer loop, so the 8 dependent misses of
|
||||
/// independent items overlap in the memory system.
|
||||
#[inline(never)]
|
||||
pub fn derive_items_program(ts: &[u32], mp: &MixParams, prog: &DeriveProgram, cache: &Cache, out: &mut [[u32; 16]]) {
|
||||
pub fn derive_items_program(ts: &[u32], mp: &MixParams, prog: &DeriveProgram, cache: &Cache, leaves: Option<&StateLeaves>, out: &mut [[u32; 16]]) {
|
||||
let n = ts.len();
|
||||
debug_assert!(out.len() >= n && n <= SOA_LANES);
|
||||
assert_eq!(prog.rounds.len(), ITEM_ROUNDS + 1);
|
||||
|
|
@ -581,6 +667,12 @@ pub fn derive_items_program(ts: &[u32], mp: &MixParams, prog: &DeriveProgram, ca
|
|||
st[i][k] = mp.key[i];
|
||||
st[8 + i][k] = t.wrapping_mul(mp.mul[i]).wrapping_add(mp.rc[i]);
|
||||
}
|
||||
if let Some(l) = leaves {
|
||||
let leaf = l.leaf(t);
|
||||
for i in 0..16 {
|
||||
st[i][k] ^= leaf[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
let mask = cache.line_mask();
|
||||
for r in 0..ITEM_ROUNDS {
|
||||
|
|
@ -602,15 +694,22 @@ pub fn derive_items_program(ts: &[u32], mp: &MixParams, prog: &DeriveProgram, ca
|
|||
|
||||
/// One dataset item, 16 words.
|
||||
pub fn derive_item(t: u32, mp: &MixParams, cache: &Cache) -> [u32; 16] {
|
||||
derive_item_leaves(t, mp, cache, None)
|
||||
}
|
||||
|
||||
/// [`derive_item`] with the state leaves of class v5.
|
||||
pub fn derive_item_leaves(t: u32, mp: &MixParams, cache: &Cache, leaves: Option<&StateLeaves>) -> [u32; 16] {
|
||||
let mut out = [[0u32; 16]; 1];
|
||||
derive_items(&[t], mp, cache, &mut out);
|
||||
derive_items_leaves(&[t], mp, cache, leaves, &mut out);
|
||||
out[0]
|
||||
}
|
||||
|
||||
/// The CPU verifier's view of the memory-hard dataset: the mixer parameters (with the shape) and the cache.
|
||||
/// The CPU verifier's view of the memory-hard dataset: the mixer parameters (with the shape), the cache (shared, so
|
||||
/// a class v5 window refresh keeps the day's 256 MiB and swaps the leaves) and, under class v5, the window's leaves.
|
||||
pub struct MemhardCpu {
|
||||
pub params: MixParams,
|
||||
pub cache: Cache,
|
||||
pub cache: Arc<Cache>,
|
||||
pub leaves: Option<Arc<StateLeaves>>,
|
||||
}
|
||||
|
||||
/// Largest batch `MemhardCpu::fetch` accepts (two warps).
|
||||
|
|
@ -622,7 +721,7 @@ impl MemhardCpu {
|
|||
Self::with_shape(key, Shape::V2)
|
||||
}
|
||||
pub fn with_shape(key: [u32; 8], shape: Shape) -> Self {
|
||||
Self { params: MixParams::with_shape(key, shape), cache: Cache::fill_log2(key, shape.cache_log2_words) }
|
||||
Self { params: MixParams::with_shape(key, shape), cache: Arc::new(Cache::fill_log2(key, shape.cache_log2_words)), leaves: None }
|
||||
}
|
||||
pub fn for_day(day: &str) -> Self {
|
||||
Self::new(day_key(day))
|
||||
|
|
@ -630,6 +729,17 @@ impl MemhardCpu {
|
|||
pub fn shape(&self) -> Shape {
|
||||
self.params.shape
|
||||
}
|
||||
/// This view with the window's state leaves (class v5). The shape must have `state`.
|
||||
pub fn with_leaves(mut self, leaves: Arc<StateLeaves>) -> Self {
|
||||
assert!(self.params.shape.state, "state leaves on a shape without state");
|
||||
self.leaves = Some(leaves);
|
||||
self
|
||||
}
|
||||
/// A view of the same day (the same cache, shared) with other leaves: the class v5 window refresh.
|
||||
pub fn refreshed(&self, leaves: Arc<StateLeaves>) -> Self {
|
||||
assert!(self.params.shape.state, "state leaves on a shape without state");
|
||||
Self { params: self.params.clone(), cache: self.cache.clone(), leaves: Some(leaves) }
|
||||
}
|
||||
/// `dataset[w] = item(w >> 4)[w & 15]` (the linear layout).
|
||||
pub fn word(&self, w: u32) -> u32 {
|
||||
self.word_at(Layout::LINEAR, w)
|
||||
|
|
@ -638,7 +748,7 @@ impl MemhardCpu {
|
|||
/// day's, so one cache serves every era of a day).
|
||||
pub fn word_at(&self, layout: Layout, w: u32) -> u32 {
|
||||
let (t, j) = layout.split(w);
|
||||
derive_item(t, &self.params, &self.cache)[j as usize]
|
||||
derive_item_leaves(t, &self.params, &self.cache, self.leaves.as_deref())[j as usize]
|
||||
}
|
||||
/// `out[k] = dataset[idx[k]]` for every k, `idx.len() <= FETCH_MAX`. Equal items are derived once.
|
||||
/// Returns the number of distinct items derived.
|
||||
|
|
@ -664,7 +774,7 @@ impl MemhardCpu {
|
|||
slot[k] = j as u8;
|
||||
}
|
||||
let mut items = [[0u32; 16]; FETCH_MAX];
|
||||
derive_items(&uniq[..u], &self.params, &self.cache, &mut items);
|
||||
derive_items_leaves(&uniq[..u], &self.params, &self.cache, self.leaves.as_deref(), &mut items);
|
||||
for k in 0..n {
|
||||
out[k] = items[slot[k] as usize][word[k] as usize];
|
||||
}
|
||||
|
|
@ -695,7 +805,7 @@ impl MemhardCpu {
|
|||
slot[k] = j as u8;
|
||||
}
|
||||
let mut items = [[0u32; 16]; FETCH_MAX];
|
||||
derive_items(&uniq[..u], &self.params, &self.cache, &mut items);
|
||||
derive_items_leaves(&uniq[..u], &self.params, &self.cache, self.leaves.as_deref(), &mut items);
|
||||
for k in 0..n {
|
||||
let o = word[k] as usize;
|
||||
out[k][..width].copy_from_slice(&items[slot[k] as usize][o..o + width]);
|
||||
|
|
@ -708,6 +818,50 @@ impl MemhardCpu {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Class v5's mixer-draw rule (AP-F4-1), the known-failed case first: a block of cheap multipliers (NAF sum under
|
||||
/// 163) or repeated rotations is inadmissible; a scan of day keys finds days the rule redraws (the census's 6.1e-4),
|
||||
/// every v5 block passes after the draw, and the v4 constants of the same keys never move.
|
||||
#[test]
|
||||
fn class_v5_mixer_draw_rule() {
|
||||
assert_eq!(naf_weight(0), 0);
|
||||
assert_eq!(naf_weight(1), 1);
|
||||
assert_eq!(naf_weight(3), 2, "11 = 100 - 1");
|
||||
assert_eq!(naf_weight(7), 2, "111 = 1000 - 1");
|
||||
assert_eq!(naf_weight(0xffff_ffff), 2);
|
||||
assert_eq!(naf_weight(0b1010_1010), 4);
|
||||
let good_rot = [1u32, 5, 9, 13, 17, 21, 25, 29];
|
||||
let cheap = [0x8000_0001u32; 16];
|
||||
assert!(!mixer_block_admissible(&good_rot, &cheap), "the known-failed case: 16 two-adder multipliers");
|
||||
let dense = [0xaaaa_aaabu32; 16];
|
||||
assert!(mixer_block_admissible(&good_rot, &dense));
|
||||
assert!(!mixer_block_admissible(&[7u32; 8], &dense), "one rotation amount");
|
||||
assert!(!mixer_block_admissible(&[1u32, 2, 3, 3, 3, 3, 3, 3], &dense), "three distinct amounts");
|
||||
let v5 = Shape { mixer_mult: 8, cache_log2_words: 26, derive_len: 0, state: true };
|
||||
let v4 = Shape { mixer_mult: 8, cache_log2_words: 26, derive_len: 0, state: false };
|
||||
let mut redrawn = 0;
|
||||
let mut scanned = 0;
|
||||
for d in 0..60_000u64 {
|
||||
let key = crate::seed::seed_words_from_bytes(&crate::bind::day_bytes(20_000 + d));
|
||||
let a = MixParams::with_shape(key, v5);
|
||||
assert!(mixer_block_admissible(&a.rot, &a.mul), "day {d}: a v5 block fails the rule after the draw");
|
||||
if a.redraws > 0 {
|
||||
redrawn += 1;
|
||||
let b = MixParams::with_shape(key, v4);
|
||||
assert_eq!(b.redraws, 0, "v4 never redraws");
|
||||
assert_ne!((a.rot, a.mul), (b.rot, b.mul), "day {d}: v5 redrew, v4 kept the block");
|
||||
assert!(!mixer_block_admissible(&b.rot, &b.mul), "day {d}: the v4 block was the inadmissible one");
|
||||
} else {
|
||||
let b = MixParams::with_shape(key, v4);
|
||||
assert_eq!((a.rot, a.mul, a.rc), (b.rot, b.mul, b.rc), "day {d}: an admissible day is byte for byte v4's");
|
||||
}
|
||||
scanned += 1;
|
||||
if redrawn >= 3 && scanned >= 2_000 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(redrawn >= 1, "no redraw in {scanned} days (the census says about 6.1e-4 per day)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_params_for_day() {
|
||||
// MEMHARD.md section 1.4 and the igneum-genesis-mh pack.
|
||||
|
|
@ -851,7 +1005,7 @@ mod tests {
|
|||
let v2 = Shape::for_class_day(&LoadClass::V2, 100_000);
|
||||
assert_eq!(v2, Shape::V2);
|
||||
let v3 = Shape::for_class_day(&LoadClass::MX4, 0);
|
||||
assert_eq!(v3, Shape { mixer_mult: 4, cache_log2_words: 26, derive_len: 0 });
|
||||
assert_eq!(v3, Shape { mixer_mult: 4, cache_log2_words: 26, derive_len: 0, state: false });
|
||||
assert_eq!(Shape::for_class_day(&LoadClass::MX4, 1_460).cache_log2_words, 27);
|
||||
assert_eq!(v3.mixers_per_item(), 36);
|
||||
assert_eq!(Shape::V2.mixers_per_item(), 9);
|
||||
|
|
@ -872,7 +1026,7 @@ mod tests {
|
|||
assert_eq!(small.segments(), 64);
|
||||
assert_eq!(small.line_mask(), 4095);
|
||||
for m in [1u32, 2, 4] {
|
||||
let mp = MixParams::with_shape(key, Shape { mixer_mult: m, cache_log2_words: 16, derive_len: 0 });
|
||||
let mp = MixParams::with_shape(key, Shape { mixer_mult: m, cache_log2_words: 16, derive_len: 0, state: false });
|
||||
for t in [0u32, 1, 12_345, u32::MAX] {
|
||||
let got = derive_item(t, &mp, &small);
|
||||
let mut s = [0u32; 16];
|
||||
|
|
@ -895,8 +1049,8 @@ mod tests {
|
|||
assert_eq!(got, s, "m {m} t {t}");
|
||||
}
|
||||
}
|
||||
let v2 = MixParams::with_shape(key, Shape { mixer_mult: 1, cache_log2_words: 16, derive_len: 0 });
|
||||
let v3 = MixParams::with_shape(key, Shape { mixer_mult: 4, cache_log2_words: 16, derive_len: 0 });
|
||||
let v2 = MixParams::with_shape(key, Shape { mixer_mult: 1, cache_log2_words: 16, derive_len: 0, state: false });
|
||||
let v3 = MixParams::with_shape(key, Shape { mixer_mult: 4, cache_log2_words: 16, derive_len: 0, state: false });
|
||||
assert_ne!(derive_item(0, &v2, &small), derive_item(0, &v3, &small));
|
||||
assert_eq!(round_key_mult(0, 0, 1), round_key(0));
|
||||
assert_eq!(round_key_mult(8, 0, 1), round_key(8));
|
||||
|
|
|
|||
287
igneum-pow/src/state.rs
Normal file
287
igneum-pow/src/state.rs
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
//! Class v5, proof of stored state and of following (`docs/design/class-v5-stored-state.md`, 7 October 2026): the
|
||||
//! leaves the item derivation XORs in (section 2 of the page), built from the canonical state stream of the
|
||||
//! window's reference block.
|
||||
//!
|
||||
//! `D[i] = Blake2b-512("igneum-sd1/" || root || i_le32 || record_i)` for the `n` records of the stream, and item
|
||||
//! `t` takes `leaf(t) = D[t mod n]`: every item is keyed by the state, so a hasher without it is wrong on every
|
||||
//! item (the known-failed case, the first test). When the stream has more records than the dataset has items, the
|
||||
//! records are ordered by `Blake2b-256("igneum-sd1-sample/" || root || record)` and the first `items` are taken, a
|
||||
//! sample nobody can choose without the whole state and the root.
|
||||
//!
|
||||
//! The stream file (`StateStream`): the plain format every side reads without a serialisation library, `IGSD1\0`,
|
||||
//! the chain block number (le64) and hash (32), the state root (32), the record count (le32), then each record as
|
||||
//! its length (le32) and bytes. The node's executor writes it (`igneum/exec/src/day_stream.rs`), the miner fetches
|
||||
//! it, the CLI's `--state` reads it, and a pack carries the leaves it yields as `leaves.bin`.
|
||||
|
||||
use crate::blake2b::{blake2b_256, blake2b_512};
|
||||
use crate::seed::fnv1a64_words;
|
||||
|
||||
pub const LEAF_TAG: &[u8] = b"igneum-sd1/";
|
||||
pub const SAMPLE_TAG: &[u8] = b"igneum-sd1-sample/";
|
||||
pub const STREAM_MAGIC: &[u8; 6] = b"IGSD1\0";
|
||||
|
||||
/// The canonical state stream at one chain block: what the executor serialises and what the leaves derive from.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct StateStream {
|
||||
pub number: u64,
|
||||
pub block: [u8; 32],
|
||||
pub root: [u8; 32],
|
||||
pub records: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl StateStream {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut b = Vec::with_capacity(6 + 8 + 32 + 32 + 4 + self.records.iter().map(|r| 4 + r.len()).sum::<usize>());
|
||||
b.extend_from_slice(STREAM_MAGIC);
|
||||
b.extend_from_slice(&self.number.to_le_bytes());
|
||||
b.extend_from_slice(&self.block);
|
||||
b.extend_from_slice(&self.root);
|
||||
b.extend_from_slice(&(self.records.len() as u32).to_le_bytes());
|
||||
for r in &self.records {
|
||||
b.extend_from_slice(&(r.len() as u32).to_le_bytes());
|
||||
b.extend_from_slice(r);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
pub fn decode(bytes: &[u8]) -> Result<StateStream, String> {
|
||||
if bytes.len() < 6 + 8 + 32 + 32 + 4 || &bytes[..6] != STREAM_MAGIC {
|
||||
return Err("not a state stream file (magic IGSD1)".into());
|
||||
}
|
||||
let mut at = 6;
|
||||
let number = u64::from_le_bytes(bytes[at..at + 8].try_into().unwrap());
|
||||
at += 8;
|
||||
let block: [u8; 32] = bytes[at..at + 32].try_into().unwrap();
|
||||
at += 32;
|
||||
let root: [u8; 32] = bytes[at..at + 32].try_into().unwrap();
|
||||
at += 32;
|
||||
let n = u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap()) as usize;
|
||||
at += 4;
|
||||
let mut records = Vec::with_capacity(n.min(1 << 20));
|
||||
for i in 0..n {
|
||||
if at + 4 > bytes.len() {
|
||||
return Err(format!("state stream truncated at record {i} of {n}"));
|
||||
}
|
||||
let len = u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap()) as usize;
|
||||
at += 4;
|
||||
if at + len > bytes.len() {
|
||||
return Err(format!("state stream truncated inside record {i} of {n}"));
|
||||
}
|
||||
records.push(bytes[at..at + len].to_vec());
|
||||
at += len;
|
||||
}
|
||||
if at != bytes.len() {
|
||||
return Err(format!("state stream has {} trailing bytes", bytes.len() - at));
|
||||
}
|
||||
Ok(StateStream { number, block, root, records })
|
||||
}
|
||||
|
||||
pub fn read_file(path: &std::path::Path) -> Result<StateStream, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?;
|
||||
Self::decode(&bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// `D[i]`: the 64-byte digest of record `i` under `root`, as 16 little-endian words.
|
||||
pub fn leaf_digest(root: &[u8; 32], i: u32, record: &[u8]) -> [u32; 16] {
|
||||
let d = blake2b_512(&[LEAF_TAG, root, &i.to_le_bytes(), record]);
|
||||
let mut w = [0u32; 16];
|
||||
for (k, x) in w.iter_mut().enumerate() {
|
||||
*x = u32::from_le_bytes(d[k * 4..k * 4 + 4].try_into().unwrap());
|
||||
}
|
||||
w
|
||||
}
|
||||
|
||||
/// The sample order key of a record under `root`.
|
||||
pub fn sample_key(root: &[u8; 32], record: &[u8]) -> [u8; 32] {
|
||||
blake2b_256(&[SAMPLE_TAG, root, record])
|
||||
}
|
||||
|
||||
/// The leaves of one window (or day) of class v5: `n` digests of 64 bytes, `leaf(t) = D[t mod n]`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct StateLeaves {
|
||||
pub root: [u8; 32],
|
||||
pub block: [u8; 32],
|
||||
pub number: u64,
|
||||
/// Records in the stream before any sample.
|
||||
pub records_total: u64,
|
||||
/// Whether the stream had more records than the dataset has items (the sample rule applied).
|
||||
pub sampled: bool,
|
||||
leaves: Vec<[u32; 16]>,
|
||||
}
|
||||
|
||||
impl StateLeaves {
|
||||
/// The items a dataset of `2^log2_words` words has: `2^(log2_words - 4)`.
|
||||
pub fn items_of(log2_words: u32) -> u64 {
|
||||
1u64 << log2_words.saturating_sub(4)
|
||||
}
|
||||
|
||||
/// The leaves of `records` (canonical order) under `root` for a dataset of `2^log2_words` words. An empty stream
|
||||
/// yields one leaf, the digest of the empty record, so `n` is never 0.
|
||||
pub fn build(root: [u8; 32], block: [u8; 32], number: u64, records: &[Vec<u8>], log2_words: u32) -> StateLeaves {
|
||||
let items = Self::items_of(log2_words);
|
||||
let records_total = records.len() as u64;
|
||||
let empty: Vec<Vec<u8>> = vec![Vec::new()];
|
||||
let records = if records.is_empty() { &empty[..] } else { records };
|
||||
let sampled = records.len() as u64 > items;
|
||||
let chosen: Vec<&Vec<u8>> = if sampled {
|
||||
let mut keyed: Vec<([u8; 32], &Vec<u8>)> = records.iter().map(|r| (sample_key(&root, r), r)).collect();
|
||||
keyed.sort_unstable_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1)));
|
||||
keyed.into_iter().take(items as usize).map(|(_, r)| r).collect()
|
||||
} else {
|
||||
records.iter().collect()
|
||||
};
|
||||
let leaves = chosen.iter().enumerate().map(|(i, r)| leaf_digest(&root, i as u32, r)).collect();
|
||||
StateLeaves { root, block, number, records_total, sampled, leaves }
|
||||
}
|
||||
|
||||
pub fn from_stream(s: &StateStream, log2_words: u32) -> StateLeaves {
|
||||
Self::build(s.root, s.block, s.number, &s.records, log2_words)
|
||||
}
|
||||
|
||||
/// Leaves from the raw words of a `leaves.bin` (16 words per leaf), for a worker or a test that holds no stream.
|
||||
pub fn from_words(root: [u8; 32], block: [u8; 32], number: u64, words: &[u32]) -> StateLeaves {
|
||||
assert!(!words.is_empty() && words.len() % 16 == 0, "leaves are 16 words each");
|
||||
let leaves = words.chunks_exact(16).map(|c| c.try_into().unwrap()).collect::<Vec<[u32; 16]>>();
|
||||
StateLeaves { root, block, number, records_total: leaves.len() as u64, sampled: false, leaves }
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn n(&self) -> u32 {
|
||||
self.leaves.len() as u32
|
||||
}
|
||||
|
||||
/// `leaf(t) = D[t mod n]`.
|
||||
#[inline(always)]
|
||||
pub fn leaf(&self, t: u32) -> &[u32; 16] {
|
||||
&self.leaves[(t % self.n()) as usize]
|
||||
}
|
||||
|
||||
pub fn leaves(&self) -> &[[u32; 16]] {
|
||||
&self.leaves
|
||||
}
|
||||
|
||||
/// The flat words of `leaves.bin`.
|
||||
pub fn words(&self) -> Vec<u32> {
|
||||
self.leaves.iter().flat_map(|l| l.iter().copied()).collect()
|
||||
}
|
||||
|
||||
/// The bytes of `leaves.bin` (little-endian words).
|
||||
pub fn bytes(&self) -> Vec<u8> {
|
||||
self.words().iter().flat_map(|w| w.to_le_bytes()).collect()
|
||||
}
|
||||
|
||||
/// FNV-1a 64 over the leaves as little-endian bytes (the pack's `IGNEUM_STATE_LEAVES_FNV64`).
|
||||
pub fn fnv1a64(&self) -> u64 {
|
||||
fnv1a64_words(&self.words())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::generator::{generate_class, V5_CLASS};
|
||||
use crate::memhard::{derive_item_leaves, Cache, MixParams, Shape};
|
||||
use crate::seed::day_key;
|
||||
use crate::verify::{hash_warp, DatasetMode, DatasetSource};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn records(n: usize, salt: u8) -> Vec<Vec<u8>> {
|
||||
(0..n).map(|i| vec![salt, i as u8, (i >> 8) as u8, 7]).collect()
|
||||
}
|
||||
|
||||
fn leaves(root: u8, n: usize, log2_words: u32) -> Arc<StateLeaves> {
|
||||
Arc::new(StateLeaves::build([root; 32], [0x22; 32], 5, &records(n, root), log2_words))
|
||||
}
|
||||
|
||||
/// The known-failed case, first: a hasher without the state (no leaves, the leaves of another root, the leaves
|
||||
/// of a stream one record short, the previous window's leaves) is wrong on every item and every lane.
|
||||
#[test]
|
||||
fn a_stateless_hasher_is_wrong_on_every_item() {
|
||||
let key = day_key("2026-10-03");
|
||||
let shape = Shape { mixer_mult: 8, cache_log2_words: 16, derive_len: 0, state: true };
|
||||
let cache = Arc::new(Cache::fill_log2(key, 16));
|
||||
let mp = MixParams::with_shape(key, shape);
|
||||
let good = leaves(0x11, 93, 20);
|
||||
let other_root = leaves(0x12, 93, 20);
|
||||
let one_short = Arc::new(StateLeaves::build([0x13; 32], [0x22; 32], 5, &records(92, 0x11), 20)); // a record short means another root
|
||||
let previous_window = leaves(0x10, 93, 20);
|
||||
for (name, bad) in [("another root", other_root.clone()), ("one record short", one_short.clone()), ("the previous window", previous_window.clone())] {
|
||||
let equal = (0..64u32).filter(|&t| derive_item_leaves(t * 7919, &mp, &cache, Some(&good)) == derive_item_leaves(t * 7919, &mp, &cache, Some(&bad))).count();
|
||||
assert_eq!(equal, 0, "{name}: {equal} of 64 items equal");
|
||||
}
|
||||
let stateless = Shape { state: false, ..shape };
|
||||
let mp_stateless = MixParams::with_shape(key, stateless);
|
||||
let equal = (0..64u32).filter(|&t| derive_item_leaves(t * 7919, &mp, &cache, Some(&good)) == derive_item_leaves(t * 7919, &mp_stateless, &cache, None)).count();
|
||||
assert_eq!(equal, 0, "no leaves at all: {equal} of 64 items equal");
|
||||
// the warp: a class v5 program over a small dataset, the same program and cache, other leaves
|
||||
let program = generate_class("igneum-genesis", V5_CLASS);
|
||||
let ds = DatasetSource::new_shape("2026-10-03", DatasetMode::MemoryHard, 20, shape).with_leaves(good.clone());
|
||||
let ds_other = DatasetSource::new_shape("2026-10-03", DatasetMode::MemoryHard, 20, shape).with_leaves(previous_window.clone());
|
||||
let a = hash_warp(&program, 0, &ds);
|
||||
let b = hash_warp(&program, 0, &ds_other);
|
||||
assert_eq!(a.iter().zip(b.iter()).filter(|(x, y)| x == y).count(), 0, "0 of 32 lanes agree");
|
||||
assert_eq!(hash_warp(&program, 0, &ds), a, "the same leaves hash the same");
|
||||
}
|
||||
|
||||
/// Every item takes a leaf: `leaf(t) = D[t mod n]`, so items `t` and `t + n` share a leaf and still differ.
|
||||
#[test]
|
||||
fn every_item_is_keyed_and_the_leaf_wraps() {
|
||||
let l = leaves(0x11, 93, 28);
|
||||
assert_eq!(l.n(), 93);
|
||||
assert!(!l.sampled);
|
||||
assert_eq!(l.records_total, 93);
|
||||
for t in [0u32, 1, 92, 93, 94, 1_000_000, u32::MAX] {
|
||||
assert_eq!(l.leaf(t), l.leaf(t % 93));
|
||||
assert_eq!(*l.leaf(t), leaf_digest(&[0x11; 32], t % 93, &records(93, 0x11)[(t % 93) as usize]));
|
||||
}
|
||||
let key = day_key("2026-10-03");
|
||||
let shape = Shape { mixer_mult: 8, cache_log2_words: 16, derive_len: 0, state: true };
|
||||
let cache = Cache::fill_log2(key, 16);
|
||||
let mp = MixParams::with_shape(key, shape);
|
||||
assert_ne!(derive_item_leaves(5, &mp, &cache, Some(&l)), derive_item_leaves(5 + 93, &mp, &cache, Some(&l)));
|
||||
// an empty stream yields one leaf (the digest of the empty record), never a division by zero
|
||||
let empty = StateLeaves::build([0x11; 32], [0; 32], 0, &[], 28);
|
||||
assert_eq!(empty.n(), 1);
|
||||
assert_eq!(empty.records_total, 0);
|
||||
assert_eq!(*empty.leaf(12_345), leaf_digest(&[0x11; 32], 0, &[]));
|
||||
}
|
||||
|
||||
/// Above the dataset size the records are sampled in the keyed order: a different root picks a different set,
|
||||
/// and the set cannot be the first `items` records of the stream.
|
||||
#[test]
|
||||
fn the_sample_above_the_dataset_size_is_keyed_by_the_root() {
|
||||
let recs = records(40, 0x33);
|
||||
let a = StateLeaves::build([0x11; 32], [0; 32], 0, &recs, 8);
|
||||
let b = StateLeaves::build([0x12; 32], [0; 32], 0, &recs, 8);
|
||||
assert_eq!(StateLeaves::items_of(8), 16);
|
||||
assert_eq!((a.n(), a.sampled, a.records_total), (16, true, 40));
|
||||
assert_ne!(a.leaves(), b.leaves(), "another root, another sample");
|
||||
// the positional first 16 are not the sample (with overwhelming probability for 40 choose 16)
|
||||
let positional = StateLeaves::build([0x11; 32], [0; 32], 0, &recs[..16], 8);
|
||||
assert_ne!(a.leaves(), positional.leaves());
|
||||
// the same inputs sample the same
|
||||
assert_eq!(StateLeaves::build([0x11; 32], [0; 32], 0, &recs, 8), a);
|
||||
// at the dataset size exactly, no sample
|
||||
let c = StateLeaves::build([0x11; 32], [0; 32], 0, &recs[..16], 8);
|
||||
assert!(!c.sampled && c.n() == 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_file_round_trip_and_refusals() {
|
||||
let s = StateStream { number: 159_357, block: [0xaf; 32], root: [0x1c; 32], records: records(93, 1) };
|
||||
let bytes = s.encode();
|
||||
assert_eq!(&bytes[..6], STREAM_MAGIC);
|
||||
assert_eq!(StateStream::decode(&bytes).unwrap(), s);
|
||||
assert!(StateStream::decode(&bytes[..bytes.len() - 1]).is_err(), "truncated");
|
||||
let mut trailing = bytes.clone();
|
||||
trailing.push(0);
|
||||
assert!(StateStream::decode(&trailing).is_err(), "trailing bytes");
|
||||
assert!(StateStream::decode(b"IGSD0\0").is_err(), "wrong magic");
|
||||
let l = StateLeaves::from_stream(&s, 28);
|
||||
let back = StateLeaves::from_words(s.root, s.block, s.number, &l.words());
|
||||
assert_eq!(back.leaves(), l.leaves());
|
||||
assert_eq!(l.bytes().len(), 93 * 64);
|
||||
assert_eq!(l.fnv1a64(), back.fnv1a64());
|
||||
}
|
||||
}
|
||||
|
|
@ -227,6 +227,42 @@ impl DatasetSource {
|
|||
Self { log2_words, mask, key, key_bytes: Vec::new(), dataset, hot: None }
|
||||
}
|
||||
|
||||
/// This source with the window's state leaves (class v5, `docs/design/class-v5-stored-state.md`): memory-hard mode
|
||||
/// under a shape with `state` only.
|
||||
pub fn with_leaves(mut self, leaves: std::sync::Arc<crate::state::StateLeaves>) -> Self {
|
||||
match &mut self.dataset {
|
||||
Dataset::MemoryHard(m) => {
|
||||
assert!(m.params.shape.state, "state leaves on a dataset whose shape has no state");
|
||||
m.leaves = Some(leaves);
|
||||
}
|
||||
Dataset::ClosedForm { .. } => panic!("state leaves on a closed-form dataset"),
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// A source of the same day with other leaves, the 256 MiB cache shared (the class v5 window refresh).
|
||||
pub fn refreshed(&self, leaves: std::sync::Arc<crate::state::StateLeaves>) -> Self {
|
||||
let dataset = match &self.dataset {
|
||||
Dataset::MemoryHard(m) => Dataset::MemoryHard(m.refreshed(leaves)),
|
||||
Dataset::ClosedForm { .. } => panic!("state leaves on a closed-form dataset"),
|
||||
};
|
||||
Self { log2_words: self.log2_words, mask: self.mask, key: self.key, key_bytes: self.key_bytes.clone(), dataset, hot: None }
|
||||
}
|
||||
|
||||
/// A copy of this source sharing its cache (and leaves), for a caller that needs an owned source from a shared one.
|
||||
pub fn refreshed_or_clone(&self) -> Self {
|
||||
let dataset = match &self.dataset {
|
||||
Dataset::MemoryHard(m) => Dataset::MemoryHard(crate::memhard::MemhardCpu { params: m.params.clone(), cache: m.cache.clone(), leaves: m.leaves.clone() }),
|
||||
Dataset::ClosedForm { d0, d1 } => Dataset::ClosedForm { d0: *d0, d1: *d1 },
|
||||
};
|
||||
Self { log2_words: self.log2_words, mask: self.mask, key: self.key, key_bytes: self.key_bytes.clone(), dataset, hot: None }
|
||||
}
|
||||
|
||||
/// The window's state leaves, when the source carries them.
|
||||
pub fn leaves(&self) -> Option<&std::sync::Arc<crate::state::StateLeaves>> {
|
||||
self.memhard().and_then(|m| m.leaves.as_ref())
|
||||
}
|
||||
|
||||
/// This source with the hot table of the epoch whose program seed bytes are `seed_bytes` (`mb` MiB).
|
||||
pub fn with_hot(mut self, seed_bytes: &[u8], mb: u32) -> Self {
|
||||
self.hot = Some(HotTable::for_seed_bytes(seed_bytes, mb));
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ fn item_by_hand(t: u32, mp: &MixParams, cache: &Cache) -> [u32; 16] {
|
|||
fn derived_item_by_hand_and_in_batches() {
|
||||
let key = day_key(DAY);
|
||||
let cache = Cache::fill_log2(key, 16);
|
||||
let shape = Shape { mixer_mult: 1, cache_log2_words: 16, derive_len: DERIVE_LEN_X8 };
|
||||
let shape = Shape { mixer_mult: 1, cache_log2_words: 16, derive_len: DERIVE_LEN_X8, state: false };
|
||||
let mp = MixParams::with_shape(key, shape);
|
||||
let prog = mp.derive.as_ref().unwrap();
|
||||
assert_eq!(prog.rounds.len(), DERIVE_PROGRAMS);
|
||||
|
|
@ -64,7 +64,7 @@ fn derived_item_by_hand_and_in_batches() {
|
|||
derive_items(&ts[..5], &mp, &cache, &mut out5);
|
||||
assert_eq!(&out5[..], &out[..5]);
|
||||
// the fixed mixer of the same key gives other items
|
||||
let v3 = MixParams::with_shape(key, Shape { mixer_mult: 8, cache_log2_words: 16, derive_len: 0 });
|
||||
let v3 = MixParams::with_shape(key, Shape { mixer_mult: 8, cache_log2_words: 16, derive_len: 0, state: false });
|
||||
assert!(v3.derive.is_none());
|
||||
assert_ne!(derive_item(0, &v3, &cache), derive_item(0, &mp, &cache));
|
||||
}
|
||||
|
|
@ -79,7 +79,7 @@ fn v2_and_v3_are_untouched() {
|
|||
let key = day_key(DAY);
|
||||
let cache = Cache::fill_log2(key, 16);
|
||||
// the version 2 item restated by hand (the mixer_mult_by_hand test of memhard.rs, m = 1)
|
||||
let v2 = MixParams::with_shape(key, Shape { mixer_mult: 1, cache_log2_words: 16, derive_len: 0 });
|
||||
let v2 = MixParams::with_shape(key, Shape { mixer_mult: 1, cache_log2_words: 16, derive_len: 0, state: false });
|
||||
let t = 12_345u32;
|
||||
let mut s = [0u32; 16];
|
||||
s[..8].copy_from_slice(&key);
|
||||
|
|
@ -96,7 +96,7 @@ fn v2_and_v3_are_untouched() {
|
|||
mixer(&mut s, round_key(8), &v2);
|
||||
assert_eq!(derive_item(t, &v2, &cache), s);
|
||||
// the mixer constants of the derivation class are the v2 draws (the stream continues after them)
|
||||
let dr = MixParams::with_shape(key, Shape { mixer_mult: 1, cache_log2_words: 16, derive_len: DERIVE_LEN_X8 });
|
||||
let dr = MixParams::with_shape(key, Shape { mixer_mult: 1, cache_log2_words: 16, derive_len: DERIVE_LEN_X8, state: false });
|
||||
assert_eq!((dr.rot, dr.mul, dr.rc), (v2.rot, v2.mul, v2.rc));
|
||||
}
|
||||
|
||||
|
|
@ -109,12 +109,12 @@ fn stream_class_name_and_id() {
|
|||
rng.next();
|
||||
}
|
||||
let expect = DeriveProgram::draw(&mut rng, DERIVE_LEN_X8);
|
||||
let mp = MixParams::with_shape(key, Shape { mixer_mult: 1, cache_log2_words: 26, derive_len: DERIVE_LEN_X8 });
|
||||
let mp = MixParams::with_shape(key, Shape { mixer_mult: 1, cache_log2_words: 26, derive_len: DERIVE_LEN_X8, state: false });
|
||||
assert_eq!(mp.derive.as_ref().unwrap(), &expect);
|
||||
// another day, another program; another length, another program
|
||||
let other = MixParams::with_shape(day_key("2026-10-04"), Shape { mixer_mult: 1, cache_log2_words: 26, derive_len: DERIVE_LEN_X8 });
|
||||
let other = MixParams::with_shape(day_key("2026-10-04"), Shape { mixer_mult: 1, cache_log2_words: 26, derive_len: DERIVE_LEN_X8, state: false });
|
||||
assert_ne!(other.derive.as_ref().unwrap().fingerprint(), expect.fingerprint());
|
||||
let short = MixParams::with_shape(key, Shape { mixer_mult: 1, cache_log2_words: 26, derive_len: 368 });
|
||||
let short = MixParams::with_shape(key, Shape { mixer_mult: 1, cache_log2_words: 26, derive_len: 368, state: false });
|
||||
assert_eq!(short.derive.as_ref().unwrap().instr_count(), 9 * 368);
|
||||
// the class: name, parse, id, and the v2 program stream (v2 loads, no width roll)
|
||||
let c = LoadClass::DR736;
|
||||
|
|
@ -179,8 +179,8 @@ fn determinism_and_pack_text() {
|
|||
fn stats_beside_x8() {
|
||||
let key = day_key(DAY);
|
||||
let cache = Cache::fill_log2(key, 18);
|
||||
let dr = MixParams::with_shape(key, Shape { mixer_mult: 1, cache_log2_words: 18, derive_len: DERIVE_LEN_X8 });
|
||||
let x8 = MixParams::with_shape(key, Shape { mixer_mult: 8, cache_log2_words: 18, derive_len: 0 });
|
||||
let dr = MixParams::with_shape(key, Shape { mixer_mult: 1, cache_log2_words: 18, derive_len: DERIVE_LEN_X8, state: false });
|
||||
let x8 = MixParams::with_shape(key, Shape { mixer_mult: 8, cache_log2_words: 18, derive_len: 0, state: false });
|
||||
for (label, mp) in [("dr736", &dr), ("x8", &x8)] {
|
||||
let n = 2048u32;
|
||||
let mut ones = [0u32; 512];
|
||||
|
|
@ -265,7 +265,7 @@ fn text_forms_match_scalar_reference() {
|
|||
/// The dataset source of the class on a day: the verifier's `word` path derives through the program.
|
||||
#[test]
|
||||
fn dataset_source_word_path() {
|
||||
let ds = DatasetSource::new_shape(DAY, DatasetMode::MemoryHard, 20, Shape { mixer_mult: 1, cache_log2_words: 16, derive_len: DERIVE_LEN_X8 });
|
||||
let ds = DatasetSource::new_shape(DAY, DatasetMode::MemoryHard, 20, Shape { mixer_mult: 1, cache_log2_words: 16, derive_len: DERIVE_LEN_X8, state: false });
|
||||
let m = ds.memhard().unwrap();
|
||||
let item = derive_item(3, &m.params, &m.cache);
|
||||
for j in 0..16u32 {
|
||||
|
|
|
|||
|
|
@ -275,7 +275,7 @@ fn edge_items_every_multiplier() {
|
|||
let key = day_key(DAY);
|
||||
let cache = Cache::fill_log2(key, 14);
|
||||
for m in [1u32, 2, 4, 8] {
|
||||
let mp = MixParams::with_shape(key, Shape { mixer_mult: m, cache_log2_words: 14, derive_len: 0 });
|
||||
let mp = MixParams::with_shape(key, Shape { mixer_mult: m, cache_log2_words: 14, derive_len: 0, state: false });
|
||||
let by_hand = |t: u32| -> [u32; 16] {
|
||||
let mut s = [0u32; 16];
|
||||
s[..8].copy_from_slice(&key);
|
||||
|
|
|
|||
|
|
@ -369,7 +369,7 @@ fn v3_packs_are_the_v2_seeds_under_mixer_x8() {
|
|||
assert_eq!(e3.program.program_id(), igneum_pow::generator::program_id(GENERATOR_VERSION_V3, &e3.program.seed, e3.program.attempt));
|
||||
let m3 = e3.dataset.memhard().unwrap();
|
||||
let m2 = e2.dataset.memhard().unwrap();
|
||||
assert_eq!(m3.shape(), Shape { mixer_mult: 8, cache_log2_words: 26, derive_len: 0 });
|
||||
assert_eq!(m3.shape(), Shape { mixer_mult: 8, cache_log2_words: 26, derive_len: 0, state: false });
|
||||
assert_eq!(m3.cache.fnv1a64(), m2.cache.fnv1a64(), "{v3}: the same cache as v2 on day 0");
|
||||
assert_eq!(m3.params.rot, m2.params.rot);
|
||||
assert_eq!(e3.dataset.log2_words, 28);
|
||||
|
|
@ -405,7 +405,7 @@ fn v3_packs_are_the_v2_seeds_under_mixer_x8() {
|
|||
assert_eq!(j["load_class"].as_str().unwrap(), "mx4");
|
||||
assert_eq!(e4.program.class, LoadClass::MX4);
|
||||
assert_eq!(e4.program.instrs, epoch(v2).program.instrs);
|
||||
assert_eq!(e4.dataset.memhard().unwrap().shape(), Shape { mixer_mult: 4, cache_log2_words: 26, derive_len: 0 });
|
||||
assert_eq!(e4.dataset.memhard().unwrap().shape(), Shape { mixer_mult: 4, cache_log2_words: 26, derive_len: 0, state: false });
|
||||
assert!(read(x4, "memhard.h").contains("j < 4u; ++j) mh_mixer(s, 0x9E3779B9u * (r * 4u + j + 1u))"));
|
||||
}
|
||||
}
|
||||
|
|
@ -899,3 +899,101 @@ fn hot_packs_emitted_sources_and_load_forms() {
|
|||
assert!(ph.contains("igneum_launch_hot_fill("));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// Class v5 (docs/design/class-v5-stored-state.md, 7 October 2026): the pinned pack under proto-cuda/packs-ca3-v5/
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
|
||||
fn v5_packs_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../proto-cuda/packs-ca3-v5")
|
||||
}
|
||||
|
||||
fn v5_read(pack: &str, file: &str) -> String {
|
||||
std::fs::read_to_string(v5_packs_dir().join(pack).join(file)).unwrap_or_else(|e| panic!("{pack}/{file}: {e}"))
|
||||
}
|
||||
|
||||
fn v5_json(pack: &str, file: &str) -> Value {
|
||||
serde_json::from_str(&v5_read(pack, file)).unwrap()
|
||||
}
|
||||
|
||||
/// The epoch of a pinned class v4 or v5 pack of the string seed and day: the program through the seam, the dataset at
|
||||
/// the day-0 size, and for a v5 pack the leaves of its `state.igsd1` (the devnet's state stream of 7 October 2026,
|
||||
/// node 1's exec snapshot at chain block 159,357: 93 records, root 0x1c583d35...).
|
||||
fn v5_epoch(pack: &str) -> Epoch {
|
||||
let j = v5_json(pack, "program.json");
|
||||
let seed = j["seed"].as_str().unwrap();
|
||||
let class = ProgramClass::parse(j["program_class"].as_str().unwrap()).unwrap();
|
||||
let program = generate_from_seed_bytes_program_class(seed, seed.as_bytes(), class, None);
|
||||
let day = j["dataset"]["day"].as_str().unwrap();
|
||||
let log2 = j["dataset"]["log2_words"].as_u64().unwrap() as u32;
|
||||
let shape = Shape::for_class(&program.class);
|
||||
let mut dataset = DatasetSource::new_shape(day, DatasetMode::MemoryHard, log2, shape);
|
||||
if class == ProgramClass::V5 {
|
||||
let stream = igneum_pow::StateStream::read_file(&v5_packs_dir().join(pack).join("state.igsd1")).unwrap();
|
||||
dataset = dataset.with_leaves(std::sync::Arc::new(igneum_pow::StateLeaves::from_stream(&stream, log2)));
|
||||
}
|
||||
Epoch { program, dataset }
|
||||
}
|
||||
|
||||
/// The class v5 pack is the class v4 program of the same seed over the state leaves: generator 5 and
|
||||
/// `program_id(5, seed, attempt)`, the base program, the shadow block and the dataset's cache equal to the v4 pack's,
|
||||
/// every vector and dataset word different, every emitted file byte for byte what the crate exports (leaves.bin
|
||||
/// included, its FNV in program.h), and the hash kernel text of kernel.cu equal to the v4 pack's but for the build
|
||||
/// kernel and the class lines. The known-failed case first: the v4 control pack's vectors under the v5 epoch agree on
|
||||
/// no lane.
|
||||
#[test]
|
||||
fn v5_pack_is_the_v4_program_over_the_state_leaves() {
|
||||
let e5 = v5_epoch("v5-genesis");
|
||||
let e4 = v5_epoch("v4-genesis");
|
||||
let v4 = v5_json("v4-genesis", "vectors.json");
|
||||
// the known-failed case: the v4 pack's hashes are not the v5 epoch's on any lane
|
||||
let out4: Vec<u64> = v4["warps"][0]["expected"].as_array().unwrap().iter().map(hex64).collect();
|
||||
let got5 = e5.hash_warp(0);
|
||||
assert_eq!(out4.iter().zip(got5.iter()).filter(|(a, b)| a == b).count(), 0, "0 of 32 lanes of the v4 pack agree with the v5 epoch");
|
||||
assert_eq!(e4.hash_warp(0).to_vec(), out4, "the v4 control pack is the v4 epoch");
|
||||
// the program: v4's draw, generator 5, the state in the class and the id
|
||||
assert_eq!(e5.program.generator, igneum_pow::GENERATOR_VERSION_V5);
|
||||
assert_eq!(e5.program.class, igneum_pow::V5_CLASS);
|
||||
assert_eq!(e5.program.class.name(), "mx8+sh256x27+state");
|
||||
assert_eq!(e5.program.instrs, e4.program.instrs);
|
||||
assert_eq!(e5.program.shadow, e4.program.shadow);
|
||||
assert_eq!((e5.program.seed, e5.program.attempt), (e4.program.seed, e4.program.attempt));
|
||||
assert_eq!(e5.program.program_id(), igneum_pow::generator::program_id(igneum_pow::GENERATOR_VERSION_V5, &e5.program.seed, e5.program.attempt));
|
||||
assert_ne!(e5.program.program_id(), e4.program.program_id());
|
||||
// the dataset: the same cache, other items
|
||||
let m5 = e5.dataset.memhard().unwrap();
|
||||
let m4 = e4.dataset.memhard().unwrap();
|
||||
assert_eq!(m5.cache.fnv1a64(), m4.cache.fnv1a64(), "one day cache");
|
||||
assert!(m5.shape().state && !m4.shape().state);
|
||||
let leaves = e5.dataset.leaves().unwrap();
|
||||
assert_eq!((leaves.n(), leaves.records_total, leaves.sampled), (93, 93, false));
|
||||
assert_ne!(e5.dataset_word(0), e4.dataset_word(0));
|
||||
// every file as the crate exports it, leaves.bin included
|
||||
for pack in ["v4-genesis", "v5-genesis"] {
|
||||
let e = if pack == "v5-genesis" { &e5 } else { &e4 };
|
||||
let v = v5_json(pack, "vectors.json");
|
||||
let out = export_pack(e, v["day"].as_str().unwrap(), v["source"].as_str().unwrap());
|
||||
for (name, text) in &out.files {
|
||||
assert_eq!(v5_read(pack, name), *text, "{pack}/{name} differs from the export");
|
||||
}
|
||||
for (name, bytes) in &out.binaries {
|
||||
assert_eq!(std::fs::read(v5_packs_dir().join(pack).join(name)).unwrap(), *bytes, "{pack}/{name}");
|
||||
}
|
||||
assert_eq!(out.binaries.len(), (pack == "v5-genesis") as usize);
|
||||
}
|
||||
let h5 = v5_read("v5-genesis", "program.h");
|
||||
assert!(h5.contains("#define IGNEUM_PROGRAM_CLASS \"v5\"") && h5.contains("#define IGNEUM_STATE_LEAVES 93") && h5.contains(&format!("#define IGNEUM_STATE_LEAVES_FNV64 {}", igneum_pow::emit::hex64(leaves.fnv1a64()))));
|
||||
assert!(h5.contains("igneum_launch_build(uint32_t* ds, const uint32_t* cache, const uint32_t* leaves, uint32_t nLeaves, uint32_t nItems)"));
|
||||
// the hash kernel text is class v4's byte for byte; the build kernel and the class lines are what differ
|
||||
let hash_text = |s: &str| {
|
||||
let a = s.find("__global__ void igneum_hash(").unwrap();
|
||||
let b = s.find("// Host-side launch wrappers").unwrap();
|
||||
s[a..b].to_string()
|
||||
};
|
||||
let k5 = v5_read("v5-genesis", "kernel.cu");
|
||||
let k4 = v5_read("v4-genesis", "kernel.cu");
|
||||
assert_eq!(hash_text(&k5), hash_text(&k4), "the hash kernel is class v4's");
|
||||
assert!(k5.contains("mh_item(cache, mh_leaf(leaves, nLeaves, t), t, s)") && !k4.contains("mh_leaf"));
|
||||
let mh5 = v5_read("v5-genesis", "memhard.h");
|
||||
assert!(mh5.contains("s[i] ^= leaf[i]"), "the leaf XOR before the first mixer");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@
|
|||
"proving_consensus_verify_daa": 18446744073709551615,
|
||||
"proving_shard_program_id": "",
|
||||
"proving_aggregator_id": "",
|
||||
"verifier_in_consensus": false,
|
||||
"sig_scheme": 0,
|
||||
"sig_scheme_activation_daa": 18446744073709551615,
|
||||
"finality_succession_activation_daa": 18446744073709551615,
|
||||
|
|
|
|||
274
infra/fast-time/proving-enforcement.mjs
Normal file
274
infra/fast-time/proving-enforcement.mjs
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
#!/usr/bin/env node
|
||||
// Enforced proving (docs/spec/proving-enforcement.md section 8; ledger P21): the fast-time case that runs the seven
|
||||
// refusals across the activation boundary. Three nodes on one fast-time network: H1 and H2 are ordinary unmodified
|
||||
// validators (the in-process SP1 verifier, the body rule and the payment rule from the floor); A is the modified producer:
|
||||
// its body rule is off (IGNEUM_TEST_SKIP_PROOF_RULE=1) and its pool trusts every record (IGNEUM_PROOF_VERIFY=trust), so a
|
||||
// record it signs for the CORRECT native statement rides in its own blocks whatever bytes stand behind it. Every node
|
||||
// mines with one CPU thread. The floor (`proving_consensus_verify_daa`, --floor DAA, the boundary) sits a few fast-time
|
||||
// minutes ahead; the honest nodes pin the program ids of proving/igneum-prove/elf/manifest.json.
|
||||
//
|
||||
// Below the boundary A's forged records pay on H1 (the v0 rule: the finding of ledger P21, observed). From the boundary
|
||||
// every carrying block of A is refused by H1 and H2 (IgneumInvalidProofRecord in their logs, or dropped unmarked after
|
||||
// the 20-s proof fetch) and no record of A's pays. The seven shapes, each submitted for shard 0 of a fresh chain block of
|
||||
// A's own chain, the native statement read from A's own node (igneum_getShardPlan with the payout address):
|
||||
// (a) no proof: empty proof bytes (their hash is the record's proof hash)
|
||||
// (b) a wrong proof: random bytes
|
||||
// (c) a real proof for another statement or program: --real-proof <compressed shard proof .bin> (skipped when absent)
|
||||
// (d) a replayed record: (b)'s record submitted again (A's own pool answers "accepted: false"; carried again by A it is
|
||||
// "shard already paid" or refused with the block)
|
||||
// (e) a wrong network id: signed for igneum-devnet-<suffix+1>; A's own unmodified pool refuses it at submit ("bad signature"),
|
||||
// the same check every honest node runs on a carried record
|
||||
// (f) an altered payout address: signed for another payout with the statement of the first; A's pool refuses it (the
|
||||
// native-execution veto), the same check every honest node runs
|
||||
// (g) a duplicate of a paid record: a second key's own valid record for a shard already paid (below the boundary, where
|
||||
// something pays): "shard already paid" on H1
|
||||
// The honest-pays-once case needs a real proof of this chain (a CPU prover, minutes); the unit tests hold it and the
|
||||
// testnet holds it live. --expect refuse (the rule's case, PASS = below: paid; from the floor: nothing paid and a refusal
|
||||
// logged) or --expect pay (the known-failed shape with --floor never: the forged record pays on both sides and must PASS
|
||||
// as the finding; the harness's own failed shape is --floor never --expect refuse, which must FAIL).
|
||||
//
|
||||
// node infra/fast-time/proving-enforcement.mjs [--floor 240] [--before 90] [--after 150] [--slot 0] [--real-proof <file>]
|
||||
// [--expect refuse|pay] [--out <file>]
|
||||
// IGNEUMD and IGNEUM_MINER name the binaries (a Linux build on the box: tools/fast-time-remote.sh --node-bin ...).
|
||||
//
|
||||
// Leftovers of an earlier run of the same slot are stopped by PID FILE, never by name (CLAUDE.md): every process this
|
||||
// harness starts is written to <tmp>/pids and a pid is killed only when /proc/<pid>/cmdline carries this slot's data
|
||||
// directory or one of its ports.
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync, appendFileSync } from 'node:fs';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
|
||||
const ROOT = new URL('../../', import.meta.url).pathname;
|
||||
const FILE = `${ROOT}infra/fast-time/override-60x.json`;
|
||||
const MANIFEST = `${ROOT}proving/igneum-prove/elf/manifest.json`;
|
||||
const IGNEUMD = process.env.IGNEUMD || `${ROOT}vendor/igneum-node/target-integration/release/igneumd`;
|
||||
const CPU_MINER = process.env.IGNEUM_MINER || `${ROOT}vendor/igneum-node/target-integration/release/igneum-miner`;
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name, dflt) => { const i = args.indexOf(`--${name}`); return i >= 0 ? Number(args[i + 1]) : dflt; };
|
||||
const sflag = (name, dflt = null) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : dflt; };
|
||||
const NEVER = '18446744073709551615';
|
||||
const FLOOR = sflag('floor', '240');
|
||||
const BEFORE = flag('before', 90), AFTER = flag('after', 150);
|
||||
const SLOT = flag('slot', 0);
|
||||
const REAL_PROOF = sflag('real-proof');
|
||||
const EXPECT = sflag('expect', FLOOR === 'never' ? 'pay' : 'refuse');
|
||||
const GENESIS_BITS = flag('genesis-bits', 0x1f010000);
|
||||
const CASE = sflag('case') || `floor-${FLOOR}-expect-${EXPECT}`;
|
||||
const OUT = sflag('out') || `${ROOT}docs/plans/proving-enforcement/${CASE}.json`;
|
||||
// 30890 and up: clear of every other fast-time harness (fork-gate 30690s, nuisance 30490s, headers-proof 30590s)
|
||||
const BASE = 30890 + SLOT * 40, SUFFIX = 985 + SLOT;
|
||||
const CHAIN = `igneum-devnet-${SUFFIX}`;
|
||||
const TMP = `/tmp/igneum-fast-time-pe${SLOT}`;
|
||||
if (!['refuse', 'pay'].includes(EXPECT)) { console.error('usage: --expect refuse|pay'); process.exit(2); }
|
||||
const started = [];
|
||||
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), `pe${SLOT}`, ...a);
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
for (const b of [IGNEUMD, CPU_MINER]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); }
|
||||
|
||||
const PIDS = `${TMP}/pids`;
|
||||
function stopLeftovers() {
|
||||
if (!existsSync(PIDS)) return;
|
||||
const ports = Array.from({ length: 40 }, (_, k) => `127.0.0.1:${BASE + k}`);
|
||||
for (const line of readFileSync(PIDS, 'utf8').split('\n').filter(Boolean)) {
|
||||
const pid = Number(line);
|
||||
let cmd = '';
|
||||
try { cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8'); } catch { continue; }
|
||||
if (!cmd.includes(TMP) && !ports.some(p => cmd.includes(p))) continue;
|
||||
try { process.kill(pid, 'SIGKILL'); log(`stopped leftover pid ${pid} of an earlier run`); } catch { }
|
||||
}
|
||||
}
|
||||
stopLeftovers();
|
||||
await sleep(1000);
|
||||
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
|
||||
const track = (proc) => { started.push(proc); try { appendFileSync(PIDS, `${proc.pid}\n`); } catch { } };
|
||||
|
||||
const baseText = readFileSync(FILE, 'utf8');
|
||||
const field = (name) => { const m = new RegExp(`"${name}":\\s*([0-9]+)`).exec(baseText); return m ? +m[1] : undefined; };
|
||||
function mergeOverrideText(text, fields) {
|
||||
let out = text;
|
||||
for (const k of Object.keys(fields)) out = out.replace(new RegExp(`\\s*"${k}":\\s*[^,}\\n]+,?`), '');
|
||||
const extra = Object.entries(fields).map(([k, v]) => `"${k}": ${typeof v === 'string' && !/^\d+$/.test(v) && v !== 'true' && v !== 'false' ? JSON.stringify(v) : v}`).join(', ');
|
||||
return out.replace(/,?\s*}\s*$/, `,\n ${extra}\n}\n`);
|
||||
}
|
||||
const DAY_MS = field('pow_day_ms');
|
||||
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'));
|
||||
const override = `${TMP}/override.json`;
|
||||
writeFileSync(override, mergeOverrideText(baseText, {
|
||||
genesis_bits: GENESIS_BITS, skip_proof_of_work: false,
|
||||
proving_v0_activation_daa: '0',
|
||||
proving_consensus_verify_daa: FLOOR === 'never' ? NEVER : FLOOR,
|
||||
proving_shard_program_id: manifest.shard.program_id, proving_aggregator_id: manifest.aggregator.program_id,
|
||||
verifier_in_consensus: 'false',
|
||||
program_class_v3_activation_daa: NEVER, program_class_v4_activation_daa: NEVER,
|
||||
}));
|
||||
log(`case ${CASE}: floor ${FLOOR} DAA, before ${BEFORE} s, after ${AFTER} s, expect ${EXPECT}, real proof ${REAL_PROOF || 'none'}`);
|
||||
|
||||
class Node {
|
||||
constructor(name, i, peers = [], env = {}) {
|
||||
this.name = name; this.i = i; this.grpcPort = BASE + i * 10; this.p2pPort = BASE + i * 10 + 1; this.jsonPort = BASE + i * 10 + 2; this.evmPort = BASE + i * 10 + 3;
|
||||
this.peers = peers; this.env = env; this.dir = `${TMP}/${name}`; this.logFile = `${this.dir}/node.log`;
|
||||
}
|
||||
get grpc() { return `grpc://127.0.0.1:${this.grpcPort}`; }
|
||||
async start() {
|
||||
mkdirSync(this.dir, { recursive: true });
|
||||
const out = openSync(this.logFile, 'a');
|
||||
const a = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex',
|
||||
`--appdir=${this.dir}`, `--rpclisten=127.0.0.1:${this.grpcPort}`, `--rpclisten-json=127.0.0.1:${this.jsonPort}`, `--evm-rpclisten=127.0.0.1:${this.evmPort}`,
|
||||
`--listen=127.0.0.1:${this.p2pPort}`, `--override-params-file=${override}`, '--loglevel=info', '--yes'];
|
||||
if (this.peers.length) for (const p of this.peers) a.push(`--addpeer=127.0.0.1:${p}`); else a.push('--outpeers=0');
|
||||
this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out], env: { ...process.env, ...this.env } });
|
||||
track(this.proc);
|
||||
for (let k = 0; k < 60; k++) {
|
||||
await sleep(1000);
|
||||
try { await this.exec('igneum_getExecStatus', []); log(`${this.name} up (pid ${this.proc.pid}) after ${k + 1} s`); return; } catch { }
|
||||
if (this.proc.exitCode !== null) throw new Error(`${this.name} exited ${this.proc.exitCode}: ${readFileSync(this.logFile, 'utf8').split('\n').slice(-5).join(' | ')}`);
|
||||
}
|
||||
throw new Error(`${this.name} did not answer in 60 s`);
|
||||
}
|
||||
async exec(method, params) {
|
||||
const r = await fetch(`http://127.0.0.1:${this.evmPort}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) });
|
||||
const j = await r.json();
|
||||
if (j.error) throw new Error(`${method}: ${j.error.message || JSON.stringify(j.error)}`);
|
||||
return j.result;
|
||||
}
|
||||
logText() { try { return readFileSync(this.logFile, 'utf8'); } catch { return ''; } }
|
||||
stop() { try { this.proc.kill('SIGINT'); } catch { } }
|
||||
}
|
||||
|
||||
function startMiner(node, label, secs) {
|
||||
const out = openSync(`${TMP}/${label}.log`, 'a');
|
||||
const p = spawn(CPU_MINER, ['mine', node.grpc, '1', String(secs), label, '--engine', 'igneum-pow', '--payout-label', label, '--status-secs', '30', '--no-vote'], { stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_POW_DAY_MS: String(DAY_MS) } });
|
||||
track(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
const sha256 = (b) => createHash('sha256').update(b).digest('hex');
|
||||
const hex = (b) => '0x' + Buffer.from(b).toString('hex');
|
||||
function signRecord(label, chain, block, number, shard, payout, statement, proofHash) {
|
||||
const r = spawnSync(CPU_MINER, ['sign-record', label, chain, block, String(number), String(shard), payout, statement, proofHash], { encoding: 'utf8' });
|
||||
if (r.status !== 0) throw new Error(`sign-record failed: ${r.stderr || r.stdout}`);
|
||||
return JSON.parse(r.stdout.trim().split('\n').pop());
|
||||
}
|
||||
|
||||
const H1 = new Node('H1', 0);
|
||||
const H2 = new Node('H2', 1, [H1.p2pPort]);
|
||||
const A = new Node('A', 2, [H1.p2pPort, H2.p2pPort], { IGNEUM_TEST_SKIP_PROOF_RULE: '1', IGNEUM_PROOF_VERIFY: 'trust' });
|
||||
const nodes = [H1, H2, A];
|
||||
const result = { case: CASE, floor: FLOOR, expect: EXPECT, phases: {}, node: IGNEUMD, slot: SLOT, ports: { base: BASE, suffix: SUFFIX }, startedAt: new Date().toISOString() };
|
||||
let miners = [];
|
||||
async function stopAll() {
|
||||
for (const m of miners) { try { m.kill('SIGINT'); } catch { } }
|
||||
for (const n of nodes) n.stop();
|
||||
await sleep(2000);
|
||||
for (const p of started) { try { if (p.exitCode === null) p.kill('SIGKILL'); } catch { } }
|
||||
}
|
||||
process.on('SIGINT', async () => { await stopAll(); process.exit(130); });
|
||||
|
||||
const PAYOUT_A = '0x' + 'a1'.repeat(20), PAYOUT_B = '0x' + 'b2'.repeat(20);
|
||||
async function daa(node) { const s = await node.exec('igneum_getExecStatus', []); return Number(s.executedTipDaa ?? 0); }
|
||||
async function tipNumber(node) { const s = await node.exec('igneum_getExecStatus', []); return Number(s.executedTip ?? 0); }
|
||||
|
||||
/// The seven shapes for shard 0 of A's chain block `n` (a block A has executed); returns what A's own pool answered.
|
||||
async function forge(n, phase) {
|
||||
const plan = await A.exec('igneum_getShardPlan', ['0x' + n.toString(16), PAYOUT_A]);
|
||||
const block = plan.hash, statement = plan.shards[0].statement;
|
||||
const planB = await A.exec('igneum_getShardPlan', ['0x' + n.toString(16), PAYOUT_B]);
|
||||
const statementB = planB.shards[0].statement;
|
||||
const shapes = [];
|
||||
const submit = async (name, record, proof) => {
|
||||
let out;
|
||||
try { out = await A.exec('igneum_submitProofRecord', [{ record, proof }]); } catch (e) { out = { accepted: false, reason: String(e.message) }; }
|
||||
shapes.push({ shape: name, accepted: out.accepted, new: out.new, reason: out.reason });
|
||||
log(`${phase} ${name}: A's pool ${out.accepted ? 'accepted' : 'refused'} (${out.reason || ''})`);
|
||||
return out;
|
||||
};
|
||||
const empty = Buffer.alloc(0), wrong = randomBytes(1024);
|
||||
// (a) no proof bytes
|
||||
await submit('a-no-proof', signRecord('forger-a', CHAIN, block, n, 0, PAYOUT_A, statement, '0x' + sha256(empty)).record, '0x');
|
||||
// (b) a wrong proof
|
||||
const recB = signRecord('forger-b', CHAIN, block, n, 0, PAYOUT_A, statement, '0x' + sha256(wrong)).record;
|
||||
await submit('b-wrong-proof', recB, hex(wrong));
|
||||
// (c) a real proof of another statement or program
|
||||
if (REAL_PROOF && existsSync(REAL_PROOF)) {
|
||||
const real = readFileSync(REAL_PROOF);
|
||||
await submit('c-other-program', signRecord('forger-c', CHAIN, block, n, 0, PAYOUT_A, statement, '0x' + sha256(real)).record, hex(real));
|
||||
} else shapes.push({ shape: 'c-other-program', skipped: 'no --real-proof file' });
|
||||
// (d) a replayed record
|
||||
await submit('d-replay', recB, hex(wrong));
|
||||
// (e) a wrong network id
|
||||
await submit('e-wrong-network', signRecord('forger-e', `igneum-devnet-${SUFFIX + 1}`, block, n, 0, PAYOUT_A, statement, '0x' + sha256(wrong)).record, hex(wrong));
|
||||
// (f) an altered payout address: the statement of PAYOUT_A signed for PAYOUT_B
|
||||
await submit('f-altered-payout', signRecord('forger-f', CHAIN, block, n, 0, PAYOUT_B, statement, '0x' + sha256(wrong)).record, hex(wrong));
|
||||
// (g) a duplicate by another key, its own valid statement for its own payout
|
||||
const dup = randomBytes(512);
|
||||
await submit('g-duplicate', signRecord('forger-g', CHAIN, block, n, 0, PAYOUT_B, statementB, '0x' + sha256(dup)).record, hex(dup));
|
||||
return { block: n, hash: block, shapes };
|
||||
}
|
||||
|
||||
/// What H1 holds for A's block `n`: the carried records and the paid map.
|
||||
async function observe(n) {
|
||||
try {
|
||||
const r = await H1.exec('igneum_getProofRecords', ['0x' + n.toString(16)]);
|
||||
const carried = (r.carried || []).map(c => ({ key: c.keyHash, carrier: c.carrierNumber, rejected: c.rejected, paidWei: c.paidWei }));
|
||||
// `paid` holds one entry per shard, null when unpaid: keep the paid ones only
|
||||
return { paid: (r.paid || []).filter(Boolean), carried };
|
||||
} catch (e) { return { error: e.message }; }
|
||||
}
|
||||
const refusals = (node) => {
|
||||
const t = node.logText();
|
||||
return { invalid: (t.match(/IgneumInvalidProofRecord|invalid proof record|proof record .* does not verify|REFUSED/gi) || []).length, dropped: (t.match(/carried proofs did not arrive|did not deliver its carried proofs/g) || []).length };
|
||||
};
|
||||
|
||||
try {
|
||||
for (const n of nodes) await n.start();
|
||||
miners = [startMiner(H1, 'h1', BEFORE + AFTER + 600), startMiner(H2, 'h2', BEFORE + AFTER + 600), startMiner(A, 'attacker', BEFORE + AFTER + 600)];
|
||||
// phase 1: below the boundary
|
||||
const floor = FLOOR === 'never' ? Infinity : Number(FLOOR);
|
||||
log(`phase 1: mining ${BEFORE} s below the floor`);
|
||||
await sleep(BEFORE * 1000);
|
||||
let d = await daa(A), tip = await tipNumber(A);
|
||||
log(`A at DAA ${d}, chain block ${tip}`);
|
||||
if (d >= floor) log(`WARNING: the floor ${floor} was crossed before the first forge (DAA ${d}); lengthen --floor`);
|
||||
// outside the 10-DAA exclusive window, where anyone may claim the shard (spec 07 7.2 item 4)
|
||||
const n1 = Math.max(1, tip - 15);
|
||||
const f1 = await forge(n1, 'below');
|
||||
await sleep(45000);
|
||||
const o1 = await observe(n1);
|
||||
result.phases.below = { daaAtForge: d, forge: f1, observed: o1, refusals: { H1: refusals(H1), H2: refusals(H2) } };
|
||||
log(`below: H1 paid ${JSON.stringify(o1.paid)}; carried ${JSON.stringify(o1.carried)}`);
|
||||
// phase 2: wait for the boundary, then forge again
|
||||
if (floor !== Infinity) {
|
||||
while ((d = await daa(A)) < floor + 5) { log(`waiting for the floor: DAA ${d} of ${floor}`); await sleep(10000); }
|
||||
} else await sleep(AFTER * 1000 / 2);
|
||||
tip = await tipNumber(A);
|
||||
const n2 = Math.max(1, tip - 15);
|
||||
const beforeRefusals = { H1: refusals(H1), H2: refusals(H2) };
|
||||
const f2 = await forge(n2, 'at-floor');
|
||||
await sleep(AFTER * 1000);
|
||||
const o2 = await observe(n2);
|
||||
const afterRefusals = { H1: refusals(H1), H2: refusals(H2) };
|
||||
result.phases.atFloor = { daaAtForge: d, forge: f2, observed: o2, refusalsBefore: beforeRefusals, refusalsAfter: afterRefusals };
|
||||
log(`at floor: H1 paid ${JSON.stringify(o2.paid)}; carried ${JSON.stringify(o2.carried)}; refusals ${JSON.stringify(afterRefusals)}`);
|
||||
// the verdict
|
||||
const paidBelow = (o1.paid || []).length > 0;
|
||||
const paidAt = (o2.paid || []).length > 0;
|
||||
const newRefusals = (afterRefusals.H1.invalid + afterRefusals.H1.dropped) > (beforeRefusals.H1.invalid + beforeRefusals.H1.dropped)
|
||||
|| (afterRefusals.H2.invalid + afterRefusals.H2.dropped) > (beforeRefusals.H2.invalid + beforeRefusals.H2.dropped);
|
||||
let pass;
|
||||
if (EXPECT === 'refuse') pass = paidBelow && !paidAt && newRefusals;
|
||||
else pass = paidBelow && paidAt;
|
||||
result.verdict = { paidBelow, paidAt, newRefusals, pass };
|
||||
result.endedAt = new Date().toISOString();
|
||||
mkdirSync(OUT.replace(/\/[^/]+$/, ''), { recursive: true });
|
||||
writeFileSync(OUT, JSON.stringify(result, null, 2));
|
||||
log(`RESULT ${pass ? 'PASS' : 'FAIL'}: below the floor paid=${paidBelow}; at the floor paid=${paidAt}, new refusals=${newRefusals}; ${OUT}`);
|
||||
await stopAll();
|
||||
process.exit(pass ? 0 : 1);
|
||||
} catch (e) {
|
||||
log(`ERROR ${e.message}`);
|
||||
result.error = e.message;
|
||||
try { mkdirSync(OUT.replace(/\/[^/]+$/, ''), { recursive: true }); writeFileSync(OUT, JSON.stringify(result, null, 2)); } catch { }
|
||||
await stopAll();
|
||||
process.exit(2);
|
||||
}
|
||||
469
infra/fast-time/testnet-object.mjs
Normal file
469
infra/fast-time/testnet-object.mjs
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
#!/usr/bin/env node
|
||||
// igneum-testnet-1's object at fast time (the testnet genesis lane, 7 October 2026): a 3-node devnet-suffix network on
|
||||
// override-60x.json with every switch of infra/seed-nodes/testnet-object.json on from genesis, as the testnet carries
|
||||
// them (difficulty v3, the DAA-second finality rule, finality v3, the signed leave item, the signing bonus at 1,000 bps,
|
||||
// class v4 unconditional, the latency ladder active at rung 0, consensus proof verification under the pinned ids, fees
|
||||
// v1, EmissionSchedule::TESTNET_1), real CPU mining on every node (igneum-pow engine, CPU genesis bits), four voting keys
|
||||
// and one key that never votes (--no-vote). What the testnet itself cannot show inside a run (its weight window is 30
|
||||
// days) this run shows in minutes: the first lock, a silent producer paid the bonus split, and a signed leave.
|
||||
//
|
||||
// What stays the devnet's, by design: the base unit (8 decimals: the devnet genesis payload is 8 bytes and the daemon
|
||||
// refuses a unit its genesis does not parse at; the 18-decimal form is tools/fleet/base-unit-gate.sh on the testnet
|
||||
// params), the finality object (the 60x file's: window 120, min_daa 120, presence 1, leave delay 60 = 3,600 / 60), the
|
||||
// ladder window (60 DAA, one epoch, in place of 86,400) and the PoW schedule (epoch 60, lead 10, day 24 minutes).
|
||||
// The testnet schedule is written at the devnet's unit (100 IGN a block = 10^10 sompi).
|
||||
//
|
||||
// Ports 29730 and up, network igneum-devnet-973, data under IGNEUM_TN_TMP (default /tmp/igneum-fast-time-testnet). The
|
||||
// live devnet, Devnet 2 and the public testnet seeds are never touched (--nodnsseed, loopback only). Everything started
|
||||
// is stopped at the end, by pid, never by name.
|
||||
//
|
||||
// Checks (each a line in the summary; PASS is every one true):
|
||||
// every node prints the start-up lines of the object (digest, class v4, ladder active, proof verification from 0, fees v1,
|
||||
// the signing bonus from 0, the leave item from 0)
|
||||
// every epoch's template is class v4 at rung 0 (27 shadow passes); no ladder bits on the chain (no node signals); the
|
||||
// object byte of every mined block is 0; the sinks and block counts agree across the nodes
|
||||
// the first finality lock arrives (finality_active, latest_locked_index > 0) on every node
|
||||
// the bonus pays: after the first lock, a chain block whose mergeset holds a block of the silent key pays that key's
|
||||
// address 72 percent of the merged block's subsidy on the UTXO side and the pool 28 percent (the voting keys 80/20)
|
||||
// the bridge is the identity under the bonus: for every chain block after the first lock, the sum of the UTXO coinbase
|
||||
// outputs to each miner's address, times 10^10, equals the sum of the execution layer's segment rewards to that
|
||||
// miner's EVM address (igneum_getSegment). Before the exec-side fix of 7 October 2026 this is the known-failed case:
|
||||
// the executor credits 80 percent to a silent key while the coinbase pays 72 (the testnet lane's finding, 08:0x UK)
|
||||
// a leave: at --leave-at seconds a voting key submits its signed leave (igneum-miner leave); the node accepts it, and
|
||||
// within leave_delay + one checkpoint the checkpoints' voter count drops by one
|
||||
//
|
||||
// node infra/fast-time/testnet-object.mjs [--secs 600] [--leave-at 360] [--expect pass|bonus-fails]
|
||||
// IGNEUMD, IGNEUM_MINER, IGNEUM_POW name the binaries (defaults: the testnet genesis worktree's fork under
|
||||
// vendor/igneum-node-testnet-genesis/target/release and igneum-pow/target/release/igneum-pow, the layout on
|
||||
// igneum-build-1 under /srv/builds/igneum-wt-testnet-genesis).
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync } from 'node:fs';
|
||||
import { connectRpc } from '../../tools/finality-attacks/lib/rpc.mjs';
|
||||
import { devAddress } from '../../tools/harness/lib/address.mjs';
|
||||
|
||||
const ROOT = new URL('../../', import.meta.url).pathname;
|
||||
const FILE = `${ROOT}infra/fast-time/override-60x.json`;
|
||||
const OBJECT = `${ROOT}infra/seed-nodes/testnet-object.json`;
|
||||
const BIN = process.env.IGNEUM_TN_BIN || `${ROOT}vendor/igneum-node-testnet-genesis/target/release`;
|
||||
const IGNEUMD = process.env.IGNEUMD || `${BIN}/igneumd`;
|
||||
const CPU_MINER = process.env.IGNEUM_MINER || `${BIN}/igneum-miner`;
|
||||
const IGNEUM_POW = process.env.IGNEUM_POW || `${ROOT}igneum-pow/target/release/igneum-pow`;
|
||||
const TMP = process.env.IGNEUM_TN_TMP || '/tmp/igneum-fast-time-testnet';
|
||||
const BASE = +(process.env.IGNEUM_TN_BASE_PORT || 29730), SUFFIX = +(process.env.IGNEUM_TN_SUFFIX || 973);
|
||||
const RUNG0 = 27, SOMPI = 10n ** 8n, WEI_PER_SOMPI = 10n ** 10n;
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name, dflt) => { const i = args.indexOf(`--${name}`); return i >= 0 ? +args[i + 1] : dflt; };
|
||||
const sflag = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; };
|
||||
const GENESIS_BITS = flag('genesis-bits', 0x1f010000);
|
||||
const SECS = flag('secs', 600);
|
||||
const LEAVE_AT = flag('leave-at', 360);
|
||||
const EXPECT = sflag('expect') || 'pass';
|
||||
if (!['pass', 'bonus-fails'].includes(EXPECT)) { console.error('usage: [--secs 600] [--leave-at 360] [--expect pass|bonus-fails]'); process.exit(2); }
|
||||
const started = [];
|
||||
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a);
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
for (const b of [IGNEUMD, CPU_MINER]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); }
|
||||
if (!existsSync(OBJECT)) { console.error(`missing ${OBJECT} (print_testnet_object writes it)`); process.exit(2); }
|
||||
|
||||
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
|
||||
// u64::MAX is not a JavaScript number: the files are merged as text, never through JSON.parse of the whole object
|
||||
const baseText = readFileSync(FILE, 'utf8');
|
||||
const objectText = readFileSync(OBJECT, 'utf8');
|
||||
const num = (text, name) => { const m = new RegExp(`"${name}":\\s*([0-9]+)`).exec(text); return m ? m[1] : undefined; };
|
||||
const str = (text, name) => { const m = new RegExp(`"${name}":\\s*"([^"]*)"`).exec(text); return m ? m[1] : undefined; };
|
||||
const EPOCH = +num(baseText, 'pow_epoch_blocks'), LEAD = +num(baseText, 'pow_epoch_lead'), DAY_MS = +num(baseText, 'pow_day_ms');
|
||||
const LEAVE_DELAY = 60, LADDER_WINDOW = 60;
|
||||
// the switches as the object carries them, the clocks at fast time, the unit the devnet's
|
||||
const switches = {
|
||||
genesis_bits: String(GENESIS_BITS), skip_proof_of_work: 'false',
|
||||
difficulty_v2_activation_daa: num(objectText, 'difficulty_v2_activation_daa'),
|
||||
difficulty_v3_activation_daa: num(objectText, 'difficulty_v3_activation_daa'),
|
||||
proving_v0_activation_daa: num(objectText, 'proving_v0_activation_daa'),
|
||||
finality_v3_activation_daa: num(objectText, 'finality_v3_activation_daa'),
|
||||
finality_daa_rule_activation_daa: num(objectText, 'finality_daa_rule_activation_daa'),
|
||||
finality_leave_activation_daa: num(objectText, 'finality_leave_activation_daa'),
|
||||
signing_bonus_activation_daa: num(objectText, 'signing_bonus_activation_daa'),
|
||||
signing_bonus_bps: num(objectText, 'signing_bonus_bps'),
|
||||
program_class_v3_activation_daa: num(objectText, 'program_class_v3_activation_daa'),
|
||||
program_class_v4_activation_daa: num(objectText, 'program_class_v4_activation_daa'),
|
||||
program_class_v4_signal_window_daa: num(objectText, 'program_class_v4_signal_window_daa'),
|
||||
program_class_v5_activation_daa: num(objectText, 'program_class_v5_activation_daa'),
|
||||
latency_ladder_activation_daa: num(objectText, 'latency_ladder_activation_daa'),
|
||||
latency_ladder_window_daa: String(LADDER_WINDOW),
|
||||
fees_v1_activation_daa: num(objectText, 'fees_v1_activation_daa'),
|
||||
proving_consensus_verify_daa: num(objectText, 'proving_consensus_verify_daa'),
|
||||
subsidy_per_block_activation_daa: num(objectText, 'subsidy_per_block_activation_daa'),
|
||||
proving_shard_program_id: JSON.stringify(str(objectText, 'proving_shard_program_id')),
|
||||
proving_aggregator_id: JSON.stringify(str(objectText, 'proving_aggregator_id')),
|
||||
};
|
||||
for (const [k, v] of Object.entries(switches)) if (v === undefined) { console.error(`the object has no ${k}`); process.exit(2); }
|
||||
// the class every epoch's template must carry: 5 when the object holds class v5 from genesis (the go object 0d05e795 of
|
||||
// 8 October 2026, byte 6), else 4 (the 5b673577 object, byte 7); before this the line copied only the v4 keys and ran
|
||||
// class v4 under a class v5 object (row 9u of docs/plans/testnet-go.md)
|
||||
const OBJECT_CLASS = String(switches.program_class_v5_activation_daa) === '0' ? 5 : 4;
|
||||
const ladderList = /"latency_ladder":\s*(\[[\s\S]*?\])/.exec(objectText)[1].replace(/\s+/g, '');
|
||||
const feesObject = /"fees":\s*(\{[\s\S]*?\n \})/.exec(objectText)[1].replace(/\s+/g, '');
|
||||
const emissionObject = /"emission":\s*(\{[\s\S]*?\n \})/.exec(objectText)[1].replace(/\s+/g, '').replace(/"launch_rate":"(\d+)"/, (_, r) => `"launch_rate":"${BigInt(r) / WEI_PER_SOMPI}"`);
|
||||
// the value of a top-level key in the file's text, nested objects and lists included (brace matching, never
|
||||
// JSON.parse: u64::MAX is not a JavaScript number)
|
||||
function spanOf(text, key) {
|
||||
const m = new RegExp(`"${key}":\\s*`).exec(text);
|
||||
if (!m) return null;
|
||||
const start = m.index; let i = m.index + m[0].length;
|
||||
const c = text[i];
|
||||
if (c === '{' || c === '[') {
|
||||
const open = c, close = c === '{' ? '}' : ']'; let depth = 0;
|
||||
for (; i < text.length; i++) { if (text[i] === open) depth++; else if (text[i] === close) { depth--; if (depth === 0) { i++; break; } } }
|
||||
} else if (c === '"') { i = text.indexOf('"', i + 1) + 1; } else { while (i < text.length && !/[,}\n]/.test(text[i])) i++; }
|
||||
// the trailing comma and the line break before the key
|
||||
if (text[i] === ',') i++;
|
||||
let s = start; while (s > 0 && /\s/.test(text[s - 1])) s--;
|
||||
return [s, i];
|
||||
}
|
||||
function mergeOverrideText(text, fields) {
|
||||
let out = text;
|
||||
for (const k of Object.keys(fields)) { const span = spanOf(out, k); if (span) out = out.slice(0, span[0]) + out.slice(span[1]); }
|
||||
const extra = Object.entries(fields).map(([k, v]) => `"${k}": ${v}`).join(',\n ');
|
||||
return out.replace(/,?\s*}\s*$/, `,\n ${extra}\n}\n`);
|
||||
}
|
||||
// the finality object: the 60x file's, with the leave delay at fast time
|
||||
const finSpan = spanOf(baseText, 'finality');
|
||||
const fastFinality = baseText.slice(finSpan[0], finSpan[1]).replace(/^[\s,]*"finality":\s*/, '').replace(/,\s*$/, '').replace(/\s+/g, '');
|
||||
const finalityObject = fastFinality.includes('leave_delay') ? fastFinality.replace(/"leave_delay":\d+/, `"leave_delay":${LEAVE_DELAY}`) : fastFinality.replace(/\}$/, `,"leave_delay":${LEAVE_DELAY}}`);
|
||||
const override = `${TMP}/override.json`;
|
||||
writeFileSync(override, mergeOverrideText(baseText, { ...switches, latency_ladder: ladderList, fees: feesObject, emission: emissionObject, finality: finalityObject }));
|
||||
log(`object: ${OBJECT}; every switch from genesis; finality ${finalityObject}; ladder window ${LADDER_WINDOW}; emission ${emissionObject}; run ${SECS} s, leave at ${LEAVE_AT} s; expect ${EXPECT}`);
|
||||
|
||||
// six keys: five voters and one that never votes, so the silent key's share of the weight window sits far from the
|
||||
// third at which the rule pauses by design (on the pin's run of 8 October 2026 one silent key of five held a quarter of
|
||||
// the blocks and finality paused at the end on a 120-block window: the rule, not the object)
|
||||
const LABELS = ['tn-voter-a', 'tn-voter-b', 'tn-voter-c', 'tn-voter-d', 'tn-voter-e', 'tn-silent'];
|
||||
const EVM = ['00000000000000000000000000000000000000a0', '00000000000000000000000000000000000000a1', '00000000000000000000000000000000000000a2', '00000000000000000000000000000000000000a3', '00000000000000000000000000000000000000a5', '00000000000000000000000000000000000000a4'];
|
||||
const N = LABELS.length, SILENT = N - 1, LEAVER = 1;
|
||||
// each miner's payout address as the miner itself prints it (its first log line names it); devAddress(label) is the
|
||||
// harness's own derivation and is not the miner's, so the chain's outputs are matched against the printed one
|
||||
let ADDR = LABELS.map(l => devAddress(l));
|
||||
function addressesFromMinerLogs() {
|
||||
return LABELS.map((_, i) => { const m = minerLog(i).join('\n').match(/igneum(?:dev|test)?:[a-z0-9]{20,}/); return m ? m[0] : ADDR[i]; });
|
||||
}
|
||||
class Node {
|
||||
constructor(i, connect = []) {
|
||||
this.i = i; this.grpcPort = BASE + i * 10; this.p2pPort = BASE + i * 10 + 1; this.jsonPort = BASE + i * 10 + 2; this.evmPort = BASE + i * 10 + 3;
|
||||
this.connect = connect; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`;
|
||||
}
|
||||
get grpc() { return `grpc://127.0.0.1:${this.grpcPort}`; }
|
||||
async start() {
|
||||
mkdirSync(this.dir, { recursive: true });
|
||||
const a = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex',
|
||||
`--appdir=${this.dir}`, `--rpclisten=127.0.0.1:${this.grpcPort}`, `--rpclisten-json=127.0.0.1:${this.jsonPort}`, `--evm-rpclisten=127.0.0.1:${this.evmPort}`,
|
||||
`--listen=127.0.0.1:${this.p2pPort}`, `--override-params-file=${override}`, '--loglevel=info', '--yes'];
|
||||
if (this.connect.length) a.push(`--connect=${this.connect.join(',')}`); else a.push('--outpeers=0');
|
||||
const out = openSync(this.logFile, 'a');
|
||||
this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out] });
|
||||
started.push(this.proc);
|
||||
writeFileSync(`${TMP}/n${this.i}.pid`, String(this.proc.pid));
|
||||
await sleep(1500);
|
||||
this.rpc = await connectRpc(`ws://127.0.0.1:${this.jsonPort}`);
|
||||
log(`n${this.i} up pid ${this.proc.pid} json ${this.jsonPort} p2p ${this.p2pPort} evm ${this.evmPort}`);
|
||||
return this;
|
||||
}
|
||||
grepLog(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } }
|
||||
async evm(method, params) {
|
||||
const r = await fetch(`http://127.0.0.1:${this.evmPort}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) });
|
||||
return (await r.json()).result;
|
||||
}
|
||||
}
|
||||
function miner(bin, argv, name, env = {}) {
|
||||
const out = openSync(`${TMP}/${name}.log`, 'a');
|
||||
const p = spawn(bin, argv, { stdio: ['ignore', out, out], env: { ...process.env, ...env } });
|
||||
started.push(p);
|
||||
writeFileSync(`${TMP}/${name}.pid`, String(p.pid));
|
||||
return p;
|
||||
}
|
||||
async function stopAll() {
|
||||
for (const p of started.reverse()) { try { p.kill('SIGINT'); } catch { } }
|
||||
await sleep(1500);
|
||||
for (const p of started) { try { p.kill('SIGKILL'); } catch { } }
|
||||
}
|
||||
process.on('SIGINT', async () => { await stopAll(); process.exit(130); });
|
||||
process.on('unhandledRejection', async (e) => { log(`FAILED: ${e?.stack || e}`); await stopAll(); process.exit(3); });
|
||||
process.on('uncaughtException', async (e) => { log(`FAILED: ${e?.stack || e}`); await stopAll(); process.exit(3); });
|
||||
const minerLog = (i) => { try { return readFileSync(`${TMP}/cpu${i}.log`, 'utf8').split('\n'); } catch { return []; } };
|
||||
|
||||
const t0 = Date.now();
|
||||
const since = () => ((Date.now() - t0) / 1000).toFixed(1);
|
||||
try {
|
||||
const n0 = await new Node(0).start();
|
||||
const nodes = [n0];
|
||||
for (let i = 1; i < N; i++) nodes.push(await new Node(i, [`127.0.0.1:${n0.p2pPort}`]).start());
|
||||
const n1 = nodes[LEAVER];
|
||||
const START_LINES = {
|
||||
digest: /Consensus params digest: ([0-9a-f]{64})/,
|
||||
class_v4: /program class v4|class v4 from|Program class: v4|class_v4/i,
|
||||
ladder_active: /Latency ladder active: rungs/,
|
||||
proof_verification_from_0: /Proving: consensus proof verification from DAA score 0/,
|
||||
fees_v1: /calibrated v1 from DAA score 0/,
|
||||
finality_v3_and_c1: /rule v3 \(frozen table, certificate fold\) from checkpoint DAA 0.*C1 in DAA seconds/,
|
||||
};
|
||||
for (const n of nodes) log(`n${n.i}: ${Object.entries(START_LINES).map(([k, re]) => `${k}=${n.grepLog(re).length > 0}`).join(' ')} | digest ${(n.grepLog(START_LINES.digest)[0] || '').replace(/^.*?digest: /, '').slice(0, 16)}`);
|
||||
// n0 and n1 vote (the key is the payout label's identity); n2 never votes: the silent key
|
||||
// the miners (and so the voters) outlive the run by five minutes and are stopped by the stop hook, so the final finality
|
||||
// read never sees a table nobody signs (pod 2 of 8 October 2026 read "0.00% signing" after the voters had exited with the run)
|
||||
const miners = nodes.map((n, i) => miner(CPU_MINER, ['mine', n.grpc, '1', String(SECS + 300), `cpu${i}`, '--engine', 'igneum-pow', '--payout-label', LABELS[i], '--evm-address', EVM[i], '--dev-fee', '0', '--status-secs', '30', '--stall-secs', '0', '--exec-rpc', `http://127.0.0.1:${n.evmPort}`, ...(i === SILENT ? ['--no-vote'] : [])], `cpu${i}`, { IGNEUM_POW_DAY_MS: String(DAY_MS) }));
|
||||
// --exec-rpc: the class v5 state provider's address; without it the 0.3.24-line miner asks the devnet port 26790 on every
|
||||
// network (row 9t), and five one-box nodes have five exec ports
|
||||
const pay = devAddress('fast-time-testnet-object');
|
||||
|
||||
const epochs = new Map();
|
||||
let lastEpoch = -1, lastReport = 0, lastDaa = 0, firstLock = null, leaveSent = null, leaveOutcome = null, votersBeforeLeave = null, votersAfterLeave = null, leaveSeenAt = null;
|
||||
const samples = [];
|
||||
const inactiveAfterLeave = [];
|
||||
async function report(node, last = 50) { return node.rpc.call('getFinalityCheckpoints', { last }).catch(() => null); }
|
||||
while (Date.now() - t0 < SECS * 1000) {
|
||||
await sleep(1000);
|
||||
let daa = null, epoch = null, cls = null, reps = null, step = null, sig = null;
|
||||
try {
|
||||
const t = await n0.rpc.call('getBlockTemplate', { payAddress: pay, extraData: [] });
|
||||
const pe = t.powEpoch || t.pow_epoch || {};
|
||||
daa = pe.virtualDaaScore ?? t.block?.header?.daaScore; epoch = pe.epochIndex; cls = pe.programClass;
|
||||
reps = pe.latencyLadderReps; step = pe.latencyLadderStep; sig = pe.latencyLadderSignal;
|
||||
} catch (e) { log(`template: ${e.message}`); }
|
||||
if (epoch != null && epoch !== lastEpoch) {
|
||||
epochs.set(epoch, { class: cls, reps, step, firstSeenDaa: daa, at: +since() });
|
||||
log(`epoch ${lastEpoch} -> ${epoch} at daa ${daa}, ${since()} s: template class ${cls} rung ${step} (${reps} passes), this node signals ${sig}`);
|
||||
lastEpoch = epoch;
|
||||
}
|
||||
lastDaa = daa ?? lastDaa;
|
||||
const r = await report(n0, 5);
|
||||
if (r && firstLock == null && r.latestLockedIndex > 0) { firstLock = { index: r.latestLockedIndex, daa, at: +since() }; log(`FIRST LOCK: index ${r.latestLockedIndex} at daa ${daa}, ${since()} s wall (active ${r.finalityActive})`); }
|
||||
if (leaveSent == null && Date.now() - t0 >= LEAVE_AT * 1000 && firstLock != null) {
|
||||
const before = await report(n1, 3);
|
||||
votersBeforeLeave = before?.checkpoints?.at(-1)?.voters ?? null;
|
||||
const out = spawnSync(CPU_MINER, ['leave', n1.grpc, LABELS[LEAVER]], { encoding: 'utf8', timeout: 20000 });
|
||||
leaveSent = { at: +since(), daa, stdout: (out.stdout || '').trim().slice(0, 300), stderr: (out.stderr || '').trim().slice(0, 300) };
|
||||
leaveOutcome = /LEAVE key=/.test(out.stdout || '') ? 'accepted' : 'refused';
|
||||
log(`LEAVE by ${LABELS[LEAVER]} at daa ${daa}: ${leaveOutcome} (${leaveSent.stdout || leaveSent.stderr}); voters before ${votersBeforeLeave}; stopping its miner (a clean departure)`);
|
||||
try { miners[LEAVER].kill('SIGINT'); } catch { }
|
||||
}
|
||||
if (leaveSent != null && votersAfterLeave == null) {
|
||||
const after = await report(n0, 3);
|
||||
const v = after?.checkpoints?.at(-1)?.voters ?? null;
|
||||
if (v != null && votersBeforeLeave != null && v < votersBeforeLeave) { votersAfterLeave = v; leaveSeenAt = { at: +since(), daa }; log(`LEAVE TOOK EFFECT: voters ${votersBeforeLeave} -> ${v} at daa ${daa}, ${since()} s`); }
|
||||
}
|
||||
if (leaveSent != null && r && r.finalityActive === false) { inactiveAfterLeave.push({ at: +since(), daa, reason: r.finalityReason }); }
|
||||
if (Date.now() - lastReport > 15000) {
|
||||
lastReport = Date.now();
|
||||
const counts = await Promise.all(nodes.map(async n => { try { const d = await n.rpc.call('getBlockDagInfo'); return `${d.blockCount}/${String(d.sink).slice(0, 8)}`; } catch { return '?'; } }));
|
||||
log(`t=${since()} s daa ${daa} epoch ${epoch} class ${cls} rung ${step} (${reps}) lock ${r?.latestLockedIndex ?? '?'} active ${r?.finalityActive ?? '?'} blocks/sink per node ${counts.join(' ')}`);
|
||||
samples.push({ t: +since(), daa, epoch, class: cls, step, reps, locked: r?.latestLockedIndex ?? null, active: r?.finalityActive ?? null, nodes: counts });
|
||||
}
|
||||
}
|
||||
// the final finality read, taken the moment the run ends and while every voter still signs
|
||||
const finalReports = await Promise.all(nodes.map(n => report(n, 3)));
|
||||
await sleep(3000);
|
||||
|
||||
// the chain: every block with its coinbase outputs by address, and the execution layer's segment rewards by miner
|
||||
const dag = await Promise.all(nodes.map(async n => { try { return await n.rpc.call('getBlockDagInfo'); } catch (e) { return { error: e.message }; } }));
|
||||
const genesis = dag[0].pruningPointHash;
|
||||
async function allBlocks(n) {
|
||||
const out = []; let low = genesis; const seen = new Set();
|
||||
for (let round = 0; round < 500; round++) {
|
||||
const r = await n.rpc.call('getBlocks', { lowHash: low, includeBlocks: true, includeTransactions: true });
|
||||
const blocks = r.blocks || [];
|
||||
let added = 0;
|
||||
for (const b of blocks) {
|
||||
const h = b.verboseData?.hash || b.header?.hash; if (seen.has(h)) continue; seen.add(h);
|
||||
const coinbase = (b.transactions || [])[0];
|
||||
const outputs = (coinbase?.outputs || []).map(o => ({ address: o.verboseData?.scriptPublicKeyAddress || '', value: BigInt(typeof o.value === 'string' ? o.value : Math.round(o.value)) }));
|
||||
out.push({ hash: h, daa: +b.header.daaScore, version: +b.header.version, chain: !!b.verboseData?.isChainBlock, outputs }); added++;
|
||||
}
|
||||
if (!blocks.length || added === 0) break;
|
||||
low = (r.blockHashes || []).at(-1) || blocks.at(-1).verboseData?.hash; if (!low) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let blocks = [];
|
||||
try { blocks = await allBlocks(n0); } catch (e) { log(`getBlocks: ${e.message}`); }
|
||||
const chainBlocks = blocks.filter(b => b.chain && b.daa > 0).sort((a, b) => a.daa - b.daa);
|
||||
const segments = [];
|
||||
const tip = Number(BigInt(await n0.evm('eth_blockNumber', []).catch(() => '0x0')));
|
||||
for (let i = 1; i <= tip; i++) { const s = await n0.evm('igneum_getSegment', [`0x${i.toString(16)}`]).catch(() => null); if (s) segments.push(s); }
|
||||
const segByHash0 = new Map(segments.map(s => [String(s.hash).replace(/^0x/, '').toLowerCase(), s]));
|
||||
ADDR = addressesFromMinerLogs();
|
||||
const isPool = (o) => o.address === '' || o.address == null;
|
||||
const hashKey = (h) => String(h).replace(/^0x/, '').toLowerCase();
|
||||
const byHash = new Map(blocks.map(b => [hashKey(b.hash), b]));
|
||||
// What each coinbase pays and what each segment credits (the node lane's reading of 7 October 2026, confirmed on the
|
||||
// chain here): the coinbase of chain block b pays b's mergeset, which is chain block b-1 (its selected parent) plus the
|
||||
// side blocks b merges; the execution record of chain block b credits b ITSELF plus the side blues b merges. So a chain
|
||||
// block is paid by its child's coinbase and credited in its own segment, one record apart; a side blue is paid and
|
||||
// credited in the same chain block. Totals per miner over the chain agree exactly once the tip's own credit (not yet
|
||||
// paid by a child) is set aside.
|
||||
const chainIndex = new Map(chainBlocks.map((b, i) => [hashKey(b.hash), i]));
|
||||
const sidesOf = (seg, selfHash) => (seg?.mergeset || []).filter(m => hashKey(m.hash) !== selfHash);
|
||||
const segHoldsItself = chainBlocks.filter(b => { const seg = segByHash0.get(hashKey(b.hash)); return seg && (seg.mergeset || []).some(m => hashKey(m.hash) === hashKey(b.hash)); }).length;
|
||||
log(`segments: ${segByHash0.size}; chain blocks whose own segment lists them in its mergeset: ${segHoldsItself} of ${chainBlocks.length}`);
|
||||
// the TESTNET_1 schedule at the devnet's unit inside the ramp (the run is minutes long; the first glide step is a month away)
|
||||
const RAMP = 7_776_000n, START = 10n, FULL = 10_000_000_000n;
|
||||
const subsidyAt = (daa) => { const sDaa = BigInt(daa); return sDaa >= RAMP ? FULL : FULL * (START * RAMP + (100n - START) * sDaa) / (100n * RAMP); };
|
||||
const poolShare = (a) => (a / 100n) * 20n + (a % 100n) * 20n / 100n;
|
||||
const splitOf = (a, silent) => { const producerShare = a - poolShare(a); let producer = producerShare; let pool = poolShare(a); if (silent) { const bonus = producerShare * 1000n / 10000n; producer -= bonus > producer ? producer : bonus; pool += bonus; } return [producer, pool]; };
|
||||
// learn each miner's UTXO address from the chain: a chain block b whose coinbase has one producer output and whose
|
||||
// mergeset is its selected parent alone (segment(b) lists no side block) pays chain block b-1, whose miner is the one
|
||||
// reward of segment(b-1) when that segment has no sides either
|
||||
{
|
||||
const learnt = new Map();
|
||||
for (let i = 1; i < chainBlocks.length; i++) {
|
||||
const b = chainBlocks[i], parent = chainBlocks[i - 1];
|
||||
const seg = segByHash0.get(hashKey(b.hash)), segParent = segByHash0.get(hashKey(parent.hash));
|
||||
if (!seg || !segParent) continue;
|
||||
if (sidesOf(seg, hashKey(b.hash)).length !== 0 || sidesOf(segParent, hashKey(parent.hash)).length !== 0) continue;
|
||||
const producers = b.outputs.filter(o => !isPool(o));
|
||||
const rewards = segParent.rewards || [];
|
||||
if (producers.length !== 1 || rewards.length !== 1) continue;
|
||||
const k = EVM.indexOf(hashKey(rewards[0].miner));
|
||||
if (k >= 0 && !learnt.has(k)) learnt.set(k, producers[0].address);
|
||||
}
|
||||
for (const [i, a] of learnt) ADDR[i] = a;
|
||||
log(`learnt addresses from the chain: ${[...learnt.entries()].map(([i, a]) => `${LABELS[i]}=${a.slice(0, 24)}`).join(' ')} (${learnt.size} of ${N})`);
|
||||
}
|
||||
const lockDaa = firstLock?.daa ?? Infinity;
|
||||
// 1. the identity per miner over the whole chain: UTXO payments x 10^10 against execution credits, the tip's own
|
||||
// credit set aside (its child's coinbase does not exist yet)
|
||||
const tipBlock = chainBlocks.at(-1);
|
||||
const tipSeg = tipBlock ? segByHash0.get(hashKey(tipBlock.hash)) : null;
|
||||
const tipOwnCredit = (k) => { if (!tipSeg) return 0n; const blues = (tipSeg.mergeset || []).filter(m => m.blue); const self = blues.findIndex(m => hashKey(m.hash) === hashKey(tipBlock.hash)); const r = (tipSeg.rewards || [])[self]; return r && hashKey(r.miner) === EVM[k] ? BigInt(r.wei) : 0n; };
|
||||
const bridgeRows = [];
|
||||
for (let k = 0; k < N; k++) {
|
||||
const utxo = chainBlocks.reduce((s, b) => s + b.outputs.filter(o => o.address === ADDR[k]).reduce((t, o) => t + o.value, 0n), 0n);
|
||||
const exec = [...segByHash0.values()].reduce((s, seg) => s + (seg.rewards || []).filter(r => hashKey(r.miner) === EVM[k]).reduce((t, r) => t + BigInt(r.wei), 0n), 0n);
|
||||
const execPaid = exec - tipOwnCredit(k);
|
||||
bridgeRows.push({ miner: LABELS[k], utxo_sompi: String(utxo), exec_wei: String(exec), exec_wei_less_tip: String(execPaid), identity: utxo * WEI_PER_SOMPI === execPaid, blocks_paid: chainBlocks.reduce((n, b) => n + b.outputs.filter(o => o.address === ADDR[k]).length, 0) });
|
||||
}
|
||||
const bridgeIdentityVoters = bridgeRows.filter(r => r.miner !== LABELS[SILENT]).every(r => r.identity && r.blocks_paid > 0);
|
||||
// 2. the bonus on the UTXO side: every coinbase output after the first lock, matched to the block it pays (chain b-1
|
||||
// or a side blue of b, by miner, when that miner has exactly one blue block in the mergeset), priced at that block's
|
||||
// own DAA: a silent key's block is paid the bonus split, a voting key's the plain 80 percent
|
||||
const bonusRows = [];
|
||||
for (let i = 1; i < chainBlocks.length; i++) {
|
||||
const b = chainBlocks[i];
|
||||
if (b.daa <= lockDaa) continue;
|
||||
const seg = segByHash0.get(hashKey(b.hash));
|
||||
if (!seg) continue;
|
||||
const paid = [{ hash: hashKey(chainBlocks[i - 1].hash), blue: true }, ...sidesOf(seg, hashKey(b.hash)).map(m => ({ hash: hashKey(m.hash), blue: !!m.blue }))];
|
||||
const paidBlocks = paid.map(p => ({ ...p, block: byHash.get(p.hash) })).filter(p => p.block);
|
||||
for (let k = 0; k < N; k++) {
|
||||
const mine = paidBlocks.filter(p => p.block.outputs !== undefined && minerOf(p.block) === k);
|
||||
if (mine.length !== 1 || !mine[0].blue) continue;
|
||||
const out = b.outputs.filter(o => o.address === ADDR[k]);
|
||||
if (out.length !== 1) continue;
|
||||
const [expectVoting] = splitOf(subsidyAt(mine[0].block.daa), false);
|
||||
const [expectSilent] = splitOf(subsidyAt(mine[0].block.daa), true);
|
||||
bonusRows.push({ chain_daa: b.daa, paid_block_daa: mine[0].block.daa, miner: LABELS[k], silent_key: k === SILENT, utxo_producer_sompi: String(out[0].value), expect_voting: String(expectVoting), expect_silent: String(expectSilent) });
|
||||
}
|
||||
}
|
||||
function minerOf(block) { for (let k = 0; k < N; k++) if (block.outputs.some(() => false)) return -1; return block.minerIndex ?? -1; }
|
||||
// a block's miner: the vote key hash is not in getBlocks' verbose data here, so take it from the segment that credits
|
||||
// the block (its own segment for a chain block, the merging chain block's segment for a side blue)
|
||||
for (const b of blocks) {
|
||||
const own = segByHash0.get(hashKey(b.hash));
|
||||
let miner = null;
|
||||
if (own) { const blues = (own.mergeset || []).filter(m => m.blue); const self = blues.findIndex(m => hashKey(m.hash) === hashKey(b.hash)); const r = (own.rewards || [])[self]; if (r) miner = hashKey(r.miner); }
|
||||
if (miner == null) for (const seg of segByHash0.values()) { const blues = (seg.mergeset || []).filter(m => m.blue); const j = blues.findIndex(m => hashKey(m.hash) === hashKey(b.hash)); if (j >= 0) { const r = (seg.rewards || [])[j]; if (r) { miner = hashKey(r.miner); break; } } }
|
||||
b.minerIndex = miner == null ? -1 : EVM.indexOf(miner);
|
||||
}
|
||||
// the bonus rows were built before minerIndex existed: build them again now that every block knows its miner
|
||||
bonusRows.length = 0;
|
||||
for (let i = 1; i < chainBlocks.length; i++) {
|
||||
const b = chainBlocks[i];
|
||||
if (b.daa <= lockDaa) continue;
|
||||
const seg = segByHash0.get(hashKey(b.hash));
|
||||
if (!seg) continue;
|
||||
const paid = [{ hash: hashKey(chainBlocks[i - 1].hash), blue: true }, ...sidesOf(seg, hashKey(b.hash)).map(m => ({ hash: hashKey(m.hash), blue: !!m.blue }))];
|
||||
const paidBlocks = paid.map(p => ({ ...p, block: byHash.get(p.hash) })).filter(p => p.block);
|
||||
for (let k = 0; k < N; k++) {
|
||||
const mine = paidBlocks.filter(p => p.block.minerIndex === k);
|
||||
if (mine.length !== 1 || !mine[0].blue) continue;
|
||||
const out = b.outputs.filter(o => o.address === ADDR[k]);
|
||||
if (out.length !== 1) continue;
|
||||
const [expectVoting] = splitOf(subsidyAt(mine[0].block.daa), false);
|
||||
const [expectSilent] = splitOf(subsidyAt(mine[0].block.daa), true);
|
||||
bonusRows.push({ chain_daa: b.daa, paid_block_daa: mine[0].block.daa, miner: LABELS[k], silent_key: k === SILENT, utxo_producer_sompi: String(out[0].value), expect_voting: String(expectVoting), expect_silent: String(expectSilent) });
|
||||
}
|
||||
}
|
||||
const silentRows = bonusRows.filter(r => r.silent_key);
|
||||
const votingRows = bonusRows.filter(r => !r.silent_key);
|
||||
const bonusPaidOnUtxo = silentRows.length > 0 && silentRows.every(r => r.utxo_producer_sompi === r.expect_silent);
|
||||
const votersPaidPlain = votingRows.length > 0 && votingRows.every(r => r.utxo_producer_sompi === r.expect_voting);
|
||||
const silentPaidLessThanVoters = bonusPaidOnUtxo && votersPaidPlain;
|
||||
const bridgeIdentity = bridgeRows.every(r => r.identity);
|
||||
|
||||
const bridgeIdentitySilent = bridgeRows.filter(r => r.miner === LABELS[SILENT]).every(r => r.identity && r.blocks_paid > 0);
|
||||
|
||||
const stampedByte = (() => { const m = n0.grepLog(/stamps object version (\d+)/).map(l => +(/stamps object version (\d+)/.exec(l)[1]))[0]; return m == null ? 0 : m; })();
|
||||
const objectBytes = blocks.filter(b => b.daa > 0).reduce((m, b) => { const v = (b.version >> 8) & 0x3f; m[v] = (m[v] || 0) + 1; return m; }, {});
|
||||
const ladderBits = blocks.filter(b => b.daa > 0).reduce((m, b) => { const k = (b.version & 0x8000) ? 'up' : (b.version & 0x4000) ? 'down' : 'none'; m[k] = (m[k] || 0) + 1; return m; }, {});
|
||||
const sinks = dag.map(d => String(d.sink || '?').slice(0, 16));
|
||||
const counts = dag.map(d => d.blockCount ?? '?');
|
||||
const accepted = LABELS.map((_, i) => minerLog(i).filter(l => /ACCEPTED block/.test(l)).length);
|
||||
const rejectedNode = nodes.map(n => n.grepLog(/PoW rejected|Rejected block|rejected block/i).length);
|
||||
// the report's reason flickers for one read at every new lock (the table frozen at the lock reads 0.00 percent signing
|
||||
// for a second); a pause is consecutive inactive reads, measured in seconds of the 1-s poll, never one read
|
||||
function longestInactiveStretchAfter(daa) {
|
||||
const xs = inactiveAfterLeave.filter(x => x.daa > daa).map(x => x.at).sort((a, b) => a - b);
|
||||
let best = 0, start = null, prev = null;
|
||||
for (const t of xs) { if (prev == null || t - prev > 2.5) { start = t; } prev = t; best = Math.max(best, t - start + 1); }
|
||||
return best;
|
||||
}
|
||||
const checks = {
|
||||
start_lines_on_every_node: nodes.every(n => Object.entries(START_LINES).every(([k, re]) => k === 'class_v4' || n.grepLog(re).length > 0)),
|
||||
digest_equal_on_every_node: new Set(nodes.map(n => (n.grepLog(START_LINES.digest)[0] || '').replace(/^.*?digest: /, '').slice(0, 64))).size === 1,
|
||||
every_epoch_object_class_at_rung_0: epochs.size > 0 && [...epochs.values()].every(e => e.class === OBJECT_CLASS && (e.step === 0 || e.step == null) && (e.reps === RUNG0 || e.reps == null)),
|
||||
// the object byte every mined block stamps is the one the node's own start-up line names ("stamps object version N"; 6 on
|
||||
// the go object with class v5 from genesis, 7 on the 5b673577 object, the Devnet 3 shape with class signalling on; 0 when
|
||||
// signalling is off); no ladder bits
|
||||
no_ladder_bits_and_object_byte_as_stamped: blocks.length > 0 && Object.keys(ladderBits).every(k => k === 'none') && Object.keys(objectBytes).every(v => +v === stampedByte),
|
||||
zero_rejected_by_nodes: rejectedNode.every(c => c === 0),
|
||||
sinks_and_counts_agree: new Set(sinks).size === 1 && new Set(counts.map(String)).size === 1,
|
||||
first_lock_on_every_node: firstLock != null && finalReports.every(r => r && r.latestLockedIndex > 0),
|
||||
silent_key_mined_after_the_lock: silentRows.length > 0,
|
||||
bonus_paid_on_the_utxo_side: silentPaidLessThanVoters && bonusPaidOnUtxo,
|
||||
voters_paid_the_plain_split_on_the_utxo_side: votersPaidPlain,
|
||||
bridge_identity_for_voting_keys: bridgeIdentityVoters,
|
||||
bridge_identity_for_the_silent_key: bridgeIdentitySilent,
|
||||
leave_accepted: leaveOutcome === 'accepted',
|
||||
leave_took_effect_within_delay_and_a_window: leaveSeenAt != null && leaveSent != null && (leaveSeenAt.daa - leaveSent.daa) <= LEAVE_DELAY + 120 + 30 + 5,
|
||||
// the point of the leave item: a clean departure never holds finality for a window; the pause after the leave, if any,
|
||||
// ends inside the delay plus one checkpoint, never a full window (120 DAA at fast time)
|
||||
// rule v3's share swings per checkpoint while CPU voters sign late; what the leave item buys is that a clean departure
|
||||
// never holds finality for a window: no inactive stretch of a window (120 DAA) after the leave took effect
|
||||
no_window_pause_after_the_leave_took_effect: leaveSeenAt != null && longestInactiveStretchAfter(leaveSeenAt.daa) < 120, // seconds: one block a second at fast time, so a window of 120 DAA is 120 s
|
||||
finality_active_at_the_end: finalReports.every(r => r && r.finalityActive === true),
|
||||
};
|
||||
let pass;
|
||||
if (EXPECT === 'bonus-fails') {
|
||||
// the known-failed case: the executor before the fix pays the silent key the full share, every other check holds
|
||||
pass = Object.entries(checks).every(([k, v]) => k === 'bridge_identity_for_the_silent_key' ? v === false : v);
|
||||
} else {
|
||||
pass = Object.values(checks).every(Boolean);
|
||||
}
|
||||
const summary = {
|
||||
pass, expect: EXPECT, checks, node: IGNEUMD, miner: CPU_MINER, override, run_secs: SECS, final_daa: lastDaa,
|
||||
epochs: Object.fromEntries([...epochs.entries()]), first_lock: firstLock,
|
||||
leave: { sent: leaveSent, outcome: leaveOutcome, voters_before: votersBeforeLeave, voters_after: votersAfterLeave, seen_at: leaveSeenAt, delay_daa: LEAVE_DELAY, longest_inactive_stretch_secs_after_effect: leaveSeenAt ? longestInactiveStretchAfter(leaveSeenAt.daa) : null, inactive_after: inactiveAfterLeave },
|
||||
blocks: { total: blocks.length, chain: chainBlocks.length, segments: segments.length, object_bytes: objectBytes, ladder_bits: ladderBits },
|
||||
bridge_rows: bridgeRows, bonus_rows: bonusRows, accepted_per_miner: accepted, rejected_by_nodes: rejectedNode, sinks, block_counts: counts, samples,
|
||||
final_reports: finalReports.map(r => r && { active: r.finalityActive, reason: r.finalityReason, locked: r.latestLockedIndex, voters: r.checkpoints?.at(-1)?.voters ?? null }),
|
||||
};
|
||||
writeFileSync(`${TMP}/summary.json`, JSON.stringify(summary, null, 2));
|
||||
log(`SUMMARY ${pass ? 'PASS' : 'FAIL'} (expect ${EXPECT}): epochs ${[...epochs.entries()].map(([e, v]) => `e${e}:v${v.class}:r${v.step}`).join(' ')}; first lock ${firstLock ? `index ${firstLock.index} at daa ${firstLock.daa}` : 'none'}; silent rows ${silentRows.length} (${silentRows.slice(0, 2).map(r => `daa ${r.paid_block_daa} utxo ${r.utxo_producer_sompi} silent ${r.expect_silent} voting ${r.expect_voting}`).join('; ')}), voting rows ${votingRows.length}; bridge ${bridgeRows.map(r => `${r.miner} utxo ${r.utxo_sompi} exec-tip ${r.exec_wei_less_tip} ${r.identity}`).join('; ')}; leave ${leaveOutcome} voters ${votersBeforeLeave} -> ${votersAfterLeave}; blocks ${blocks.length} chain ${chainBlocks.length}; rejected ${rejectedNode.join('/')}; sinks ${sinks.join(' ')} at ${counts.join('/')}`);
|
||||
for (const [k, v] of Object.entries(checks)) if (!v) log(`${EXPECT === 'bonus-fails' && k === 'bridge_identity_for_the_silent_key' ? 'EXPECTED FAILED CHECK' : 'FAILED CHECK'} ${k}`);
|
||||
log(`summary: ${TMP}/summary.json`);
|
||||
await stopAll();
|
||||
process.exit(pass ? 0 : 1);
|
||||
} catch (e) {
|
||||
log(`FAILED: ${e?.stack || e}`);
|
||||
await stopAll();
|
||||
process.exit(3);
|
||||
}
|
||||
228
infra/fast-time/tn-late-join.mjs
Normal file
228
infra/fast-time/tn-late-join.mjs
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
#!/usr/bin/env node
|
||||
// The testnet lane's copy of infra/fast-time/headers-proof-join.mjs (ca3-v4-node e5f993d4) for the late-join gate of
|
||||
// ledger N9's second half, 7 October 2026: the same join, with consensus proof verification and proving v0 from genesis
|
||||
// in the override and a prover beside node A (--prover), so the joiner's proofs come from A's archive.
|
||||
//
|
||||
// The 0.3.17 canary's own path (7 October 2026): a FRESH node joining through IBD WITH A HEADERS PROOF a chain whose sink
|
||||
// carries legal version-1026 signalling headers. The two-daemon test covers relay and headerless IBD; the proof path
|
||||
// needs a chain past the pruning depth, so this harness runs one on a fast-time profile at the Prunality floor
|
||||
// (finality 120, merge 60, k 18: pruning 2F + 4Mk + 2k + 2 = 4,598 DAA, set 4,600) and lets a fresh node join it.
|
||||
//
|
||||
// node A: override-60x.json re-depthed as above, the window object (window 120, floor 100000), IGNEUM_CLASS_SIGNAL=4,
|
||||
// three CPU threads (about 2.3 blocks/s on this Mac) until its pruning point has left genesis and the sink is
|
||||
// --margin blocks past that (about 35 minutes), then the miner stops.
|
||||
// node B: fresh, the same override, --addpeer A. PASS (--expect join): B's log carries "Starting IBD with headers
|
||||
// proof" and "IBD with peer ... completed successfully", B's sink equals A's within --watch seconds, and
|
||||
// the sink's header on B reads version 1026. The known-failed shape of the gate is the 0.3.17 binary (no
|
||||
// knob reproduces its raw comparison); the harness's own failed shape is `--expect stall`, which must FAIL.
|
||||
//
|
||||
// node infra/fast-time/headers-proof-join.mjs [--expect join|stall] [--margin 200] [--watch 600] [--threads 3]
|
||||
// IGNEUMD, IGNEUM_MINER name the binaries (defaults: vendor/igneum-node-0316/target-0316/release).
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync, copyFileSync } from 'node:fs';
|
||||
import { connectRpc } from '../../tools/finality-attacks/lib/rpc.mjs';
|
||||
|
||||
const ROOT = new URL('../../', import.meta.url).pathname;
|
||||
const FILE = `${ROOT}infra/fast-time/override-60x.json`;
|
||||
const BIN = process.env.IGNEUM_0316_BIN || `${ROOT}vendor/igneum-node-0316/target-0316/release`;
|
||||
const IGNEUMD = process.env.IGNEUMD || `${BIN}/igneumd`;
|
||||
const CPU_MINER = process.env.IGNEUM_MINER || `${BIN}/igneum-miner`;
|
||||
const TMP = process.env.IGNEUM_TN_TMP || '/tmp/igneum-fast-time-tn-join';
|
||||
const BASE = +(process.env.IGNEUM_TN_BASE_PORT || 30590), SUFFIX = +(process.env.IGNEUM_TN_SUFFIX || 997);
|
||||
const NEVER = '18446744073709551615';
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name, dflt) => { const i = args.indexOf(`--${name}`); return i >= 0 ? Number(args[i + 1]) : dflt; };
|
||||
const sflag = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; };
|
||||
const EXPECT = sflag('expect') || 'join';
|
||||
const WINDOW = Number(sflag('window') || 150);
|
||||
// The testnet lane's late-join case (ledger N9's second half, 7 October 2026): --prover "<command>" starts a prover
|
||||
// beside A's miner (its records land in A's blocks through the ordinary record flows; the command sees IGNEUM_GRPC
|
||||
// and IGNEUM_EVM_RPC for node A), and --join-after <blocks> holds B's start until A has mined that many blocks past
|
||||
// its ready point, so B joins after the pool's 600-chain-block window has passed and every proof it asks for must
|
||||
// come from the peers' archives. PASS is the same join; the failed shape is the node before the archive, which
|
||||
// stalls on "proofs this peer did not deliver in 20 s".
|
||||
const PROVER = sflag('prover') || null;
|
||||
const JOIN_AFTER = Number(sflag('join-after') || 0);
|
||||
// --resume: node A is already up on BASE's ports with its chain (a harness instance whose miner ran out before the join), and
|
||||
// RESUME_PIDS names pid files of the processes to stop before B joins (the rescue miner, the prover); nothing is wiped
|
||||
const RESUME = args.includes('--resume');
|
||||
const RESUME_PIDS = (sflag('resume-pids') || '').split(',').filter(Boolean);
|
||||
const MARGIN = flag('margin', 200), WATCH = flag('watch', 600), THREADS = flag('threads', 3), MAX_MINE = flag('max-mine', 5400);
|
||||
const OUT = sflag('out') || `${TMP}/late-join-expect-${EXPECT}.json`;
|
||||
if (!['join', 'stall'].includes(EXPECT)) { console.error('usage: --expect join|stall'); process.exit(2); }
|
||||
const started = [];
|
||||
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a);
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
for (const b of [IGNEUMD, CPU_MINER]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); }
|
||||
if (!RESUME) { rmSync(TMP, { recursive: true, force: true }); } mkdirSync(TMP, { recursive: true });
|
||||
|
||||
const baseText = readFileSync(FILE, 'utf8');
|
||||
const field = (name) => { const m = new RegExp(`"${name}":\\s*([0-9]+)`).exec(baseText); return m ? +m[1] : undefined; };
|
||||
export function mergeOverrideText(text, fields) {
|
||||
let out = text;
|
||||
for (const k of Object.keys(fields)) out = out.replace(new RegExp(`\\s*"${k}":\\s*[^,}\\n]+,?`), '');
|
||||
const extra = Object.entries(fields).map(([k, v]) => `"${k}": ${typeof v === 'string' && !/^\d+$/.test(v) ? JSON.stringify(v) : v}`).join(', ');
|
||||
return out.replace(/,?\s*}\s*$/, `,\n ${extra}\n}\n`);
|
||||
}
|
||||
// the depths at the Prunality floor: the blockrate block's finality_depth and pruning_depth replaced in place
|
||||
export function redepth(text) {
|
||||
// --window: the sampled difficulty window in samples (rate 4). The default 150 (the minimum) spans 600 DAA, which the
|
||||
// pruning point at DAA about 800 fills; 661 (the devnet's own) spans 2,644 DAA and is the known-failed shape found
|
||||
// on the first run (7 October 2026, 05:45 UK): the joiner's walk through the sampled trusted blocks runs out at a
|
||||
// gap before genesis and the rule "DAA window data has only N entries" ends the IBD. The devnet meets that for
|
||||
// the 2,644 DAA after its pruning point first leaves genesis; the node fix makes a young chain's short window legal.
|
||||
return text
|
||||
.replace(/"finality_depth":\s*\d+/, '"finality_depth": 120')
|
||||
.replace(/"pruning_depth":\s*\d+/, '"pruning_depth": 4600')
|
||||
.replace(/"difficulty_window_size":\s*\d+/, `"difficulty_window_size": ${WINDOW}`);
|
||||
}
|
||||
const DAY_MS = field('pow_day_ms');
|
||||
const override = `${TMP}/override.json`;
|
||||
if (!RESUME) writeFileSync(override, mergeOverrideText(redepth(baseText), {
|
||||
genesis_bits: 0x1f010000, skip_proof_of_work: false,
|
||||
program_class_v3_activation_daa: NEVER, program_class_v4_activation_daa: '100000', program_class_v4_signal_window_daa: 120,
|
||||
// the testnet lane's late-join case (ledger N9's second half): consensus proof verification from genesis under the
|
||||
// testnet object's pinned ids and proving v0 from genesis, so the prover's records are carried and verified, and a
|
||||
// joiner past the pool's window must get their proofs from the peers' archives
|
||||
proving_v0_activation_daa: '0', proving_consensus_verify_daa: '0',
|
||||
proving_shard_program_id: '0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a', proving_aggregator_id: '0x474678f35f7545db28055d5e5bbc308231d84a5a072202087a2a8d5b09123896',
|
||||
}));
|
||||
log(`expect ${EXPECT}: pruning depth 4600 (finality 120, merge 60, k 18), the window object set, signal byte 4, ${THREADS} threads, margin ${MARGIN}, watch ${WATCH} s`);
|
||||
|
||||
class Node {
|
||||
constructor(i, connect = null) {
|
||||
this.i = i; this.grpcPort = BASE + i * 10; this.p2pPort = BASE + i * 10 + 1; this.jsonPort = BASE + i * 10 + 2; this.evmPort = BASE + i * 10 + 3;
|
||||
this.connect = connect; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`;
|
||||
}
|
||||
get grpc() { return `grpc://127.0.0.1:${this.grpcPort}`; }
|
||||
async attach() {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
try { this.rpc = await connectRpc(`ws://127.0.0.1:${this.jsonPort}`); await this.rpc.call('getBlockDagInfo'); break; } catch { this.rpc = null; await sleep(500); }
|
||||
}
|
||||
if (!this.rpc) throw new Error(`n${this.i}: no live node answers on ${this.jsonPort}`);
|
||||
log(`n${this.i} attached on json ${this.jsonPort} (resume)`);
|
||||
return this;
|
||||
}
|
||||
async start() {
|
||||
mkdirSync(this.dir, { recursive: true });
|
||||
const a = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex',
|
||||
`--appdir=${this.dir}`, `--rpclisten=127.0.0.1:${this.grpcPort}`, `--rpclisten-json=127.0.0.1:${this.jsonPort}`, `--evm-rpclisten=127.0.0.1:${this.evmPort}`,
|
||||
`--listen=127.0.0.1:${this.p2pPort}`, `--override-params-file=${override}`, '--loglevel=info', '--yes'];
|
||||
if (this.connect) a.push(`--addpeer=127.0.0.1:${this.connect}`); else a.push('--outpeers=0');
|
||||
const out = openSync(this.logFile, 'a');
|
||||
this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_CLASS_SIGNAL: '4' } });
|
||||
started.push(this.proc);
|
||||
writeFileSync(`${TMP}/n${this.i}.pid`, String(this.proc.pid));
|
||||
await sleep(1500);
|
||||
if (this.proc.exitCode != null) throw new Error(`n${this.i} exited ${this.proc.exitCode}: ${this.grepLog(/ERROR|Error|error|refused|invalid/).slice(-3).join(' | ')}`);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
try { this.rpc = await connectRpc(`ws://127.0.0.1:${this.jsonPort}`); await this.rpc.call('getBlockDagInfo'); break; } catch { this.rpc = null; await sleep(500); }
|
||||
}
|
||||
if (!this.rpc) throw new Error(`n${this.i}: the RPC did not answer within 10 s`);
|
||||
log(`n${this.i} up pid ${this.proc.pid} json ${this.jsonPort} p2p ${this.p2pPort}${this.connect ? ` addpeer ${this.connect}` : ''}`);
|
||||
return this;
|
||||
}
|
||||
grepLog(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } }
|
||||
async dag() { return this.rpc.call('getBlockDagInfo'); }
|
||||
async headerOf(hash) { const b = await this.rpc.call('getBlock', { hash, includeTransactions: false }); return b.block.header; }
|
||||
}
|
||||
function miner(name, grpc, threads, secs) {
|
||||
const out = openSync(`${TMP}/${name}.log`, 'a');
|
||||
const p = spawn(CPU_MINER, ['mine', grpc, String(threads), String(secs), name, '--engine', 'igneum-pow', '--payout-label', name, '--status-secs', '60', '--no-vote', '--stall-secs', '0'], { stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_POW_DAY_MS: String(DAY_MS) } });
|
||||
started.push(p);
|
||||
writeFileSync(`${TMP}/${name}.pid`, String(p.pid));
|
||||
return p;
|
||||
}
|
||||
async function stopAll() {
|
||||
for (const p of [...started].reverse()) { try { p.kill('SIGINT'); } catch { } }
|
||||
await sleep(1500);
|
||||
for (const p of started) { try { p.kill('SIGKILL'); } catch { } }
|
||||
}
|
||||
process.on('SIGINT', async () => { await stopAll(); process.exit(130); });
|
||||
process.on('unhandledRejection', async (e) => { log(`FAILED: ${e?.stack || e}`); await stopAll(); process.exit(3); });
|
||||
process.on('uncaughtException', async (e) => { log(`FAILED: ${e?.stack || e}`); await stopAll(); process.exit(3); });
|
||||
|
||||
const a = RESUME ? await new Node(0).attach() : await new Node(0).start();
|
||||
const genesis = (await a.dag()).pruningPointHash;
|
||||
const m = RESUME ? null : miner('a-miner', a.grpc, THREADS, MAX_MINE);
|
||||
// the testnet lane's prover beside A's miner (its records reach A's blocks through the record flows)
|
||||
let prover = null;
|
||||
if (PROVER) {
|
||||
const out = openSync(`${TMP}/prover.log`, 'a');
|
||||
prover = spawn('/bin/sh', ['-c', PROVER], { stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_GRPC: a.grpc, IGNEUM_EVM_RPC: `http://127.0.0.1:${a.evmPort}` } });
|
||||
started.push(prover);
|
||||
writeFileSync(`${TMP}/prover.pid`, String(prover.pid));
|
||||
log(`prover up pid ${prover.pid}: ${PROVER}`);
|
||||
}
|
||||
const t0 = Date.now();
|
||||
const since = () => ((Date.now() - t0) / 1000).toFixed(1);
|
||||
let lastReport = 0, pruningMovedAt = null, pruningPoint = null;
|
||||
if (RESUME) { const d = await a.dag(); pruningMovedAt = { t: 0, daa: +d.virtualDaaScore, blocks: d.blockCount, resumed: true }; pruningPoint = d.pruningPointHash; log(`resume: A at ${d.blockCount} blocks, daa ${d.virtualDaaScore}, pruning point ${String(d.pruningPointHash).slice(0, 8)} (taken as moved)`); }
|
||||
while (!RESUME && Date.now() - t0 < MAX_MINE * 1000) {
|
||||
await sleep(10000);
|
||||
const d = await a.dag();
|
||||
if (pruningMovedAt == null && d.pruningPointHash !== genesis) { pruningMovedAt = { t: +since(), daa: +d.virtualDaaScore, blocks: d.blockCount }; pruningPoint = d.pruningPointHash; log(`A's pruning point left genesis at ${since()} s: ${String(d.pruningPointHash).slice(0, 8)} at DAA ${d.virtualDaaScore}, ${d.blockCount} blocks`); }
|
||||
if (Date.now() - lastReport > 120000) { lastReport = Date.now(); log(`t=${since()} s A ${d.blockCount} blocks daa ${d.virtualDaaScore} pruning ${String(d.pruningPointHash).slice(0, 8)}${pruningMovedAt ? ' (moved)' : ''}`); }
|
||||
if (pruningMovedAt && d.blockCount >= pruningMovedAt.blocks + MARGIN) break;
|
||||
}
|
||||
// --join-after: A's miner runs on this many blocks past the ready point (past the pool's 600-chain-block record
|
||||
// window, so every proof B asks for must come from the peers' archives), then stops as before so B joins a still sink
|
||||
if (JOIN_AFTER > 0) {
|
||||
// past the pool's 600-chain-block record window, measured in DAA (one block a second here): the block count is the
|
||||
// unpruned count and plateaus once the pruning point moves (seen on the pod, 7 October 2026, 13:4x UK)
|
||||
const readyDaa = +(await a.dag()).virtualDaaScore;
|
||||
const target = readyDaa + JOIN_AFTER;
|
||||
log(`A ready at daa ${readyDaa}; mining on to daa ${target} (${JOIN_AFTER} past the ready point) before B joins`);
|
||||
for (;;) {
|
||||
const d = await a.rpc.call('getBlockDagInfo');
|
||||
if (+d.virtualDaaScore >= target) break;
|
||||
await sleep(5000);
|
||||
}
|
||||
}
|
||||
for (const f of RESUME_PIDS) { try { const pid = +readFileSync(f, 'utf8').trim(); if (pid > 1) { process.kill(pid, 'SIGINT'); log(`resume: stopped pid ${pid} from ${f}`); } } catch (e) { log(`resume: ${f}: ${e.message}`); } }
|
||||
try { m?.kill('SIGINT'); } catch { }
|
||||
try { prover?.kill('SIGINT'); } catch { }
|
||||
await sleep(3000);
|
||||
const aAtJoin = await a.dag();
|
||||
const aSinkHeader = await a.headerOf(aAtJoin.sink);
|
||||
log(`A ready for the join: ${aAtJoin.blockCount} blocks, ${aAtJoin.headerCount} headers, daa ${aAtJoin.virtualDaaScore}, pruning point ${String(aAtJoin.pruningPointHash).slice(0, 8)}, sink ${String(aAtJoin.sink).slice(0, 8)} version ${aSinkHeader.version}`);
|
||||
if (!pruningMovedAt) { log(`SUMMARY FAIL (expect ${EXPECT}): A's pruning point never left genesis in ${MAX_MINE} s; the chain is too short for a headers-proof join`); await stopAll(); process.exit(1); }
|
||||
|
||||
// the fresh node (on a resume, B's directory from an earlier join is wiped so B joins fresh)
|
||||
if (RESUME) rmSync(`${TMP}/n1`, { recursive: true, force: true });
|
||||
const b = await new Node(1, a.p2pPort).start();
|
||||
const tj = Date.now();
|
||||
let joined = null, proofLine = null, doneLine = null, lastJ = 0;
|
||||
while (Date.now() - tj < WATCH * 1000) {
|
||||
await sleep(5000);
|
||||
const d = await b.dag().catch(() => null);
|
||||
proofLine = proofLine || b.grepLog(/Starting IBD with headers proof/)[0] || null;
|
||||
doneLine = doneLine || b.grepLog(/IBD with peer .* completed successfully/)[0] || null;
|
||||
if (d && String(d.sink) === String(aAtJoin.sink) && joined == null) { joined = { t: (Date.now() - tj) / 1000, blocks: d.blockCount, headers: d.headerCount }; log(`B reached A's sink at ${joined.t} s after the join: ${d.blockCount} blocks, ${d.headerCount} headers`); }
|
||||
if (Date.now() - lastJ > 30000) { lastJ = Date.now(); log(`t+${((Date.now() - tj) / 1000).toFixed(0)} s B ${d?.blockCount} blocks ${d?.headerCount} headers sink ${d ? String(d.sink).slice(0, 8) : '?'}; proof line ${proofLine ? 'yes' : 'no'}, done ${doneLine ? 'yes' : 'no'}`); }
|
||||
if (joined && doneLine) break;
|
||||
}
|
||||
const bSinkVersion = joined ? (await b.headerOf(aAtJoin.sink).catch(() => null))?.version ?? null : null;
|
||||
const errors = b.grepLog(/flow error|header version mismatch|wrong block version|completed with error/).slice(0, 5).map(l => l.replace(/^.*?\] /, '').slice(0, 200));
|
||||
const checks = {
|
||||
pruning_point_moved: !!pruningMovedAt,
|
||||
a_sink_signalling: aSinkHeader.version === 1026,
|
||||
headers_proof_path_taken: !!proofLine,
|
||||
ibd_completed: !!doneLine,
|
||||
b_reached_a_sink: !!joined,
|
||||
b_sink_version_1026: bSinkVersion === 1026,
|
||||
no_version_refusal: !errors.some(e => /header version mismatch|wrong block version/.test(e)),
|
||||
};
|
||||
const good = EXPECT === 'join'
|
||||
? Object.values(checks).every(Boolean)
|
||||
: !(checks.b_reached_a_sink && checks.ibd_completed);
|
||||
const summary = { pass: good, expect: EXPECT, window: WINDOW, pruning_depth: 4600, margin: MARGIN, threads: THREADS, a_at_join: { blocks: aAtJoin.blockCount, headers: aAtJoin.headerCount, daa: aAtJoin.virtualDaaScore, pruning: String(aAtJoin.pruningPointHash).slice(0, 16), sink: String(aAtJoin.sink).slice(0, 16), sink_version: aSinkHeader.version }, pruning_moved_at: pruningMovedAt, joined, proof_line: proofLine?.replace(/^.*?\] /, '').slice(0, 200) ?? null, done_line: doneLine?.replace(/^.*?\] /, '').slice(0, 200) ?? null, b_sink_version: bSinkVersion, b_errors: errors, checks, node: IGNEUMD, miner: CPU_MINER };
|
||||
mkdirSync(OUT.replace(/\/[^/]+$/, ''), { recursive: true });
|
||||
writeFileSync(OUT, JSON.stringify(summary, null, 2));
|
||||
try { copyFileSync(b.logFile, OUT.replace(/\.json$/, '-b-node.log')); } catch { }
|
||||
const fails = Object.entries(checks).filter(([, v]) => !v).map(([k]) => k);
|
||||
log(`SUMMARY ${good ? 'PASS' : 'FAIL'} (expect ${EXPECT}): A ${aAtJoin.blockCount} blocks at DAA ${aAtJoin.virtualDaaScore} with the pruning point moved at ${pruningMovedAt?.t} s, sink version ${aSinkHeader.version}; B ${proofLine ? 'took the headers-proof path' : 'did not take the headers-proof path'}, ${doneLine ? 'IBD completed' : 'IBD not completed'}, ${joined ? `reached A's sink at ${joined.t} s with ${joined.headers} headers` : 'did not reach the sink of A'}, sink version on B ${bSinkVersion}; B errors: ${errors.length}${fails.length ? `; FAILED CHECK ${fails.join(', ')}` : ''}`);
|
||||
log(`summary: ${OUT}`);
|
||||
await stopAll();
|
||||
process.exit(good ? 0 : 1);
|
||||
84
infra/fast-time/tn-prover-loop.mjs
Normal file
84
infra/fast-time/tn-prover-loop.mjs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
#!/usr/bin/env node
|
||||
// A prover beside a fast-time node (the testnet genesis lane, 7 October 2026): every --every seconds it asks the node for
|
||||
// the shards its key is assigned, takes the newest empty one it has not proved, exports the chain, cuts the fixture,
|
||||
// proves the shard on the CPU (igneum-prove-host --mode compressed, SP1_PROVER=cpu), signs the record with the key and
|
||||
// submits it with the proof bytes. Its records reach the node's blocks through the ordinary record flows, which is what
|
||||
// the late-join gate needs: proofs carried by blocks that leave the pool's window before a joiner asks for them.
|
||||
//
|
||||
// IGNEUM_GRPC=grpc://127.0.0.1:P IGNEUM_EVM_RPC=http://127.0.0.1:Q node infra/fast-time/tn-prover-loop.mjs \
|
||||
// --label a-miner --chain igneum-devnet-997 [--every 20] [--window 100] [--tmp /tmp/tn-prover]
|
||||
// IGNEUM_MINER, IGNEUM_PROVE_HOST, IGNEUM_PROVE_EXPORT name the binaries (the miner for key-hash and sign-record).
|
||||
//
|
||||
// One RESULT line per event (assigned, export, prove, submit, refused) with a UTC stamp; the loop never kills anything.
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const sflag = (name, dflt) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : dflt; };
|
||||
const LABEL = sflag('label', 'a-miner');
|
||||
const CHAIN = sflag('chain', 'igneum-devnet-997');
|
||||
const EVERY = +sflag('every', 20);
|
||||
const WINDOW = +sflag('window', 100);
|
||||
const TMP = sflag('tmp', '/tmp/tn-prover');
|
||||
const EVM = process.env.IGNEUM_EVM_RPC || 'http://127.0.0.1:30593';
|
||||
const MINER = process.env.IGNEUM_MINER;
|
||||
const HOST = process.env.IGNEUM_PROVE_HOST;
|
||||
const EXPORT = process.env.IGNEUM_PROVE_EXPORT;
|
||||
const PAYOUT = '0x4242424242424242424242424242424242424242';
|
||||
const log = (...a) => console.log(new Date().toISOString(), ...a);
|
||||
for (const b of [MINER, HOST, EXPORT]) if (!b || !existsSync(b)) { console.error(`missing binary: ${b}`); process.exit(2); }
|
||||
mkdirSync(TMP, { recursive: true });
|
||||
|
||||
async function rpc(method, params) {
|
||||
const r = await fetch(EVM, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) });
|
||||
const j = await r.json();
|
||||
if (j.error) throw new Error(`${method}: ${JSON.stringify(j.error)}`);
|
||||
return j.result;
|
||||
}
|
||||
function run(bin, argv, env = {}) {
|
||||
const t = Date.now();
|
||||
const r = spawnSync(bin, argv, { encoding: 'utf8', env: { ...process.env, ...env }, maxBuffer: 1 << 28 });
|
||||
return { code: r.status, out: `${r.stdout || ''}${r.stderr || ''}`, secs: ((Date.now() - t) / 1000).toFixed(1) };
|
||||
}
|
||||
const kh = run(MINER, ['key-hash', LABEL]);
|
||||
const KEY_HASH = (kh.out.trim().split('\n').pop() || '').trim();
|
||||
if (!/^[0-9a-f]{64}$/.test(KEY_HASH)) { console.error(`key-hash ${LABEL}: ${kh.out}`); process.exit(2); }
|
||||
log(`RESULT start label=${LABEL} key=${KEY_HASH} chain=${CHAIN} evm=${EVM} every=${EVERY}s window=${WINDOW}`);
|
||||
const done = new Set();
|
||||
let proved = 0, refused = 0;
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
for (;;) {
|
||||
try {
|
||||
const work = await rpc('igneum_getAssignedShards', [[`0x${KEY_HASH}`], WINDOW]);
|
||||
const mine = (work || []).filter(w => w.assigned && w.txCount === 0 && !w.paid && !done.has(`${w.number}/${w.shard}`));
|
||||
mine.sort((x, y) => parseInt(y.number, 16) - parseInt(x.number, 16));
|
||||
const target = mine[0];
|
||||
if (!target) { await sleep(EVERY * 1000); continue; }
|
||||
const number = parseInt(target.number, 16);
|
||||
done.add(`${target.number}/${target.shard}`);
|
||||
log(`RESULT assigned number=${number} shard=${target.shard} listed=${work.length} mine=${mine.length}`);
|
||||
const plan = await rpc('igneum_getShardPlan', [target.number]);
|
||||
const seq = await rpc('igneum_exportSegments', ['0x0', target.number]);
|
||||
writeFileSync(`${TMP}/seq.json`, JSON.stringify(seq));
|
||||
const fixture = `${TMP}/block-${number}.json`;
|
||||
const ex = run(EXPORT, [`${TMP}/seq.json`, String(number), fixture, '--source', `tn late-join network block ${number}`]);
|
||||
if (ex.code !== 0) { log(`RESULT export_failed number=${number} secs=${ex.secs} tail=${ex.out.split('\n').slice(-3).join(' | ').slice(0, 300)}`); continue; }
|
||||
log(`RESULT export number=${number} secs=${ex.secs}`);
|
||||
const results = `${TMP}/results-${number}-${target.shard}.json`;
|
||||
const pr = run(HOST, [fixture, '--mode', 'compressed', '--shard', String(target.shard), '--prover', PAYOUT, '--out', results], { SP1_PROVER: 'cpu', RUST_LOG: 'off' });
|
||||
writeFileSync(`${TMP}/prove-${number}-${target.shard}.log`, pr.out);
|
||||
if (pr.code !== 0) { log(`RESULT prove_failed number=${number} secs=${pr.secs} tail=${pr.out.split('\n').filter(l => /RESULT|rror/.test(l)).slice(-3).join(' | ').slice(0, 400)}`); continue; }
|
||||
const res = JSON.parse(readFileSync(results, 'utf8'));
|
||||
log(`RESULT prove number=${number} shard=${target.shard} secs=${pr.secs} cycles=${res.cycles} proof_bytes=${res.compressed_proof_bytes}`);
|
||||
const sg = run(MINER, ['sign-record', LABEL, CHAIN, plan.hash, String(number), String(target.shard), PAYOUT, res.statement, res.proof_sha256]);
|
||||
if (sg.code !== 0) { log(`RESULT sign_failed number=${number} out=${sg.out.slice(0, 300)}`); continue; }
|
||||
const signed = JSON.parse(sg.out.trim().split('\n').pop());
|
||||
const proofHex = '0x' + readFileSync(res.proof_file).toString('hex');
|
||||
const sub = await rpc('igneum_submitProofRecord', [{ record: signed.record, proof: proofHex }]);
|
||||
if (sub.accepted) { proved++; log(`RESULT submit number=${number} shard=${target.shard} accepted proved_total=${proved}`); } else { refused++; log(`RESULT refused number=${number} shard=${target.shard} reason=${sub.reason} refused_total=${refused}`); }
|
||||
} catch (e) {
|
||||
log(`RESULT error ${String(e.message || e).slice(0, 300)}`);
|
||||
await sleep(EVERY * 1000);
|
||||
}
|
||||
}
|
||||
163
infra/seed-nodes/testnet-object.json
Normal file
163
infra/seed-nodes/testnet-object.json
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
{
|
||||
"timestamp_deviation_tolerance": 132,
|
||||
"past_median_time_window_size": 27,
|
||||
"difficulty_window_size": 661,
|
||||
"min_difficulty_window_size": 150,
|
||||
"difficulty_rule": "igneum-dual",
|
||||
"coinbase_payload_script_public_key_max_len": 150,
|
||||
"max_coinbase_payload_len": 16384,
|
||||
"max_tx_inputs": 1000,
|
||||
"max_tx_outputs": 1000,
|
||||
"max_signature_script_len": 250000,
|
||||
"max_script_public_key_len": 10000,
|
||||
"mass_per_tx_byte": 1,
|
||||
"mass_per_script_pub_key_byte": 10,
|
||||
"mass_per_sig_op": 1000,
|
||||
"block_mass_limits": {
|
||||
"storage": 500000,
|
||||
"compute": 500000,
|
||||
"transient": 1000000
|
||||
},
|
||||
"block_lane_limits": {
|
||||
"lanes_per_block": 50,
|
||||
"gas_per_lane": 1000000000
|
||||
},
|
||||
"storage_mass_parameter": 1000000000000,
|
||||
"deflationary_phase_daa_score": 0,
|
||||
"pre_deflationary_phase_base_subsidy": 50000000000,
|
||||
"skip_proof_of_work": false,
|
||||
"max_block_level": 250,
|
||||
"pruning_proof_m": 1000,
|
||||
"blockrate": {
|
||||
"target_time_per_block": 1000,
|
||||
"ghostdag_k": 18,
|
||||
"past_median_time_sample_rate": 10,
|
||||
"difficulty_sample_rate": 4,
|
||||
"max_block_parents": 10,
|
||||
"mergeset_size_limit": 180,
|
||||
"merge_depth": 3600,
|
||||
"finality_depth": 43200,
|
||||
"pruning_depth": 108000,
|
||||
"coinbase_maturity": 100
|
||||
},
|
||||
"pre_crescendo_target_time_per_block": 1000,
|
||||
"crescendo_activation": 0,
|
||||
"genesis_bits": 487587840,
|
||||
"finality": {
|
||||
"checkpoint_interval": 30,
|
||||
"checkpoint_depth": 60,
|
||||
"weight_window": 2592000,
|
||||
"dust": 100,
|
||||
"presence_window": 240,
|
||||
"aggregators": 8,
|
||||
"equivocation_ban": 2592000,
|
||||
"min_daa": 2592000,
|
||||
"aggregator_fallback": 15,
|
||||
"certificate_fold": 6,
|
||||
"leave_delay": 3600
|
||||
},
|
||||
"pow_epoch_blocks": 3600,
|
||||
"pow_epoch_lead": 600,
|
||||
"pow_day_ms": 86400000,
|
||||
"difficulty_v2_activation_daa": 0,
|
||||
"difficulty_v3_activation_daa": 0,
|
||||
"proving_v0_activation_daa": 0,
|
||||
"finality_v3_activation_daa": 0,
|
||||
"finality_daa_rule_activation_daa": 0,
|
||||
"fork_gate_activation_daa": 18446744073709551615,
|
||||
"fork_gate_window_daa": 600,
|
||||
"peer_directory_activation_daa": 18446744073709551615,
|
||||
"subsidy_per_block_activation_daa": 0,
|
||||
"signing_bonus_activation_daa": 0,
|
||||
"signing_bonus_bps": 1000,
|
||||
"finality_leave_activation_daa": 0,
|
||||
"program_class_v3_activation_daa": 0,
|
||||
"program_class_v4_activation_daa": 0,
|
||||
"program_class_v4_signal_window_daa": 86400,
|
||||
"base_unit_decimals": 18,
|
||||
"program_class_v5_activation_daa": 0,
|
||||
"pow_genesis_dataset_log2": 28,
|
||||
"latency_ladder": [
|
||||
{
|
||||
"reps": 27,
|
||||
"admissible": true
|
||||
},
|
||||
{
|
||||
"reps": 35,
|
||||
"admissible": true
|
||||
},
|
||||
{
|
||||
"reps": 53,
|
||||
"admissible": true
|
||||
},
|
||||
{
|
||||
"reps": 88,
|
||||
"admissible": false
|
||||
},
|
||||
{
|
||||
"reps": 173,
|
||||
"admissible": false
|
||||
},
|
||||
{
|
||||
"reps": 267,
|
||||
"admissible": false
|
||||
}
|
||||
],
|
||||
"latency_ladder_activation_daa": 0,
|
||||
"latency_ladder_window_daa": 86400,
|
||||
"latency_ladder_cache_rung": {
|
||||
"mib": 512,
|
||||
"admissible": false
|
||||
},
|
||||
"latency_ladder_cache_rung_activation_daa": 0,
|
||||
"sig_scheme": 0,
|
||||
"sig_scheme_activation_daa": 0,
|
||||
"finality_succession_activation_daa": 0,
|
||||
"pow_era_blocks": 15552000,
|
||||
"pow_era_lead": 7200,
|
||||
"era_vdf_activation_daa": 0,
|
||||
"vdf_scheme": 0,
|
||||
"era_vdf_t": 108000000,
|
||||
"fees": {
|
||||
"pgas": {
|
||||
"version": 1,
|
||||
"cycles_per_pgas": 1000,
|
||||
"intrinsic_pgas_per_tx": 300,
|
||||
"modexp_base": 10,
|
||||
"modexp_per_byte_numer": 1,
|
||||
"modexp_per_byte_denom": 10
|
||||
},
|
||||
"block_proving_gas_limit": 120000,
|
||||
"shard_proving_gas_budget": 30000,
|
||||
"min_execution_base_fee_wei": 100000000000,
|
||||
"min_proving_base_fee_wei": 10000000000000,
|
||||
"initial_execution_base_fee_wei": 100000000000,
|
||||
"initial_proving_base_fee_wei": 10000000000000,
|
||||
"base_fee_change_denominator": 8
|
||||
},
|
||||
"fees_v1_activation_daa": 0,
|
||||
"proving_v1_activation_daa": 0,
|
||||
"proving_v1_segment_blocks": 8,
|
||||
"proving_v1_unproven_daa": 600,
|
||||
"proving_v1_aggregator_share_bps": 1000,
|
||||
"proving_v1_fresh_rule_daa": 0,
|
||||
"pool_split_activation_daa": 18446744073709551615,
|
||||
"exec_restart_number": 18446744073709551615,
|
||||
"exec_restart_hash": "",
|
||||
"exec_restart_trust_daa": 18446744073709551615,
|
||||
"exec_restart_state_root": "",
|
||||
"emission": {
|
||||
"launch_rate": "100000000000000000000",
|
||||
"ramp_seconds": 7776000,
|
||||
"ramp_start_percent": 10,
|
||||
"step_seconds": 2629800,
|
||||
"step_decay_q32": 4172697914,
|
||||
"tail": {
|
||||
"kind": "percent",
|
||||
"bps_per_year": 100
|
||||
}
|
||||
},
|
||||
"proving_consensus_verify_daa": 0,
|
||||
"proving_shard_program_id": "0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a",
|
||||
"proving_aggregator_id": "0x474678f35f7545db28055d5e5bbc308231d84a5a072202087a2a8d5b09123896"
|
||||
}
|
||||
|
|
@ -29,3 +29,5 @@ docs/analysis/class-v6
|
|||
# 8 October 2026: the miner window audit (an internal design record: the founder's order, lane ids, the build plan)
|
||||
docs/design/app-audit-2026-10-08.md
|
||||
docs/analysis/class-v6/coexistence-model.md
|
||||
# 8 October 2026: the Devnet 3 proving pipeline record (the fleet lane: box names, the operations record, the external review quoted)
|
||||
docs/analysis/proving-pipeline-2026-10-08.md
|
||||
|
|
|
|||
181
tools/fleet/base-unit-gate.sh
Executable file
181
tools/fleet/base-unit-gate.sh
Executable file
|
|
@ -0,0 +1,181 @@
|
|||
#!/usr/bin/env bash
|
||||
# B10 of the base-unit widening (O-2.6, docs/design/base-unit.md section 8): two nodes on the testnet params (18 decimals)
|
||||
# mine for N seconds on one box, then the coinbase, the gRPC and the execution layer must read one number.
|
||||
#
|
||||
# Runs ON igneum-build-1 (ssh build@188.40.146.49 'bash -s' < tools/fleet/base-unit-gate.sh [seconds]), against the
|
||||
# binaries of the decimals worktree's fork (target/release of /srv/builds/igneum-wt-decimals/vendor/igneum-node-decimals).
|
||||
# Ports 28110 to 28191 (nothing else on the box uses them); data under /srv/builds/_gate-decimals, wiped at the start; the
|
||||
# processes it starts are the only ones it stops (a pid file each, never a pattern). PASS is the last line.
|
||||
#
|
||||
# What it checks (each a FAIL line otherwise):
|
||||
# 1. both nodes answer and B reaches the same sink and block count as A (the p2p wire carries the wide amounts)
|
||||
# 2. at least MIN_BLOCKS blocks were mined
|
||||
# 3. igneum-miner inspect over the last 30 blocks: every coinbase's payload subsidy is above u64::MAX (18 decimals),
|
||||
# the outputs split 80/20 exactly (the UTXO side), identical on both nodes
|
||||
# 4. the execution layer: eth_getBalance of the miner's EVM address is equal on both nodes and equals the sum of the
|
||||
# producer shares the segments paid (igneum_getSegment over every chain block), which is the identity bridge
|
||||
# 5. every reward in a segment equals producer_share(block_subsidy(daa of the rewarded block itself)) under the testnet
|
||||
# schedule at 18 decimals (100 IGN a second, the 90-day ramp from 10%), computed here in exact integers (each merged
|
||||
# block its own DAA: subsidy_per_block_activation_daa 0 on the testnet since the re-cut of 7 October 2026)
|
||||
#
|
||||
# CPU note (7 October 2026, 01:5x UK): the testnet genesis bits are 2^28 expected hashes a block and the box's CPU engine
|
||||
# does 0.147 MH/s on 32 threads, so a block takes 10 to 30 minutes on CPU; the ten-minute, hundreds-of-blocks form of
|
||||
# this gate needs a GPU wave box (the fleet lane's); on the box alone run it for an hour with MIN_BLOCKS=3.
|
||||
#
|
||||
# The nodes run --nodnsseed: a gate never dials the public testnet seeds (the pod run of 8 October 2026 did, and was refused on
|
||||
# the digest; harmless, and wrong).
|
||||
# A fresh chain is never "synced" by the mining rule (its sink is the two-day-old genesis), so both nodes run with
|
||||
# --enable-unsynced-mining, as a devnet's first node does; without it every found block is Reject(IsInIBD) (seen 00:2x UK).
|
||||
#
|
||||
# Known-failed case: run with GATE_EXPECT_DECIMALS=8 against the same nodes and check 3 and 5 fail (the schedule at 8
|
||||
# does not match an 18-decimal chain). The self-test target below does that on the recorded output.
|
||||
set -euo pipefail
|
||||
SECS="${1:-600}"; MIN_BLOCKS="${MIN_BLOCKS:-60}"; THREADS="${THREADS:-48}"
|
||||
BIN="${BIN:-/srv/builds/igneum-wt-decimals/vendor/igneum-node-decimals/target/release}"
|
||||
ROOT="${ROOT:-/srv/builds/_gate-decimals}"; A_RPC=28110; A_P2P=28111; A_EVM=28190; B_RPC=28120; B_P2P=28121; B_EVM=28191
|
||||
# WORKER=<path to igneum-worker-cuda|igneum-worker-opencl>: the GPU form (a wave box; the fleet lane rents it): the first
|
||||
# pack is exported from node A, the worker serves it and the miner prepares the next seeds; THREADS is then ignored
|
||||
WORKER="${WORKER:-}"
|
||||
EVM_ADDR="00000000000000000000000000000000000000aa"
|
||||
fail=0
|
||||
say() { echo "$(date -u +%H:%M:%SZ) gate: $*"; }
|
||||
die() { say "FAIL: $*"; fail=1; }
|
||||
stop_all() {
|
||||
for p in "$ROOT"/*.pid; do [ -f "$p" ] && kill "$(cat "$p")" 2>/dev/null || true; done
|
||||
sleep 2
|
||||
}
|
||||
trap stop_all EXIT
|
||||
rm -rf "$ROOT"; mkdir -p "$ROOT/a" "$ROOT/b"
|
||||
[ -x "$BIN/igneumd" ] && [ -x "$BIN/igneum-miner" ] || { echo "FAIL: binaries missing in $BIN"; exit 2; }
|
||||
|
||||
say "starting node A (testnet params, 18 decimals)"
|
||||
"$BIN/igneumd" --testnet --netsuffix=1 --nodnsseed --enable-unsynced-mining --appdir="$ROOT/a" --listen=127.0.0.1:$A_P2P --rpclisten=127.0.0.1:$A_RPC --evm-rpclisten=127.0.0.1:$A_EVM --outpeers=1 --loglevel=info > "$ROOT/a.log" 2>&1 &
|
||||
echo $! > "$ROOT/a.pid"
|
||||
sleep 3
|
||||
grep -m1 "Base unit" "$ROOT/a.log" || true
|
||||
if grep -q -E "refusing to start|panicked" "$ROOT/a.log"; then die "node A did not start: $(grep -m1 -E 'refusing|panicked' "$ROOT/a.log")"; exit 1; fi
|
||||
say "starting node B, connected to A"
|
||||
"$BIN/igneumd" --testnet --netsuffix=1 --nodnsseed --enable-unsynced-mining --appdir="$ROOT/b" --listen=127.0.0.1:$B_P2P --rpclisten=127.0.0.1:$B_RPC --evm-rpclisten=127.0.0.1:$B_EVM --connect=127.0.0.1:$A_P2P --loglevel=info > "$ROOT/b.log" 2>&1 &
|
||||
echo $! > "$ROOT/b.pid"
|
||||
for i in $(seq 1 60); do
|
||||
if curl -s -m 2 -X POST -H 'content-type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' http://127.0.0.1:$A_EVM | grep -q result \
|
||||
&& curl -s -m 2 -X POST -H 'content-type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' http://127.0.0.1:$B_EVM | grep -q result; then break; fi
|
||||
sleep 2
|
||||
done
|
||||
if [ -n "$WORKER" ]; then
|
||||
say "nodes up; exporting the first pack from A, then mining $SECS s on the GPU worker $WORKER, payout to 0x$EVM_ADDR"
|
||||
mkdir -p "$ROOT/packs/first" "$ROOT/packs/prepare"
|
||||
"$BIN/igneum-miner" export-pack grpc://127.0.0.1:$A_RPC "$ROOT/packs/first" > "$ROOT/pack.log" 2>&1 || say "export-pack exit $? (the testnet address prefix; the miner prepares the pack itself)"
|
||||
timeout -s TERM "$((SECS + 180))" "$BIN/igneum-miner" mine grpc://127.0.0.1:$A_RPC 1 "$SECS" gate --worker "$WORKER" --worker-args "--pack $ROOT/packs/first" --prepare-packs "$ROOT/packs/prepare" --network testnet --exec-rpc "http://127.0.0.1:$A_EVM" --payout-label gate --evm-address "$EVM_ADDR" --dev-fee 0 --status-secs 60 > "$ROOT/miner.log" 2>&1 || say "miner exit $?"
|
||||
else
|
||||
say "nodes up; mining $SECS s with $THREADS CPU threads on A, payout to 0x$EVM_ADDR"
|
||||
nice -n 19 timeout -s TERM "$((SECS + 180))" "$BIN/igneum-miner" mine grpc://127.0.0.1:$A_RPC "$THREADS" "$SECS" gate --engine igneum-pow --network testnet --exec-rpc "http://127.0.0.1:$A_EVM" --payout-label gate --evm-address "$EVM_ADDR" --dev-fee 0 --status-secs 60 > "$ROOT/miner.log" 2>&1 || say "miner exit $?"
|
||||
fi
|
||||
sleep 5
|
||||
say "miner done; last status: $(grep -E "blocks|found|accepted" "$ROOT/miner.log" | tail -1 | cut -c1-200)"
|
||||
# block one on a fresh class v5 chain (the test that was missing, 8 October 2026: with class v5 from genesis the miner needs the
|
||||
# state stream after the genesis seed block, which an executor that has executed nothing never published; 6e04f7fc publishes it
|
||||
# at start). The lines printed: the node's genesis-stream line, the miner's first found block, and the first class v5 refusal if
|
||||
# any; no block at all is the known-failed case of that fix and fails here, never a silent pass
|
||||
# the miner lines above run under a wall-clock timeout of SECS + 180: the miner's window counts mining, not waiting, so a
|
||||
# miner refused at genesis would otherwise sit in its two-second loop for ever (eight minutes by hand on the pod run above)
|
||||
say "block one: node A genesis stream: $(grep -m1 -E "state stream after genesis" "$ROOT/a.log" | cut -c1-200)"
|
||||
say "block one: miner first block: $(grep -m1 -E "ACCEPTED block" "$ROOT/miner.log" | cut -c1-200)"
|
||||
say "block one: first class v5 refusal: $(grep -m1 -E "class v5 needs the execution state" "$ROOT/miner.log" | cut -c1-200)"
|
||||
BLOCK_ONE=$(evm0() { curl -s -m 5 -X POST -H 'content-type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' "http://127.0.0.1:$A_EVM"; }; evm0 | python3 -c "import sys,json; print(int(json.load(sys.stdin).get('result','0x0'),16))" 2>/dev/null || echo 0)
|
||||
say "block one: node A exec tip after mining: $BLOCK_ONE"
|
||||
# a tip of 0 ends the run here with exit 1: the later checks abort under set -e at zero blocks before the final FAIL line, so
|
||||
# the exit code read 0 on the pod run of 15:32 UK, 8 October 2026 (the known-failed form of 6e04f7fc, ba294c98's pair)
|
||||
if [ "$BLOCK_ONE" -le 0 ]; then say "FAIL: block one was never mined on a fresh class v5 chain (exec tip 0): the genesis state stream is not published (row 9v of docs/plans/testnet-go.md)"; exit 1; fi
|
||||
|
||||
say "check 1: both nodes agree"
|
||||
"$BIN/igneum-miner" watch 6 grpc://127.0.0.1:$A_RPC grpc://127.0.0.1:$B_RPC > "$ROOT/watch.log" 2>&1 || true
|
||||
tail -2 "$ROOT/watch.log" | cut -c1-240
|
||||
evm() { curl -s -m 5 -X POST -H 'content-type: application/json' --data "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"$2\",\"params\":$3}" "http://127.0.0.1:$1" ; }
|
||||
TIP_A=$(evm $A_EVM eth_blockNumber '[]' | jq -r .result); TIP_B=$(evm $B_EVM eth_blockNumber '[]' | jq -r .result)
|
||||
say "exec tips: A $TIP_A B $TIP_B"
|
||||
[ "$TIP_A" = "$TIP_B" ] || die "exec tips differ (A $TIP_A, B $TIP_B)"
|
||||
BLOCKS=$((TIP_A))
|
||||
[ "$BLOCKS" -ge "$MIN_BLOCKS" ] || die "only $BLOCKS chain blocks, wanted $MIN_BLOCKS"
|
||||
|
||||
say "check 3: the UTXO side over the last 30 blocks on both nodes"
|
||||
"$BIN/igneum-miner" inspect 30 grpc://127.0.0.1:$A_RPC grpc://127.0.0.1:$B_RPC > "$ROOT/inspect.log" 2>&1 || true
|
||||
grep -c "same_on_all_nodes=true" "$ROOT/inspect.log" | sed 's/^/ blocks identical on both nodes: /'
|
||||
if grep -q "MISMATCH" "$ROOT/inspect.log"; then die "80/20 mismatch: $(grep -m1 MISMATCH "$ROOT/inspect.log")"; fi
|
||||
if grep -q "same_on_all_nodes=false" "$ROOT/inspect.log"; then die "a block differs between the nodes"; fi
|
||||
python3 - "$ROOT/inspect.log" "${GATE_EXPECT_DECIMALS:-18}" <<'PY' || die "a coinbase payload subsidy is not the 18-decimal schedule (see above)"
|
||||
import re, sys
|
||||
decimals = int(sys.argv[2]); UNIT = 10 ** decimals
|
||||
launch_rate = 100 * UNIT; ramp_seconds = 90 * 86400; start = 10; bps = 1
|
||||
def subsidy(daa):
|
||||
s = daa // bps; full = launch_rate // bps
|
||||
return full if s >= ramp_seconds else full * (start * ramp_seconds + (100 - start) * s) // (100 * ramp_seconds)
|
||||
n = bad = 0
|
||||
for line in open(sys.argv[1]):
|
||||
m = re.search(r" daa=(\d+) .*subsidy_in_payload=(\d+)", line)
|
||||
if not m: continue
|
||||
n += 1; daa, got = int(m.group(1)), int(m.group(2))
|
||||
if got != subsidy(daa):
|
||||
bad += 1
|
||||
if bad <= 3: print(f" daa {daa}: payload subsidy {got} != schedule {subsidy(daa)}")
|
||||
print(f" payload subsidies checked {n}, wrong {bad}; one IGN = {UNIT}; day-0 block = {subsidy(0)} base units")
|
||||
assert n > 0 and bad == 0
|
||||
PY
|
||||
tail -1 "$ROOT/inspect.log" | cut -c1-200
|
||||
|
||||
say "check 4 and 5: the execution layer, both nodes, against the schedule"
|
||||
BAL_A=$(evm $A_EVM eth_getBalance "[\"0x$EVM_ADDR\",\"latest\"]" | jq -r .result); BAL_B=$(evm $B_EVM eth_getBalance "[\"0x$EVM_ADDR\",\"latest\"]" | jq -r .result)
|
||||
say "balances: A $BAL_A B $BAL_B"
|
||||
[ "$BAL_A" = "$BAL_B" ] || die "balances differ between the nodes"
|
||||
: > "$ROOT/segments.jsonl"
|
||||
for n in $(seq 1 "$BLOCKS"); do evm $A_EVM igneum_getSegment "[\"$(printf '0x%x' "$n")\"]" >> "$ROOT/segments.jsonl"; echo >> "$ROOT/segments.jsonl"; done
|
||||
python3 - "$ROOT/segments.jsonl" "$BAL_A" "$EVM_ADDR" "${GATE_EXPECT_DECIMALS:-18}" <<'PY' || die "the execution layer does not match the schedule (see above)"
|
||||
import json, sys
|
||||
path, bal_hex, addr, decimals = sys.argv[1], sys.argv[2], sys.argv[3].lower(), int(sys.argv[4])
|
||||
UNIT = 10 ** decimals
|
||||
# the testnet schedule (EmissionSchedule::TESTNET_1 rescaled to the unit): 100 IGN a second, a 90-day ramp from 10 percent,
|
||||
# the first monthly glide step is far beyond a ten-minute gate
|
||||
launch_rate = 100 * UNIT; ramp_seconds = 90 * 86400; start = 10; bps = 1
|
||||
def ramp(full, s):
|
||||
if s >= ramp_seconds: return full
|
||||
return full * (start * ramp_seconds + (100 - start) * s) // (100 * ramp_seconds)
|
||||
def subsidy(daa): return ramp(launch_rate // bps, daa // bps)
|
||||
def producer(a):
|
||||
q, r = divmod(a, 100); pool = q * 20 + r * 20 // 100
|
||||
return a - pool
|
||||
total = 0; checked = 0; bad = 0
|
||||
for line in open(path):
|
||||
line = line.strip()
|
||||
if not line: continue
|
||||
r = json.loads(line).get("result")
|
||||
if not r:
|
||||
print(" segment query failed:", line[:120]); bad += 1; continue
|
||||
# each blue block of the mergeset is credited the subsidy of ITS OWN DAA (subsidy_per_block_activation_daa 0 on
|
||||
# the testnet, ledger N8, 7 October 2026); the rewards list runs in the mergeset's blue order, one entry per blue
|
||||
# block. Before N8 every reward was the chain block's subsidy and 165 of 561 segments read one ramp step low
|
||||
# (the pod run of 09:4x UK, 7 October 2026: the known-failed shape of this check).
|
||||
def daa_of(m):
|
||||
return int(m["daaScore"], 16) if isinstance(m["daaScore"], str) else int(m["daaScore"])
|
||||
blues = [m for m in r["mergeset"] if m["blue"]]
|
||||
if len(blues) != len(r["rewards"]):
|
||||
print(f" segment {r['number']}: {len(blues)} blue blocks but {len(r['rewards'])} rewards"); bad += 1; continue
|
||||
for m, w in zip(blues, r["rewards"]):
|
||||
if w["miner"].lower() != m["miner"].lower():
|
||||
print(f" segment {r['number']}: reward {w['miner']} is not the blue block's miner {m['miner']}"); bad += 1; continue
|
||||
daa = daa_of(m)
|
||||
expect = producer(subsidy(daa))
|
||||
wei = int(w["wei"], 16) if isinstance(w["wei"], str) else int(w["wei"])
|
||||
checked += 1
|
||||
if wei != expect:
|
||||
bad += 1
|
||||
if bad <= 3: print(f" segment {r['number']}: reward {wei} != expected {expect} (block daa {daa})")
|
||||
if w["miner"].lower().removeprefix("0x") == addr: total += wei
|
||||
bal = int(bal_hex, 16)
|
||||
print(f" rewards checked {checked}, wrong {bad}; sum of our rewards {total}; eth_getBalance {bal}; one IGN = {UNIT}")
|
||||
print(f" balance in IGN (8 visible digits): {bal // UNIT}.{(bal % UNIT) // (UNIT // 10**8):08d}")
|
||||
assert bad == 0, "a reward disagrees with the schedule"
|
||||
assert total == bal, "the balance is not the sum of the rewards (the bridge is not the identity)"
|
||||
assert bal > 18446744073709551615, "the balance fits a u64: not an 18-decimal chain"
|
||||
PY
|
||||
|
||||
if [ "$fail" = 0 ]; then say "PASS: two nodes at 18 decimals, $BLOCKS chain blocks, the coinbase, the gRPC and the execution layer read one number"; else say "FAIL (see above)"; exit 1; fi
|
||||
Loading…
Reference in a new issue