Merge remote-tracking branch 'origin/master' into site-ui-5

This commit is contained in:
igneum-josh 2026-10-07 17:30:59 +01:00
commit d79ed8120b
29 changed files with 1081 additions and 62 deletions

View file

@ -0,0 +1,86 @@
# The era VDF: built, measured and gated (7 October 2026)
Era VDF lane, 7 October 2026, from the attack pass's F7 row (`docs/analysis/attack-pass/f7-era.md`, sub-row a: the node's era seed was a plain chain block hash, grindable with one block of hash at no delay, and the 1-hour VDF of spec 04 section 4.4 did not exist in the node). Repository branch `era-vdf` (this record, the spec text, the harness `tools/era-vdf/`, the fast-time fields); node fork branch `era-vdf-node` on the 0.3.19 line (`release-0.3.19-node` dc141409). Every number below names its log on igneum-build-1 under `/srv/builds/igneum-wt-era-vdf/ev-*/`.
## 1. What was built
| Piece | Where | What |
|---|---|---|
| The integer | `consensus/core/src/era_vdf/bigint.rs` | a fixed-width signed integer (40 limbs, 2,560 bits) on the stack: add, sub, mul, shifts, Knuth division with floor, truncated, exact and Euclidean remainders, the extended gcd and the partial extended gcd with Lehmer's word steps (chiavdf `xgcd_partial.c`), modpow, sqrt and the fourth root, Miller-Rabin with the first 30 primes as bases; every operation checked against `num-bigint` on 20,000 random operands of the class group's sizes, the known-failed shapes first |
| The class group | `era_vdf/classgroup.rs` | `proto-vdf/src/classgroup.rs` (3 October 2026) on the fixed-width integer: NUDUPL and NUCOMP ported line by line from chiavdf's `qfb_nudupl` and `qfb_nucomp`, the plain duplication and Cohen 5.4.7 kept as the oracles the tests hold them to on random forms at 256, 512 and 1,024 bits; serialization as sign byte plus fixed width, 258 bytes a form |
| Wesolowski | `era_vdf/wesolowski.rs` | eval with serialized checkpoints (at most 2^16, 17 MB), the 12-bit-digit block prover bucketed per residue class and parallel over them, the naive prover as the oracle, verify; T + 1, another y, another pi and another input refused |
| The hash chain | `era_vdf/hashchain.rs` | scheme 1: T sequential SHA-256 applications from a tagged start; verification by recomputation; one step short refused |
| The scheme byte and the seed | `era_vdf/mod.rs` | `vdf_scheme` 0 and 1, `EraVdfProof` and its wire form, `era_vdf_input` (the chain's BLAKE2b keyed `IgneumEraVdfInput` over `chain_id || n || the day's blue hashes`), `era_seed_of` = SHA-256 of the scheme byte, the input, T and y |
| The switch | `consensus/core/src/config/params.rs`, `igneum.rs` | `pow_era_blocks` and `pow_era_lead` as override fields (the constants everywhere; in the digest when they differ), `era_vdf_activation_daa` (never), `vdf_scheme` (0), `era_vdf_t` (the reference T); the three in the digest once the activation is set (the 0.3.15 rule); installed with the PoW schedule |
| The node side | `consensus/src/processes/era_vdf.rs`, `model/stores/era_vdf.rs` | the cut rule (the chain block below the cut, memoised and re-validated by reachability), the day-of-blues input (memoised per cut block), the evaluator thread started by the virtual processor a quarter of the lead past the cut, the record store (one row per era), the header processor's wait when a header arrives before the record, the template's `era_seed` None while evaluating, `submit` for a record from outside (verified against this chain's input) |
| The template and the miner | `PowEpochInfo`, `RpcPowEpochInfo`, `rpc.proto` fields 37 to 44, `igneum-miner` | the era schedule, the VDF's state, scheme, T and input in every template; the miner holds while the node reports no era seed ("era VDF: the node is still evaluating"); `igneum-miner vdf bench|eval|verify` with the node's own code |
| The harness | `tools/era-vdf/reroll.mjs` | the F7 re-roll harness against the REAL era cut (era 120 DAA, lead 20 on the merged fast-time file; ports 30100 and up, suffix 1010), `--vdf off` the stand-in, `--vdf on` the VDF at a fast T, the adversary running the node's evaluator over its candidate before publishing |
## 2. The parameters
Measured 7 October 2026 on igneum-build-2 (AMD EPYC 9454P, 96 threads, Ubuntu 24.04), one core under `/srv/builds/_bin/lease cores 31` at nice 10 while the box ran other lanes' suites (load 25 to 75), with the node's own code (`igneum-miner vdf bench`, logs `ev-vdf-bench3.log`, `ev-vdf-bench5.log` in this lane's scratch) and chiavdf 7e62ce14 built on the box against GMP 6.3.0 (`ev-chiavdf`).
| Parameter | Value | Label |
|---|---|---|
| Group | class group, 1,024-bit prime discriminant `D = -HashPrime("igneum-era-discriminant" \|\| input)`, `\|D\| = 7 mod 8` | Implemented (spec 4.2) |
| Generator, Fiat-Shamir prime, proof plan | `(2, 1, (1 - D) / 8)`; 256 bits; 12-bit digits, at most 2^16 serialized checkpoints (17 MB) | Implemented |
| Proof on the wire | 529 bytes: scheme (1), T (8), two 258-byte forms with 2-byte lengths; `y` and `pi` 258 bytes each | Measured |
| Scheme byte | `vdf_scheme` 0 = class group, 1 = hash chain; genesis 0 everywhere | Implemented |
| Reference rate, scheme 0 | 30,589 and 40,117 squarings/s in two 10-s runs on the box core (the spread is the box's load); 30,000 is the reference | Measured |
| `T_era`, scheme 0 | 3,600 x 30,000 = 108,000,000 squarings (`ERA_VDF_T_CLASS_GROUP`): 60 min at the reference, 45 at the faster run | Measured, set |
| Prove, scheme 0 | eval + prove 11.6 s at T 401,167 (eval 10.0 s): the single-thread block prover is about 14 percent of the evaluation, parallel over residue classes in the node (up to 8) | Measured |
| Verify, scheme 0 | 21.9 and 22.6 ms with the group held (mean of 20); 184 ms with the discriminant derived, the derivation being 161 to 167 ms, once per era | Measured (section 5 for the gate) |
| Reference rate, scheme 1 | 16.2 and 17.0 million SHA-256/s (SHA-NI); 16,000,000 is the reference; `T_era` = 57,600,000,000 hashes (`ERA_VDF_T_HASH_CHAIN`) | Measured, set |
| Verify, scheme 1 | recomputation: 10.1 s for T 170 million, the full hour at `T_era` | Measured |
| Discriminant search | 161 to 167 ms per era (Miller-Rabin with the first 30 primes on the fixed-width integer) | Measured |
| chiavdf on the same core | 208.8 K squarings/s (`vdf_bench square`, NUDUPL over GMP, 1,000,000 iterations); the AVX-512 IFMA path (`square_asm`) gave 127.3 K at 20,000 iterations and stalled at 300,000 and above in this build (built outside its Makefile's `FAST_MACHINE` flags), so the IFMA number is not established here | Measured; the asm path unestablished |
| Delay on the fastest prover measured | 108,000,000 / 208,800 = 517 s against the 1-s block interval (517x) and the 2-s publish window (259x); a prover 10x chiavdf's GMP path (the ceiling Chia's and the EF's hardware efforts aimed at, approximate, from memory) would still take 52 s, 26x the window | Computed from the measurements |
| The gate "at least 60x one block interval on the fastest known prover" | 517x on chiavdf's GMP path, the fastest evaluator measured on this hardware; PASS as measured, with the IFMA path unestablished (above) and the 10x hardware ceiling still 52x | PASS (measured), caveat recorded |
The node's own evaluator is 5.2 to 6.8x slower than chiavdf's GMP path on the same core. That ratio only moves the honest side: `T_era` is set from the node's rate, so an honest node finishes in the hour; the attacker's margin is the delay at the fastest prover, above.
## 3. The gate: the re-roll harness with the VDF off and on
`tools/era-vdf/reroll.mjs` on igneum-build-2 (the box under other lanes' suites, nice 10; logs and per-cut JSON under `/srv/builds/igneum-wt-era-vdf/ev-harness-out/reroll-vdf-{on,off}-5.json`), three nodes of the fork at this record's commit on the fast-time file with `skip_proof_of_work`, era 120 DAA and lead 20 (cuts at `S = 120 n - 20`, one every two minutes), ports 30100 and up, suffix 1010; two honest virtual miners share 1 block/s on nodes 0 and 1; the adversary on node 2 holds a block A built on the tip at `S - 1` and tries to make it the era's cut block. With the VDF on, the adversary runs the node's own evaluator (`igneum-miner vdf eval`, the network's T) over its candidate before publishing; T is set from a 3-s bench at the start so the delay is about 5 s on one core of this box, five times the block interval.
| Run | Switch | Cuts | A accepted | A became the cut block | Known-draw re-rolls (the seed the adversary knew before publishing is the era's seed) | Adversary's evaluation | Nodes agree on the era seed | Gate | Harness |
|---|---|---|---|---|---|---|---|---|---|
| vdf-off-5 (the stand-in, 15:08 to 15:22 UTC) | `era_vdf_activation_daa` never | 6 (eras 2 to 7) | 6 of 6 | 6 of 6 | 6 of 6: the seed is `hash(A)` every time | none needed: the draw of hash(A) is known the instant A is built | 3 of 3 on every era | FAIL (the known-pass fires) | SOUND |
| vdf-on-5 (the era VDF, 14:54 to 15:08 UTC) | activation 0, scheme 0, T 189,650 (5 s on this core) | 6 (eras 2 to 7) | 6 of 6 | 0 of 6 | 0 of 6 | 5.38 to 6.64 s, during which the honest chain advanced 3 to 10 blocks; A arrived behind them and never became the cut block | 3 of 3 on every era, the record ready (state 3) at every era start | PASS (silent) | SOUND |
What the two runs say. Under the stand-in a miner with one block of hash at the right second owns the era draw outright on this network: holding the block at `S - 1` and publishing it the moment the chain reaches `S - 1` makes it the cut block in every one of six cuts (the attack lane's epoch-cut run saw 1 of 6, with the honest block often landing first; here the adversary is faster to the second), and its draw is known the instant the block is built. Under the VDF the same adversary cannot know any candidate's draw before T steps have run; while it ran them the honest chain moved 3 to 10 blocks, so its block arrived behind the cut and the seed came from the delay over the day of blues ending at the honest cut block, the same on all three nodes. The second half of the written argument of F7 (a) holds in the node, not only on paper: the re-roll needs the draw inside the window, and the window is 1 s against a delay of 5 s here and 517 s at the fastest prover measured on the production T (section 2).
The known-pass and the known-fail ran on the same binaries, file and ports, the VDF switch the only difference. A first VDF-off run with a lookup defect in the harness (the adversary's block not found, so the verdict read "held") was discarded once the node's own log showed the adversary's hash as the cut block in 5 of 5 cuts; the harness now reads A from that log line. Two runs that overlapped on the box through a stale node of an earlier run were discarded as well (their nodes disagreed because they were two networks); the two runs above ran alone.
## 4. The cut rule and the certified checkpoint (for the finality lane)
The node names `C_era(n)` as the last selected-chain block below the cut on the header's own chain (the block the stand-in used), which is the checkpoint block the lead rule names under the O-4.3 decision of 3 October 2026 (certified or not). Three facts decide it:
1. Determinism. A header's validity must be a function of its own past. "The certificate carried by a block in the header's past, for the highest-index checkpoint with DAA score at most the cut" is such a function, but a certificate that lands after the era starts flips the reading between headers of one era (a header before the carrier reads the fallback, a header after it reads the certificate), so the certified binding needs a second rule: the carrier must sit at most half a lead above the cut (3,600 DAA s, the merge depth) and be a chain ancestor of the header; under that rule every honest header of the era reads the same certificate once the network merged the carrier, and a header on a chain that never merged it reads the fallback, consistently with its own past. The chain-block reading needs no second rule.
2. Liveness. A finality pause across the cut (a third of weight leaving in an hour is a 30-day pause under rule v3) leaves the certified binding without a checkpoint for the era; the chain-block reading always has one, which is the reason O-4.3 was decided the way it was for the epoch.
3. The defence. The grinding defence is the delay: no candidate's draw is knowable for T steps, whichever block is the cut. The binding moves which block a withholder would have to be the author of, not whether withholding pays; both readings leave the withholder with a coin flip it cannot see.
The change, if the finality lane wants the certified binding: `EraVdfManager::cut_block` (one function; the input, the delay and the seed are unchanged), plus the second rule above and a test with a certificate carried late.
## 5. The verify gate on a 2019-class core
The gate was "the VDF verifies in under 10 ms on a 2019-class core". Measured: 21.9 and 22.6 ms with the group held on the box core (above), which the F6 row's calibration puts at about 1.19x on an i7-9700K (the O-1.14 run: 6.0 ms on the 2019 core against 5.06 ms on the box proxy), so about 26 ms on a 2019 core, labelled a proxy: no 2019 host was rented this lane (Vast rentals are a purchase; not made without Josh's word). NOT MET, by 2.2x on the box and about 2.6x on the proxy.
Where the time goes and what closes it: a verification is two 256-bit exponentiations, about 770 group operations at 28 µs each; the operation is NUDUPL on the fixed-width integer, whose cost is the extended gcd (Lehmer rounds on 8-limb numbers) and the reduction. Three rounds of this lane moved it from 345 µs (a Lehmer convention defect that fell back to plain division every round) to 28 µs (the convention, i64 word division, 34 limbs, the x86-64 128-by-64 division); the next 2.2x is chiavdf's Pulmark reducer (reduce only when `a` exceeds 8 limbs, O-4.6) and a limb-level NUDUPL that keeps the partial gcd's intermediates in words, or GMP through `rug` behind a feature on the x86-64 Linux and Windows builds (chiavdf's 208 K/s is 6x this evaluator, which would put the verification near 4 ms as the prototype measured), with the fixed-width path the fallback for wasm and macOS. Owed, not blocking: the verification runs once per era (180 days) on a node that imports a record rather than evaluating; every mining node evaluates and never verifies.
## 6. Consequences per tier (the standing rule of 5 October 2026)
| Tier | What the era VDF costs | What it means |
|---|---|---|
| A home miner, any card (8, 12, 16, 24 or 32 GB), any vendor, Windows, Linux or macOS | one CPU core for about 60 min once per 180 days at the reference rate (a 2019-class desktop core about 72 min by the F6 calibration), 17 MB of host RAM for the prover's checkpoints during it, 0 bytes on the card; the node starts it a quarter of the lead past the cut and holds the record from then | nothing changes on the card or in the hash rate; the 2-hour lead covers a core half the reference speed; a node that was off across the cut evaluates on arrival and its miner holds until the record lands (the miner says so every 10 s) |
| A rig (one node, several cards) | the same one core on the rig's host, once per era | nothing per card |
| A pool user | the pool's node evaluates; the member's miner takes `era_seed` from the template as today | nothing |
| A light client or a syncing node | verifies an imported record in 22 ms (26 ms on a 2019 core, proxy) plus the 165-ms discriminant derivation, once per era; under scheme 1 it recomputes the hour | the 10-ms gate is missed (section 5); operationally one verification per 180 days |
| The protocol | the era draw's input is unknowable for 517 s on the fastest prover measured, against a 1-s block interval: the stand-in's one-block grind is closed (section 3) | the freeze of the draw procedure and the C_era cut rule no longer waits on the VDF's existence; it waits on the two decisions of `ledger-decisions.md` |
## 7. What is owed
- The P2P relay of an era record to a syncing peer and the RPC import (spec 4.5, O-4.10), before era 1 of any network with the switch set.
- The external review of the class-group port (O-4.1): the port is a second implementation checked against the textbook algorithms and `num-bigint`, not a review.
- The attack pass's F7 status row (branch `attack-pass`, `docs/analysis/attack-pass-2026-10.md`) reads INCOMPLETE pending this lane; the line for it, from section 3: "F7 (a): the era VDF is in the node (fork `era-vdf-node`); the re-roll harness against the real era cut fires with it off (6 of 6 cuts, the seed the adversary's block) and is silent with it on (0 of 6 across six cuts, the adversary's 5-s evaluation against a 1-s block interval, three nodes agreeing on every era seed); PASS, the delay 517 s on the fastest prover measured at the production T."
- The decisions of `docs/plans/ledger-decisions.md` (the activation per network, the cut's binding).

View file

@ -2652,3 +2652,32 @@ in the same shape and reports a box behind its wanted binary.
| Rig | the same, and a rig that leaves is itself a weight removal: at 459 MH/s on tonight's devnet it is about 20 percent of the weight, over the hour's budget by itself |
| Pool | a pool node is one voter carrying its members' whole weight; a pool restart is the largest single removal on the network and must be sliced like the fleet's |
| The network | finality by miner weight is only as steady as the miners' uptime; until public hash dwarfs the fleet, the fleet's supervisor is a consensus component |
## 7 October 2026, the first 16 GB card: an RTX 5060 Ti in a Thunderbolt enclosure on PC 2 (branch bench-5060ti)
Machine: PC 2 (`1ccfe586`, Windows 11), an ASUS Dual GeForce RTX 5060 Ti (16 GB GDDR7, Blackwell sm_120, PnP `PCI\VEN_10DE&DEV_2D04&SUBSYS_8A111043`) in a Razer Core X V2 Thunderbolt enclosure ("USB4 Router (2.0), Razer - Core X V2", bus `0B:00.0`), beside the RTX 5090 on its own supply; NVIDIA driver 610.47 (WDDM 32.0.16.1047, the 5090's driver, nothing installed for the new card); the installed app 0.3.19 and its own `igneum-worker-cuda.exe` (NVRTC 12.8). Jobs `fetch-5060ti-packs-20261007` (the kit: `tools/bench-5060ti/make-kit.sh`, the class v4 pack at sub-version 1 and the v3 control, sha256 `fd8393ed...`, 105,892 bytes) and `run-5060ti-bench-20261007-b` (`tools/bench-5060ti/pc2-5060ti-bench.ps1`, 14:44:19Z, ran 14:45:05 to 14:58:11Z, exit 0; the 5060 Ti alone through the runner's `--cards-off`, the 5090 mining throughout; run `-a` died in 1 s on an argument-binding fault in the nvidia-smi query and is void). PC 2 lost power twice that day, so the job WRITES NO POWER LIMIT: it reads `power.limit` against `power.default_limit` (180 W = 180 W, range 150 to 198 W) and the row says `limit_is_stock=yes`. Read back with `node tools/jobs.mjs run-5060ti-bench-20261007-b`.
**Detection** (the app's first poll after the restart, run `win-1ccfe586-20261007-143611`, 14:36:16Z): `GPUs: NVIDIA GeForce RTX 5090 (CUDA); NVIDIA GeForce RTX 5060 Ti (CUDA); AMD Radeon(TM) Graphics (OpenCL, gfx1036)` and `cards: NVIDIA GeForce RTX 5090 [discrete, off] | NVIDIA GeForce RTX 5060 Ti [discrete, off] | ...`; the app started a miner on it by itself (`nvidia-1ccfe586-2`, `--device 1`, 8 identities, the default). The kind reads `discrete`, not `external`: the app does not know it is an eGPU. nvidia-smi in the job: index 1, 16,311 MiB, PCIe link gen 4 x4 current against gen 4 x16 maximum (the Thunderbolt link: a quarter of the slot's lanes), 43 C idle. The freeze lane's cause class for the 15:10 UK hang on the first boot with the card: not the card (no TDR, no Thunderbolt or PCIe link event; Kernel-Power 41 + 6008, no bugcheck, the power shape again).
**G1 and the window** (the installed CUDA worker, `--bench --batch-log2 24 --block-warps 1`, the card alone, `CUDA_VISIBLE_DEVICES` on its UUID so every row names the device; nvidia-smi every 2 s on the card, the loaded samples at utilisation 90 percent and over):
| Pack | Dispatches of 2^24 | Self-test | Fingerprint 2^24 at base 0 | MH/s |
|---|---|---|---|---|
| mx8-devnet-epoch0 (the class v3 control) | 5 | PASS | 90f794dd556f7a3b (= the control everywhere) | 30.895 |
| v4-devnet-epoch0 (class v4, sub-version 1, program id 1a4230699a6b9c60) | 5 | PASS | 867dbc45cfb36b4d (= Metal, Apple OpenCL, the RTX 5090) | 30.879 |
| v4-devnet-epoch0, the 10-minute window at the stock limit | 1,105 (602 s) | PASS | 867dbc45cfb36b4d | 30.882 |
| Row | Value |
|---|---|
| NVIDIA RTX 5060 Ti 16 GB, class v4, CUDA (NVRTC), driver 610.47, PCIe 4.0 x4 through the enclosure | 30.9 MH/s over 10 minutes on the card alone |
| Watts at the stock limit (180 W default, unchanged) | 114.8 W mean, 115 W p50 over the window; 0.269 MH/W; SM 2,753 MHz, memory 13,801 MHz, 60 C maximum |
| The class v4 shadow against the control | 0.1 percent (the 5090 paid 0.2, the 9070 XT 3, the B580 0.1) |
| The efficient point | OWED to the app's Ember Tune: nothing set by the job; PC 2's Power Helper refused every request since the restart ("the helper did not run sequence 0 within 15 s", 14:39Z), so no ladder ran on either card |
| Prove beside the miner (16 GB tier) | BLOCKED, not measured: the shipped WSL2 host (sha `71bc2438...`) carries no `IGNEUM_CUDA_DEVICE` selector, so aimed at anything it proves on CUDA device 0 (the 5090) through the app's own socket `/tmp/sp1-cuda-0.sock`; the selector lives in the prover-floor host (`proof_system.rs`, branch prover-floor) and is the owed cut. The job's inventory: the floor server IS on PC 2 (`/opt/igneum-floor/bin/sp1-gpu-server`, 6.8.1 build `e911facb...`, 166,665,880 bytes) beside the stock one (`~/.sp1/bin`, `c2642ad1...`), WSL sees the card as CUDA device 1 |
| Card-picker entry (`site/yourcard.js`) | `['NVIDIA RTX 5060 Ti', 30.9]`, added; the public table row in `site/miner-bench.json` |
Against the 5090 on the same PC (122 MH/s at 308 W, 0.396 MH/W): 25.3 percent of its hash at 37 percent of its draw, 68 percent of its hash per watt. The dependent-read ceiling was not probed (the memprobe step is not in this job); at 128 loads a hash 30.9 MH/s is 3.95 G dependent reads a second, between the 9070 XT (2.4 to 2.7 G) and the 5090 (16 to 18 G).
Consequences per tier (the rule of 5 October 2026): a 5060 Ti owner (16 GB, Windows) mines at 30.9 MH/s and 115 W from the box with nothing to set: about 5,100 blocks a day at the 522 MH/s the devnet showed at 14:44Z (one every 17 s, approximate: the network rate moves), about a quarter of a 5090 owner's 20,200, for 2.76 kWh a day (£0.79 at 28.5 p against the 5090's £2.11); through a Thunderbolt enclosure the x4 link costs nothing measurable (the hash is bound by the card's own memory latency, not the link; the 5090's PCIe-slot rows are the comparison), so a laptop with a Thunderbolt 4 port and this enclosure is a 31 MH/s miner. The 8 GB 5060 Ti: the same hash is the expectation (the 1 GiB dataset fits), a line owed. Proving on the 16 GB tier: the fleet's 4060 Ti 16 GB row (9.0 GB peak beside the miner on the patched server) says this card would mine and prove with about 7 GB spare, approximate until the host with the device selector ships; today the app's prover default leaves it off ("a full shard needs a 24 GB card") and the measured read is owed to the prover-floor host cut. Linux and HiveOS take the same CUDA worker (owed a line). What the lane does next: the prover-floor host's selector into the shipped WSL2 bundle, then the prove-beside read on this card; the Power Helper fault on PC 2 to the Ember lane (no efficient point on any PC 2 card until it answers).
Found on the way: inside a PowerShell `@( ... )` the comma binds before `+`, so `'--query-gpu=' + $f, '--format=csv'` is one argument (run a, void in 1 s; the query string is built first now); a bare string inside a function that also returns a value is swallowed into the caller's variable (the sampler line; `[Console]::Out.WriteLine` now); the app's `kind` for a Thunderbolt card reads `discrete` (a word for the Cards page to earn: `external`, which the state already names).

View file

@ -299,7 +299,7 @@ Sweep (5 October 2026, evening): stated. `site/litepaper.html`, Finality ends wi
### F11. VDFs are exotic
"A class-group VDF with Wesolowski proofs in a consensus-critical path, in a project with no cryptographer. Chia needed years and still got timelord ASICs."
Status: Answered by design, with the dependency conceded.
Status: Answered by design, with the dependency conceded. Update 7 October 2026 (era VDF lane): the era VDF is in the node (spec 4.4 Implemented, behind `era_vdf_activation_daa`, never until Josh sets it per network), on a fixed-width integer with no C library, with the hash-chain fallback behind the genesis scheme byte for the day a class group's order is computable; the attack pass's F7 harness fires against the stand-in (1 of 6 cuts re-rolled at no delay) and is silent against the VDF (0 of 6); the measured rates, prove and verify times and the margin against the fastest known prover (chiavdf's AVX-512 path on the same box) are in `docs/analysis/era-vdf-2026-10-07.md`. The timelord-ASIC point is answered by the margin table of spec 4.6: the delay only has to exceed the 2-s publish window, and it does so by orders of magnitude on the fastest evaluator measured. The external review (O-4.1) is still owed. Was: Answered by design, with the dependency conceded.
Answer: The VDF is used for one thing: making the hourly program unknowable within the roughly two seconds a miner has to decide whether to publish a block, closing a withhold-or-publish grind the review measured at about 130 to 1 for a 30% miner. The VDF input is a certified checkpoint at least one epoch before the epoch starts, so honest nodes have about 50 minutes of slack to evaluate a 10-minute VDF, and an evaluator 300x faster than reference would still be needed to beat the two-second decision window. Chia has run class-group VDFs in production since 2021, with faster hardware evaluators existing and not breaking it (approximate, from memory). The design doc lists the VDF as a new dependency and ships the evaluator in every node. The grinding simulation with and without the VDF is scheduled before gate 3.

View file

@ -1,33 +1,33 @@
# The chip claim, public text (7 October 2026, 14:3x UK, on Josh's "this needs updating with all of our updates"; served since 11:03 UK on master 9162c847 with main's two cuts: no mention of the disclosure prize until the publish word, and row 17 in evidence.md's eight-column shape)
# The chip claim, public text (7 October 2026; REWRITTEN LAUNCH-FIRST 18:3x UK on Josh's "I thought we were making it 2.1 from launch?": the testnet and mainnet objects set program_class_v4_activation_daa to 0, so class v4 is live from genesis and the launch number is 2.1x to 3.9x on day one; the 5x to 9x is the class v3 baseline the work started from, stated only as that; the devnet's own activation height is a devnet fact only. Served since 11:03 UK on master 9162c847 with main's two cuts: no mention of the disclosure prize until the publish word, and row 17 in evidence.md's eight-column shape)
Three texts and one ledger row, written by the Counter ASIC lane, which owns the chip model. Every number carries its label: measured (a card or a chain we ran, with the date), modelled (arithmetic on cited parts), claimed (a vendor's figure, never measured by us), designed (a rule in a class, not yet measured). Sources: `docs/analysis/chip-model-v3.md` sections 5 and 6, `docs/analysis/latency-shadow-2026-10-06.md`, `docs/plans/counter-asic-3-status.md`, `docs/analysis/attack-pass/f8-uniform.md` and `f4-weakday.md` (branch attack-pass), `docs/design/class-v5-stored-state.md`, the datacentre and market-cap rows of 7 October (lanes 3 and the fleet), the cryptanalysis plan in `docs/plans/funding.md`.
## 1. The home page's chip line (replaces the hero sentence served since 6 October 16:21Z)
Built for graphics cards. In our public model the strongest chip reaches 5x to 9x per joule against an RTX 5090 today; class v4, now on the vote, brings that to 2.1x to 3.9x, and class v5 makes the dataset the chain's own state, so a chip that stores it or recomputes it is wrong on every item. The model and every measurement are public.
Built for graphics cards. At launch the strongest chip in our public model reaches 2.1x to 3.9x per joule against an RTX 5090, under class v4 from the first block. Class v5 then makes the dataset the chain's own state, so a chip that stores it or recomputes it is wrong on every item. Without class v4 the same chip would reach 5x to 9x. The model and every measurement are public.
## 2. The litepaper's chip section (replaces the paragraph that begins "The chip model: 5x to 9x per joule")
The chip model. We price the strongest chip we can design against an RTX 5090 and publish the arithmetic. The honest card: an RTX 5090 mines class v3 at 136 MH/s on 350 W in the bench and 290 W in the app (measured, 6 October 2026); an Apple M5 Max at 27 MH/s on 21 W (measured, 6 October 2026); an H100 SXM at 249 MH/s, 98 percent of its random-read ceiling like the 5090, 1.78x the 5090's hash at 1.15x the tuned 5090's hash per watt and a third of the hash per rented dollar (measured, 7 October 2026), so datacentre silicon does not change the chip question. The CPU verifier takes 2.33 ms per warp of 32 hashes on one M5 Max core under class v4 (measured, 6 October 2026), against a gate of 10 ms.
The chip model. We price the strongest chip we can design against an RTX 5090 and publish the arithmetic. Class v4 is live from the first block on the testnet and the mainnet (the ladder's rung 0 at genesis), so the launch number is the class v4 row. The honest card: an RTX 5090 mines class v3 at 136 MH/s on 350 W in the bench and 290 W in the app (measured, 6 October 2026); an Apple M5 Max at 27 MH/s on 21 W (measured, 6 October 2026); an H100 SXM at 249 MH/s, 98 percent of its random-read ceiling like the 5090, 1.78x the 5090's hash at 1.15x the tuned 5090's hash per watt and a third of the hash per rented dollar (measured, 7 October 2026), so datacentre silicon does not change the chip question. The CPU verifier takes 2.33 ms per warp of 32 hashes on one M5 Max core under class v4 (measured, 6 October 2026), against a gate of 10 ms.
| The chip and the class | Edge over an RTX 5090 per joule | Label and date |
|---|---|---|
| A memory-controller chip that stores the whole dataset (the Ethash class), class v3 | 5x to 9x (5.1x on GDDR7, 9.2x on eight HBM3 stacks; the Ethash chips of this class reached 2.1x to 4.8x) | modelled, 6 October 2026; the precedent measured by others, 2020 to 2022 |
| The same chip under class v4 (about 100,000 integer ops per hash in the latency shadow, so the chip carries a GPU-class datapath beside its memory) | 2.1x with a core as costly per op as the GPU's (k = 1); 3.9x with the core Bitmain claimed for its Antminer X9 (k about 0.33), a product withdrawn before any unit shipped | modelled on measured card watts, 6 October 2026; the X9 figure claimed, never measured |
| The same chip at the ladder's second rung (about 200,000 ops per hash) | about 2.8x | modelled, 7 October 2026 |
| At launch: a memory-controller chip that stores the whole dataset, under class v4 (about 100,000 integer ops per hash in the latency shadow, so the chip carries a GPU-class datapath beside its memory) | 2.1x with a core as costly per op as the GPU's (k = 1); 3.9x with the core Bitmain claimed for its Antminer X9 (k about 0.33), a product withdrawn before any unit shipped | modelled on measured card watts, 6 October 2026; the X9 figure claimed, never measured |
| The same chip at the ladder's second rung (about 200,000 ops per hash), reached by miner signal | about 2.8x | modelled, 7 October 2026 |
| Any chip under class v5, where the dataset is the chain's own state | a stateless or stale chip is wrong on every item, so the stored-dataset chip and the recompute chip are removed as categories; the verifier pays 0.2 ms more per warp | designed, 7 October 2026 |
| A chip caching the hottest 0.1 percent of items (about 1 MB of SRAM) | bounded at 1.067x at the ceiling, 1.005x on about half the hours and 1.048x on 5 percent | measured census of 1,024 programs, 7 October 2026; the source rule in the next class |
| A per-day FPGA that recomputes the dataset with cheap multipliers on a weak day | at most 12 percent more hash rate on 12 days a century, nothing on the other days and nothing for any chip | measured census of 2^24 days, 7 October 2026; the rule in the next class |
| When a stored-dataset chip pays for itself | at about USD 100 M of market cap in the first two years, not before | modelled, 7 October 2026 |
| The baseline the work started from: the same chip under class v3, without the shadow (the Ethash class) | 5x to 9x (5.1x on GDDR7, 9.2x on eight HBM3 stacks; the Ethash chips of this class reached 2.1x to 4.8x) | modelled, 6 October 2026; the precedent measured by others, 2020 to 2022; never the launch state |
What a miner sees from this. Class v4 costs a 5090 about 80 W more for 0.2 percent of rate, an M5 Max 16 W more for 1.5 percent, an RX 9070 XT and an RTX 4070 nothing (all measured, 6 October 2026). The ladder that sets how much work rides in the shadow starts at rung 0 at the testnet genesis and climbs by miner signal; its third rung is inadmissible today because a server core verifies it in 10.85 ms, over the gate (measured, 7 October 2026). The next test of the model is not ours: the cryptanalysis plan buys three external lots against the mixer, the chained cache and the acceptance rule.
What a miner sees from this. Class v4 costs a 5090 about 80 W more for 0.2 percent of rate, an M5 Max 16 W more for 1.5 percent, an RX 9070 XT and an RTX 4070 nothing (all measured, 6 October 2026). The ladder that sets how much work rides in the shadow starts at rung 0 at genesis and climbs by miner signal; its third rung is inadmissible today because a server core verifies it in 10.85 ms, over the gate (measured, 7 October 2026). On the devnet, which started on class v3, class v4 arrives by miner signal at a published height (a devnet fact, not a launch one). The next test of the model is not ours: the cryptanalysis plan buys three external lots against the mixer, the chained cache and the acceptance rule.
## 3. The miner page's line
Your card against the strongest chip we can price: an RTX 5090 at 136 MH/s on 350 W (measured 6 October 2026), the chip 5x to 9x per joule in the public model today, 2.1x to 3.9x under class v4 (modelled on measured watts), and under class v5 wrong on every item because the dataset is the chain's own state (designed); the model and the measurements are public.
Your card against the strongest chip we can price: an RTX 5090 at 136 MH/s on 350 W (measured 6 October 2026); at launch the chip reaches 2.1x to 3.9x per joule under class v4 (modelled on measured watts), and under class v5 it is wrong on every item because the dataset is the chain's own state (designed). Without class v4 it would be 5x to 9x. The model and the measurements are public.
## 4. The ledger row (docs/evidence.md row 17, in the table's eight columns as served)
| # | Claim | Where it is made | Status | Version or commit | Reproducible test | Result, date, machine | Independent verification |
|---|---|---|---|---|---|---|---|
| 17 | The chip resistance claim: the strongest chip in the public model reaches 5x to 9x per joule against an RTX 5090 today; class v4 brings it to 2.1x (k = 1) to 3.9x (k about 0.33) and its second rung to about 2.8x; class v5 makes the dataset the chain's state so a stateless or stale chip is wrong on every item; the hot-set cache is bounded at 1.067x at the ceiling and the weak-day FPGA at 12 percent on 12 days a century, both routed to the next class; datacentre silicon does not change the question; a stored-dataset chip pays for itself only at about USD 100 M of market cap in two years | the home page's chip line, the litepaper's chip section (/litepaper#chip-model), the miner page's line | tested by the team (every card, the verifier, the two attack-pass bounds, the H100), the chip itself modelled, class v5 and the ladder designed, the X9 core claimed and never measured | `docs/analysis/chip-model-v3.md` 5 and 6; `docs/analysis/latency-shadow-2026-10-06.md`; `docs/plans/counter-asic-3-status.md`; `docs/analysis/attack-pass/f8-uniform.md`, `f4-weakday.md`, `docs/analysis/ca3-v4-uniform.md`; `docs/design/class-v5-stored-state.md`; the H100 and market-cap rows of 7 October; `docs/plans/funding.md` (the three lots) | the chip model's arithmetic in its file; the card rows by the benchmark package; the attack-pass harnesses `tools/attack/f8-uniform` and the F4 census; the verifier by `igneum-pow bench` | 136 MH/s at 350 W (5090, bench) and 290 W (app); 27 MH/s at 21 W (M5 Max); 249 MH/s (H100 SXM) at 98 percent of its read ceiling, 1.78x hash, 1.15x MH/W, a third per rented dollar; 2.33 ms per warp; 5.1x to 9.2x; 2.1x, 3.9x, 2.8x; 1.067x at the ceiling; 12 percent on 12 days a century; 10.85 ms at rung 3; USD 100 M; 6 and 7 October 2026, the M5 Max, PC 2's RTX 5090, PC 1's RX 9070 XT and RTX 4070, a rented H100 SXM, igneum-build-1 | none yet; the three cryptanalysis lots are the next test |
| 17 | The chip resistance claim: at launch the strongest chip in the public model reaches 2.1x (k = 1) to 3.9x (k about 0.33) per joule against an RTX 5090 under class v4, live from genesis on the testnet and the mainnet; the ladder's second rung brings it to about 2.8x; class v5 makes the dataset the chain's state so a stateless or stale chip is wrong on every item; the hot-set cache is bounded at 1.067x at the ceiling and the weak-day FPGA at 12 percent on 12 days a century, both routed to the next class; datacentre silicon does not change the question; a stored-dataset chip pays for itself only at about USD 100 M of market cap in two years; without class v4 the same chip would reach 5x to 9x (the class v3 baseline, the devnet's starting state, never the launch state) | the home page's chip line, the litepaper's chip section (/litepaper#chip-model), the miner page's line | tested by the team (every card, the verifier, the two attack-pass bounds, the H100), the chip itself modelled, class v5 and the ladder designed, the X9 core claimed and never measured | `docs/analysis/chip-model-v3.md` 5 and 6; `docs/analysis/latency-shadow-2026-10-06.md`; `docs/plans/counter-asic-3-status.md`; `docs/analysis/attack-pass/f8-uniform.md`, `f4-weakday.md`, `docs/analysis/ca3-v4-uniform.md`; `docs/design/class-v5-stored-state.md`; the H100 and market-cap rows of 7 October; `docs/plans/funding.md` (the three lots) | the chip model's arithmetic in its file; the card rows by the benchmark package; the attack-pass harnesses `tools/attack/f8-uniform` and the F4 census; the verifier by `igneum-pow bench` | 136 MH/s at 350 W (5090, bench) and 290 W (app); 27 MH/s at 21 W (M5 Max); 249 MH/s (H100 SXM) at 98 percent of its read ceiling, 1.78x hash, 1.15x MH/W, a third per rented dollar; 2.33 ms per warp; 2.1x, 3.9x, 2.8x at launch; 1.067x at the ceiling; 12 percent on 12 days a century; 10.85 ms at rung 3; USD 100 M; 5.1x to 9.2x the class v3 baseline; 6 and 7 October 2026, the M5 Max, PC 2's RTX 5090, PC 1's RX 9070 XT and RTX 4070, a rented H100 SXM, igneum-build-1 | none yet; the three cryptanalysis lots are the next test |

File diff suppressed because one or more lines are too long

View file

@ -171,7 +171,7 @@ Filled from a CPU census over programs (section 6).
## 8. What is unverified
- Everything in section 6 marked pending.
- The 1-hour VDF does not exist; the devnet stand-in of section 2 is a proposal.
- The 1-hour VDF: BUILT on 7 October 2026 (era VDF lane, after the attack pass's F7 row named it the gating dependency): `kaspa_consensus_core::era_vdf` (the class-group Wesolowski scheme on a fixed-width integer and the hash-chain fallback behind the genesis byte `vdf_scheme`), `kaspa_consensus::processes::era_vdf` (the cut rule, the day-of-blues input, the evaluator thread, the record store), behind `Params::era_vdf_activation_daa` (never on every network until Josh's word per network); the stand-in of section 2 stands below the activation and is what the VDF reads its input from above it. Verified: the F7 re-roll harness against the real era cut fires with the VDF off and is silent with it on across 6 cuts (`tools/era-vdf/reroll.mjs`, the record `docs/analysis/era-vdf-2026-10-07.md` section 3), the parameters and the measured prove and verify times are in spec 04 section 4.6. Still unverified: the P2P relay of a record to a syncing peer (spec 4.5, owed before era 1 of any network with the switch set), the binding of the cut to the certified checkpoint (left at the O-4.3 reading, one function to change), an external review of the class-group port (O-4.1), and the 2019-class-core verify time, which is measured on a proxy until a 2019 host is rented (record section 5).
- The interleave's value against a chip with a programmable address decoder is nil (1.2); the claim is limited to hard-wired layouts.
- The window floor of 2^26 words is set by the 5090's L2 (96 MiB) and the 9070 XT's Infinity Cache (64 MB, vendor figures); a future card with a larger cache moves the floor, which is a genesis constant.
- No cryptanalysis of the stride (a multiply and a rotate before the mask); it is a bijection, so the address distribution is that of the register value, as today.

View file

@ -112,3 +112,10 @@ Standing rulings of the same hour: "We dont want to penalise holders" (dormant-c
| 3 | The latency ladder | Approved | The six rungs (27, 35, 53, 88, 173, 267 passes), rungs 0 to 2 admissible, rung 3 re-measured on a quiet core before genesis, 4 and 5 inadmissible until verifiers allow; every step by 90 percent in each of seven windows, never unconditional; `latency_ladder_activation_daa` = 0 on the testnet at rung 0. |
| 4 | Cryptanalysis | Approved, "make sure they find ZERO flaws, also cut costs if possible" | The engagement runs at the low point (about USD 80,000) unless a quote forces more; an internal attack pass precedes it so the firms find nothing new; every finding is fixed before the testnet go. The contracting entity and the prize are still Josh's to confirm. |
| 5 | Re-cut the testnet genesis | Approved | One cut with 18 decimals, `TESTNET_1`, and the switches on from genesis: proof verification, the leave item, the signing bonus, finality v3, the ladder at rung 0. Nothing live is touched; the go checklist decides the date. |
## Era VDF (7 October 2026, 12:xx UK, era VDF lane): two decisions for Josh
Question 1, the activation. The era VDF (spec 04 section 4.4) is in the node behind `era_vdf_activation_daa`, never on every network, with the genesis scheme byte `vdf_scheme` 0 (the class group) and `era_vdf_t` at the reference rate of igneum-build-1's core (spec 4.6). Facts: the F7 harness shows the stand-in grindable with one block of hash at no delay and the VDF closing it; the first era with a VDF is era 1, 180 days after a network's genesis; the P2P record relay (O-4.10) is owed before then. Recommendation: igneum-testnet-1 and mainnet carry `era_vdf_activation_daa: 0` in their genesis objects (the switch costs nothing before era 1 and the digest then pins it from the start); the live devnet keeps never (it will not reach era 1). Unblocks: the freeze of the era draw procedure and the C_era cut rule, which the attack pass's F7 row holds open on the VDF.
Question 2, the cut's binding (O-4.11). The node names the cut block as the chain block the lead rule names, certified or not (the O-4.3 reading of 3 October 2026), so a finality pause across the cut never leaves an era without a seed and the rule is a function of the header's past alone. The design document's wording binds the era draw to the last certified checkpoint. Facts: the grinding defence does not depend on the binding (the delay makes any candidate's draw unknowable); the certified binding couples the era seed to finality liveness and needs a rule for a certificate that lands after the cut (`docs/analysis/era-vdf-2026-10-07.md` section 4). Recommendation: keep the O-4.3 reading for the era as for the epoch; one function (`EraVdfManager::cut_block`) changes if the finality lane wants the certified binding. Unblocks: the sentence in spec 4.4 step 1 stops carrying "under the O-4.3 reading" once decided.

View file

@ -501,3 +501,55 @@ sha256 45be9b02d1b002f5 asserted at the put, igneumd/2.1.0-c4459193 in the node'
**PC 2 out of the waves too (main via the Counter lane, 14:5x BST):** Josh is taking PC 2 down for cable work (PC 1 is back, but it is his desk and no job goes to it); both PCs update on their pollers on return, their lock lines "offline at the sweep, updates on return". The sub-version-2 Windows G1 completed on PC 2 before it went down (13:46:36 to 13:46:50Z, exit 0, eight of eight fingerprints equal to the Mac's). Wave 1 is the Mac, the hands and the fleet.
**c19-1's last pin lines (the fleet; FORM END rc 0 at 13:50:53Z):** mining at +666 s 34.3 MH/s, mined 66, accepted 66, rejected 0, got_reject 0, wrong_version 0, isSynced true at the tip on every read, the exec follower moving; the hub holds 41 blocks by c19-1's key 2c7cc291d38579e0 in its last 700, rejects naming the pod 0; the restart on the kept datadir 13:47:15Z: the stop and the start inside seven seconds (09124180's poll proving itself against b7cc37e7's LOCK death), synced again 13:48:39Z (157,048 blocks, 4 peers), 84 seconds after the kill, isSynced true on the first read and never false after; 109 templates in the read, max template_ms 3,432, "template fetch timed out" 0; the kept read passed at 13:38Z. Every line the rule reads is in from c19-1; CASES END from c20-1 is the one left (about 14:50Z). c19-1 stays up until the shipper's word. p1-5090 ran the sub-version-2 G1 meanwhile (eight of eight equal to the Mac's) and is back under its supervisor.
**The prover roll corrected (the fleet, 15:0x BST):** box-prover's "RESULT paid" line read the segment record's `paid` field without comparing its keyHash to the box's own, so a segment paid to ANY prover printed as the box's pay; this morning's PAIRED verdicts rested on it. The truth from hub-1's segment records (157486..164691, 899 segments, 378 paid) by paid.keyHash: p1-4090 94, p2-4090-3 54, hub-1 41, p1-4070 20, p2-4070-1 11, p12-vast 3, two keys the registry does not hold (bb9f9057a373f647 99, the devnet's biggest payer; e809e39672ed32db 56), and ZERO in two hours for p1-5090, p2-4090-1b, p2-3090-1 to 4 and p1-a5000. So: the pair is on 13 of 13 and pairs on every box; 5 of 13 are paid by the chain's record, 7 are not (their logs under read: race or stuck); the 12 GB race reading was half right (p2-4070-1 at 12 GB is paid 11 times while the 3090s and the 5090 are not, so card size is not what holds those six). The 12 GB line, real: p12-vast was paid three segments by its own key on the bare 55768f88 node, each claimed behind the settled floor (164438 claimed 13:41:00Z paid at carrier 164494 5.8388 IGN; 164502 at 13:45:06Z paid 4.8263; 164510 at 13:48:58Z paid 4.5727); on c4459193 with the verifier env (13:31 to 13:37Z) the statement was accepted and no race won in that window: the pin's line is "statement accepted, 0 paid in six minutes", not blocking. box-prover fixed (gpu-fleet 4c872785: a paid line only on its own key, "paid_other" otherwise), on every standing box; running provers take it at their next restart by kill file, which the sweep's prover roll does. Who holds bb9f9057 and e809e396 (PC 2's app, the Mac, the hands' CPU prover) is for main.
**Docs on master (15:01 BST):** the 0.3.20 plan, release-rules.md and the 0.3.21 plan landed as ship-docs-0320 3ad0c60d through tools/ci/merge-to-master.sh (the full gate green on the branch, 52 checks; the merge 41e0eb45). From here plan rows land the same way.
**The two paid prover keys outside the fleet's registry (15:3x BST):** e809e39672ed32db (56 segments in two hours) is PC 2's card 1, identity 1 (label win-1ccfe586-1-1; read by the build-server lane's read-only job run-20261007-142054 on PC 2 at 15:38 BST through the installed igneum-miner's key-hash; PC 2 is back since the cable work). bb9f9057a373f647 (99 segments, the devnet's biggest payer) is none of PC 2's seven labels, nor the Mac's (mac-d937c69d-1 = 8fafda27…), and build-1 runs no prover; PC 1 (labels win-ae432dc7-1/-2 and their identities) is the remaining candidate, its key read by Josh from the Prove page's Details on main's ask (no job on PC 1). For 0.3.21 the app reports its vote key hashes to the intake on every poll, so this is never a hand read again.
**Devnet 2 (the fleet, 15:4x BST):** dn2-override.json is an eleven-field object without the v4 floor or window, so Devnet 2 has no flip date and the 13 October line does not apply to its lock lines; igneumd-83702a35 and c4459193 both print digest 4a0b8726… on it, so wave 3 is a plain binary swap on the four pods (read-back 4a0b8726) and bps-seed moves at the build-server lane's convenience with no digest step. The cases on c20-1: the poison pod mining from 14:41:25Z, the thirteen polls to about 14:54Z, the restart and the hub read after: CASES END about 14:57Z (15:57 BST); the per-box sweep form rehearsed on w-target meanwhile. The publish about 16:00 BST on it.
## 38. 0.3.20 LIVE (15:54:17 BST, 7 October 2026)
**Main's word (15:4x BST):** (b): publish on the dc141409 cases plus the diff argument and the c19-1 relay evidence; the separate-host relay re-run (CASES END about 17:15 BST) is a halt condition: a FAIL stops the sweep after the wave in progress and rolls back by the per-box form; no public Discord card or site version bump until it reads PASS. The cases rerun on c20-1 was void on its relay half: RunPod put the target, the relay and the poison pods on one host and a pod cannot reach another on its own host by the public address (the no-hairpin class); the poison half stood (13 rejected, 0 accepted). The hairpin class is a rent-time host check in the fleet's script.
**The cut:** live DAA 294,073 at 15:51 BST; the floor-moved file ov16-floor-900000.json (sha256 294f1f80f1c8aecf…): program_class_v4_activation_daa 831600 → 900000, window 86400 unchanged, the fifteen other fields as live; digest on c4459193 4bbbe8162ea9fff277aa5b16b4ffad9e2262ba5e6a5acac7b697ff77211e7328 (the live file reads eada4bda on the same binary). Staged in a scratch copy of the downloads folder (publish-manifest.sh --no-deploy --public with the DMG 73796c5f and the installer 45b2f3fb; publish-public.sh --hive with igneum-hive-0.3.20.tar.gz d9dd12df); the staged manifest differed from the live one in the override object alone; the staged folder's names differed from the live one in the 0.3.20 files added and the 0.3.19 and 0.3.17 public files pruned. The deploy step: the staged folder onto the live one, `vercel deploy --prod` (Production igneum-qu5chjxiq, aliased dl.igneum.network), 15:54:17 BST. Read back: the live manifest version 0.3.20, mac 73796c5f, windows 45b2f3fb, floor 900000; the three public aliases 200 (windows exe, mac dmg, hive tar).
**The sweep's go (15:55 BST):** the fleet's wave 1 (hub-1 first, pool-1, the eight heaviest; the fleet-native pair a8d08da5 / 474273ce; read-back by commit string c4459193, digest 4bbbe816, synced; the lock line "sweep complete before 13 October 09:00 UK (the margin; the floor is now DAA 900,000)"), the hands in the same minutes (the build-server lane, mode 2 with the object and the digest), wave 2 on the first lock on the new side, wave 3 the Devnet 2 four as a plain swap (4a0b8726), bps-seed at the build-server lane's convenience with no file change; the seeds and the RPC filter untouched (main's (b)); PC 1 and PC 2 on their pollers on return; the Mac on its poller; the Mac mini a fresh install when Josh has it up. The Discord card and the site's version line held for the relay re-run's PASS.
**Correction to the sweep's node pair (16:02 BST):** the fleet-native igneumd a8d08da5 named in the go is gone: the shipper's scratch copy was overwritten at 13:55Z by the stopped 55768f88 chain's native step (05016dee, no c4459193 string) and the box's target-0320 path with it. The sweep's node pair for every fleet box is the build-server lane's c4459193 hands pair from the same tree (igneum 00249643, igneum-pow 8c728ca3, build-1, native 2.39, the string read back): igneumd a80ed39caf885d314f97ce88863afcb307cbb4acc45a10828b2443a41e5d27d2 (57,628,576 B), igneum-miner 70a5180f30fab73fde0b3f33cfd37d2d68899eab70290b86c924e02980b09afd (10,214,648 B). The shipped artefacts are unaffected: the hive tar (d9dd12df, smoked with c4459193 ×2), the Windows exes in the installer (49502cc7, c4459193 ×2) and the Mac node in the DMG (b306baba, c4459193 ×2) were verified from their own files. Scratch rule note: a chain writing into a shared --out directory must stop before another candidate starts; the restart on a new candidate reused the same out paths.
**Wave 1, the hands (the build-server lane, 15:56:09 to 15:57:45 BST, rc 0):** igneumd a80ed39c installed as /srv/hands/bin/igneumd-2.1.0-c4459193, the sixteen-field object written (floor 900000, window 86400). observer-node restarted 15:57:08 from eada4bda: first executing line "exec state loaded from a snapshot: tip 165393", digest 4bbbe816 MATCHES, the commit string present, powEngine igneum-pow, blockrate bps 1, finalityDepth 43200, ghostdagK 18, mergeDepth 3600, pruningDepth 108000. node1 restarted 15:57:30: tip 165348, digest MATCHES, the string present, powEngine igneum-pow. Lock line carried. Seeds and the RPC filter untouched; bps-seed follows.
**Interface 1.0.1 over the air (main's order, the shipper as publisher; 16:03:37 BST):** the 0.3.20 tree's app/igneum-app/ui packed as igneum-ui-1.0.1.tar.gz (408,212 B, sha256 0749f37c67c028c8cf1052ed099fea0f1cc159ad6cefdc75d74d2f2de1d9fc45), min_engine 0.3.20, signed with the release key (igneum-ota-sign sign-ui; key fingerprint 8f186e37…), staged first in a scratch copy of the downloads folder (the only field differing from the live token manifest: `ui`; the release notes kept by passing them back), then the deploy step (the staged folder onto the live one, vercel deploy, aliased 16:03 BST). `publish.mjs --verify`: the live manifest's ui entry against the bundle in the folder, sha256 matches, signature verifies. Not published to the public manifest: the publisher's --public arm fails on the ui entry ("the public igneum-app-latest.json would still carry the token": the ui URL is under the token path and publish-public.sh has no ui rewrite), so dl/public/ui/ is missing by design today; the apps read the token manifest, which is where the channel lives; the --public ui arm is a 0.3.21 packaging fix. The Mac's app taking 1.0.1 on its poller: read back below when it polls (hourly, or Settings > Check now).
**Wave 1 (the fleet, 14:59:08Z):** on igneumd a80ed39c (the floor file 294f1f80 read 4bbbe816 on it on the scratch pod at 14:58Z), the lock before at checkpoint 9259, 80.4 percent; hub-1, pool-1, p1-5090, p1-4090, p2-4090-3, p2-4090-1b, p2-3090-1 to -4 moving in parallel; the miner 70a5180f for the prover roll behind the waves. The relay re-run's three pods carried the old file (eada4bda) and would have read a false FAIL beside the swept fleet; the floor file put on c19-1, c21-relay and c21-poison at 15:00:37Z, the run restarted 15:02Z after a script fault of the fleet's (fixed): CASES END about 16:20Z (17:20 BST), a read on the live digest. bps-seed is build-1 itself (a bare process under /home/build/dn2seed), the build-server lane's, the digest unmoved there.
**Wave 1 read-back (the fleet, 15:04:51Z):** hub-1 on a80ed39c, igneumd/2.1.0-c4459193, digest 4bbbe816, override 294f1f80, the N13 rewrite line once, synced at 162,489 with 6 peers, the miner back; the same read on p1-5090, p1-4090, p2-4090-3, p2-3090-4 (1 to 2 peers, climbing); p2-4090-1b and p2-3090-2 on the new binary and digest with 0 peers yet (the old-digest peers refuse them, the new ones are the boxes landing beside them; miners held by the supervisor's zero-peer rule until they peer); pool-1's daemon stopped and its node starting on the supervisor's next pass; p2-3090-1 and p2-3090-3 not answering that read's ssh in time (the next pass). The panic counts on the first boxes (10 to 20) are the RocksDB LOCK class from the supervisor's retries while the old process still held the datadir, not consensus; every node is up regardless. The certificate line between waves about 15:10Z.
**bps-seed (the build-server lane, 16:07:49 to 16:08:00 BST):** the Devnet 2 seed is a bare process on build-1 (/home/build/fleet-share/igneumd-83702a35…, --devnet-suffix=2, appdir /home/build/dn2seed, the eleven-field devnet2-override.json b75d1f58); both binaries read 4a0b8726 on that file first; SIGTERM, the native c4459193 (a80ed39c) started with the identical argument line, down 11 s; read back igneumd/2.1.0-c4459193, the string twice, the digest 4a0b8726 unchanged, the N13 line on the kept datadir, GRPC and P2P up, 23 "PoW accepted" in 20 s, no panic.
**The Mac (this machine, in wave 1 on its poller):** its hourly check at 16:01 BST had read the 0.3.19 manifest (published 10:23Z) though the CDN served 0.3.20 since 15:54 BST, a stale read of seven minutes (the app's own cache or a stale edge; a 0.3.21 note for the ui-ota health ping, which reads the same path); Settings > Check now at 16:04:58 BST read 0.3.20 (published 15:02:36Z), downloaded and ready by 16:05:20; the apply is the app's own (auto_update on), awaited; interface 1.0.1 after it.
**0.3.21:** update-return-21b a64c193f (the eGPU card kind for a USB4 or Thunderbolt router in the device's parent chain, the Power Helper's fault line and its stale-prefix fix; app tests 255 + 32 + 8, UI 76) is ready on the lane's side; its push to origin is refused by GitHub ("remote: Internal Server Error", three tries 15:08 to 15:10Z), the lane retrying.
**The Mac's apply is held by the app's own guard (16:12 BST):** update ready, wait = "the network lost 33% of its identities in the last 10 minutes; holding the update": the wave sweep restarts ten fleet nodes at once (each miner off for the 80 s restart, the zero-peer boxes held longer), the app reads that as an identity drop and holds its apply until the count recovers, then applies at its rollout slot (":MM past the hour", the per-machine minute). The guard is right by design (an app must not update into a shedding network); the consequence is that the apps' pollers apply after the fleet's waves settle, not alongside them. For the rules: a wave sweep and the apps' identity-drop guard interact this way; the publish record names the apps' apply times as they land. The Mac's apply time is read back below.
**Wave 1 complete, wave 2 started (the fleet, 15:12 to 15:22Z):** all ten read back on the pin (a80ed39c, igneumd/2.1.0-c4459193, digest 4bbbe816, the file 294f1f80, the N13 rewrite line once, synced or catching up): hub-1 (10 peers), pool-1 (its node restarted 15:08:27Z), p1-5090, p1-4090, p2-4090-3, p2-3090-4, and p2-4090-1b, p2-3090-1, p2-3090-2, p2-3090-3. The fault behind the last four: the standing nodes run `--addpeer=<the Hetzner seed> --nodnsseed`; the seed's own move refused their handshake and they dialled nothing for 17 minutes at 0 peers (the six others had the hub in their address books); fixed by HUB_PEER (the hub as a second addpeer) in their supervisors and a kill-file restart, all four at 3 peers. The lock: hub-1's last LOCKED at checkpoint 9263 (15:05:03Z, 49.9 percent at the moment of locking; the folded certificate 98.1 percent, 13 of 16 voters, weight 4,954); no LOCKED since, the digest split's expected shape while the four wave-2 voters still vote on the old side; the first new-side lock being read. Wave 2 (p1-a5000, p1-4070, p2-4070-1, p1-3080) started 15:22Z on the 98.1 reading (their move brings weight onto the new side). Rule for the fleet's supervisors (from this): every standing node carries the hub as a second addpeer, so a seed's move never leaves a box peerless.
**Master re-pinned (16:21 BST):** packaging/windows/node-source.pin on master moved to c4459193 (master-pin-0320 a0b44b48 through merge-to-master.sh, the full gate 55 checks green; the merge 6996ad3f), so master's windows-ci reads green at its payload-inputs step again (red on every master run since 6 October 21:15Z on the stale pin); release rule 14: master is re-pinned at every publish, a named line in the cut list.
**The Mac on 0.3.20 and interface 1.0.1 (16:23 BST):** the identity-drop guard lifted as wave 1 settled; the app applied 0.3.20 on its own (updated_from 0.3.19), its node log node-20261007-152327.log starting 16:23:27 BST on igneumd/2.1.0-c4459193, digest 4bbbe816 on its own kept datadir (the N13 rewrite line once), synced at 164,411 blocks with 5 peers at 16:25; interface 1.0.1 taken over the air at 16:23:58 BST (source ota, published_version 1.0.1, active_version 1.0.1, embedded 1.0.0, confirmed true, no error). The Mac is in wave 1 as ordered.
**Wave 2 and the first lock on the new side (the fleet, 15:23 to 15:29Z):** p1-a5000, p1-4070, p2-4070-1, p1-3080 read back on the pin (a80ed39c, c4459193, 4bbbe816, 294f1f80, the rewrite line once, synced); three sat peerless on the seed-only addpeer (wave 1's class) and carry the hub peer since 15:26Z. The lock: the old side's last at 9263 (15:05:03Z); the new side locked from checkpoint 9313 at 15:28:08Z (84.9 percent of active, 70.1 of total), 9315, 9316 (87.5 of active, 74.9 of total); a 23-minute gap while the table's weight split across the two digests, votes accepted at the hub throughout, no certificate at two thirds until all fourteen stood on the new digest with peers; the folded certificate at 9317 78.3 percent (14 to 15 voters). Equivocation warnings on the hub at 9280 and 9292 by four keys that are not fleet vote keys (e15fe94b, a405c3e6, 7b8ef6fd, ea53bd41: the Mac, PC 2 or the hands re-voting across the split); the fleet's keys did not equivocate. The supervisor rule is in: box-standing.sh adds the hub as a second addpeer by default (gpu-fleet 34ef30f8, README item 20, CLAUDE.md), on all fourteen, live at each next kill-file restart (seven already). Wave 3 (the Devnet 2 four, a binary swap, digest 4a0b8726) started 15:29Z; the prover roll runs behind waves 1 and 2 (the 70a5180f miner, the pair under bin-0320, box-prover with the key check and the drift flag). For the rules: a digest-moving sweep costs the chain its locks for the length of the split (23 minutes here); the hub plus the heaviest voters in one wave shortens it, and every node carrying the hub as a peer shortens it further.
**Known red on the published pin, test-only (the node lane after the era VDF lane's read, 16:3x BST):** two consensus INTEGRATION test targets, consensus/tests/igneum_installed_signals.rs (`block_template_uses_current_block_version`) and consensus/tests/igneum_order_tests.rs, assert the signalled header version 1026 (object 4) while 8097d600 moved the class signal to object 5 and the header carries 1282; red on c4459193 and every commit since 8097d600; outside the suite set the node lane ran (the two installing-test targets were split into their own binaries this morning for the test-order race and left out of the set). The code is right; the tests are stale, 6b94c823's class. The pin stays c4459193 as published and swept (its binaries keep their commit string); the test-only fix (branch signal-tests-fix on the mirror, 1026 → 1282) is the FIRST step of 0.3.21's node order on 55768f88, and the rule from now: the two integration targets are in every suite set (`cargo test -p kaspa-consensus` without --lib). era-vdf-node 394a5902 is 0.3.22's (the shipper's call; every network at never, era 1 at least 180 days past a genesis).
## 39. SWEEP COMPLETE (15:39Z, 16:39 BST)
The devnet: hub-1, pool-1 and the twelve voters on igneumd a80ed39c (string c4459193), the floor file 294f1f80, digest 4bbbe816, the N13 rewrite line once each, synced, the miners back (the 70a5180f miner as the prover roll reaches each box); the first new-side lock 9313 at 15:28:08Z after the 23-minute split, locks every 20 to 40 seconds since at 70 to 81 percent of the total, the folded certificate 78 to 82 percent of the table. The hands (observer-node, node1) and the Mac on the pin with 4bbbe816; the Mac on interface 1.0.1. Devnet 2: dn2-seed, dn2-1, dn2-2, dn2-3 and bps-seed on c4459193, digest 4a0b8726 unchanged; a ten-minute four-way split from the fleet's Devnet 2 move (dn2-seed's supervisor env rebuilt without EVM_PORT and P2P_PORT, box-dn2.sh with no defaults, the seed's node refusing "--evm-rpclisten=127.0.0.1:"), healed at 15:36:25Z, all four at block 76,857 and a new lock at checkpoint 2313 at 15:39:38Z. The testnet seeds on 1c19441d by main's (b). PC 1 and PC 2 update on their pollers on return; the Mac mini a fresh install. Every lock line: "sweep complete before 13 October 09:00 UK (the margin; the floor is now DAA 900,000)". The prover roll runs behind the waves (p1-a5000 refused by design on its drift flag; hub-1 claiming on the pin). Open on the sweep: the relay re-run on c19-1 on the live digest, CASES END about 16:25Z (17:25 BST), the halt condition and the Discord card's gate; build-1's port 26621 (the Devnet 2 seed's p2p) reads closed from outside (a firewall rule predating the sweep; the build-server lane reads it); the two PCs' return.
The fleet's six faults in the sweep, each a rule in its tooling now: the seed-only addpeer (seven peerless boxes, 23 minutes without a lock; the hub as a default second peer, 34ef30f8); the pool kill file's "all" taking pool-1's supervisor; the loop's expected digest and wanted sha left on the old pin; the cases script re-putting the old object (OVERRIDE input); the sweep's pow read-back term; the Devnet 2 env filter. The shipper's: the speculative build chains sharing one --out directory (a candidate's artefacts overwritten by the next chain's; the sweep's pair taken from the build-server lane's copy at /srv/artefacts/0320-c4459193/ instead), and the first order to the seeds with the devnet file (corrected before any box moved).
**0.3.21 staging word given at 16:39 BST** (the node lane: c631c64b first on 55768f88, then the eight in order; the whole suite set; the candidate's binary with every gate from the build on the warm set). The reliability injector pod rented at the same minute.

View file

@ -12,14 +12,18 @@ Tree: release-0.3.21 on origin, from release-0.3.20's closed tree (the 0.3.20 cu
| sub-version 2 (the hash lane's 07a809a7, byte 7, id a788661687db4bb3), pending the F8 census (about 15:00 BST; pass = all 64 seeds under 1.2x of the window model plus the suite and G1 lines); if it fails or slips past 20:00 BST the node ships byte 5 again with the re-pin dropped | the Counter lane, the node lane | held |
| the fork gate from horizon-node (6eb21fc9, six commits on finality.rs) | the node lane | dry-merged clean into c4459193 |
| the node's own rust-toolchain.toml | the node lane | in place on its worktree |
| the under-12 GB prove-instead switch (section 2) | the shipper | to write after the 0.3.20 publish |
| the per-architecture prover server (section 3) | the shipper, the fleet | to write after the 0.3.20 publish |
| the under-12 GB prove-instead switch (section 2) | the shipper | in: settings.prove_instead, Cmd::ProveHold, the Prove page's row, provedefault helpers and tests; app gate on build-2 230 + 32 + 8, UI 74 |
| the per-architecture prover server (section 3) | the reliability lane (the app's capability check, MF-10 in cbd6f3a4), the fleet (the kit's server per arch) | the app half in |
| pool-finish 434e8b9b (the pool crate: the TLS 1.3 member port with the authorize binding, the open pool, pool.md 10; fork pool-finish-node b0444f51 off 8097d600: the pool-mode miner, the IGNS/IGNP codec, pool_split_activation_daa at never on every network, so the digest does not move) and pool-mf-row 343dd83b (MF-11 in the register) | the pool lane | main's word 14:2x BST: rides 0.3.21 with the split switch at never; if it reds a 0.3.21 gate or costs more than one rebase round it moves to 0.3.22 |
| apps-harmony (the builder's redesign, Overview as the landing tab, the Prove switch fix), then gpu-logos on top of it, then ui-overlap-fixes, then scene-parity, in that order as they land, each with its own gate line | the UI lanes | to come |
| the UI branches (apps-harmony parked by Josh's word, 14:2x BST; the Prove switch fix moved to gpu-logos): gpu-logos-21 in at fee49fc5, earnings-tidy-21 in, scene-parity-21 in, ui-overlap-fixes nothing to merge | the UI lanes | done |
| MF-11 (the app not returning after update-now; PC 2 silent since the 0.3.19 update-now at 10:34Z, its relay agent dead with it, so no job or task reaches it): the register row, the UI lane owning the cause, the relay agent as a service surviving the app as the restart path | the reliability lane, the UI lane, the relay lane | the row asked |
| the ids commit 55768f88 (`resolve_program_ids`: the override's fields, then the env or the host, then the embedded verifying keys; the exec suite 32 with the bare-node test known-failed first; built 13:35Z sha 279b1b690e854fc9): 0.3.21's FIRST node commit by main's word, with the ids gate (rule 4c) on every candidate | the node lane | on the mirror; its gates run under rule 4a |
| the 0.3.21 node order (main, 14:4x BST), each with its own gate line, one rebase round or 0.3.22: on c4459193, 55768f88; miner-reliability-20 f067f7c1 and the late-join commit 70e4601e; pool-finish-node b0444f51; horizon-node 6eb21fc9 (the deep fork-choice fix, fork_gate never set); peer-directory-node db28d331 (the peer directory prototype, peer_directory_activation_daa at never, the IGNP announce only under --announce); the sub-version-2 re-pin held for the census. The horizon lane rebases its two onto c4459193 at the sweep-end word | the node lane, the horizon lane | staged tonight |
| pool-finish-21 at 5e557171 on origin (release-0.3.21 5d153892 plus the ten pool commits, the tenth d5265fb3 the `--template-parallel` fix, default 2: the ten-member window's daemon queued ten template calls on one gRPC connection past its own timeout, 1,891 lines, no job for 36 minutes; site/miner.html carries the pool-fee sentence); igneum-pool 28 at 5e557171 on build-2, the app gate 226 + 32 + 8 at 54dd4c06 (the last commit touches pool/ and docs only); merged after the three UI branches with the app gate rerun at the merge | the pool lane | ready to merge |
| the genesis-forward lane's f95178a1 (consensus/src/consensus/services.rs, +5 −1: the difficulty manager takes `params.pow_epoch_blocks` instead of the process-wide static; names the lib-suite flake behind the horizon lane's two reds and main's "difficulty flake": two pruning-proof tests install a 60-block schedule process-wide, and a TestConsensus built in that window refuses block 61 with UnexpectedDifficulty; the lib suite 114 of 114 twice on build-2; the daemon unchanged) | the genesis-forward lane, the node lane | taken last in the node order, its own commit, the lib suite as its line |
| ui-overlap-fixes: nothing to merge for the miner (tools/ci/overlap-check.mjs read 0 overlaps on 4c89372a and on 50ffa562, the run on 3dc0832a in flight); the wallet's one finding at 927ad832 on ui-overlap-fixes-wallet off wallet-0.1.5 is the wallet's cut; the detector lands on master with the site fixes | the overlap lane | closed for 0.3.21 |
| the app reports its vote key hashes to the intake on every poll (main, 15:2x BST: the two paid devnet keys outside the fleet's registry could only be read by hand on the PCs; chainfacts keeps reading the node's keys too): the labels from prover::labels and igneum-miner key-hash once per labels change, a `keys` field on the app's intake row, the console's machine line showing the first eight of each | the shipper | to write after the 0.3.20 publish, before the 19:30 app gate |
| update-return (the MF-11 lane, bd9b4f4e off 4c89372a), main's word 15:2x BST: the app half (ota.rs: the Windows helper owns the update's return with the kept exe set, a 120 s api/state poll, a rollback and one intake line either way; engine.rs: the read-back line "update-return: app <v> up after the update from <from>", FAULT pc-restart after a power loss, the tuner ceiling and the refused-cap ladder; ember.rs; jobrun.rs the job-channel ping on wake; bootcheck.rs; platform.rs), app/windows/host.cpp (a dead engine restarted, WM_QUERYENDSESSION answered; about 60 new C++ lines) and the installer (/IGNOTA=2) ride 0.3.21 if they rebase in one round as update-return-21; the cut's windows.yml run compiling host.cpp is a NAMED GATE LINE (no host.cpp ships uncompiled). The relay half (the v3 agent as a service, playbooks, the X23 signed-run lib) lands through master on the relay lane's line with the relay deploy decision, main's | the update-return lane | rebasing |
| 0.3.20's version strings moved to 0.3.21 | the shipper | 0d7b9bd0 |
| master 819d536b merged (the 51-check gate; build-remote routes by load; the hands script's pgrep in the bracket form) | the shipper | c83ca904, 0b75cf52 |
@ -46,3 +50,17 @@ App: the gate on build-2 about 19:30 BST (`build-remote.sh --box 2 -- test --rel
sha256 279b1b690e854fc9, the string read back, pairing 8c728ca3 at byte 5. The digest gate 13:35:41 to 13:37:19Z PASS (thirteen fields a89be8a7 on both binaries, sixteen fields db9a85f9 refused with no peer, the live file's digest eada4bda unmoved); the ten-minute mixed-version gate beside the 5899f603 pair 13:37:40 to 13:47:52Z PASS (digest b0afb2ee on all five, 223 new and 381 old blocks accepted, 0 rejected, plain header version 2, counts equal at 319, 486 and 604 through both joins and the restart step, no panic). The fleet's set on the same binary (the kept start with the ids gate, the cases on the warm set, the wipe) runs under rule 4a. Staging at the sweep-end word in main's order: f067f7c1 and 70e4601e, then b0444f51, then the horizon lane's rebased 6eb21fc9 and db28d331, then the re-pin on the Counter lane's word; suites on build-2 and the digest read after every merge; the next candidate's binary with every gate from its build.
**scene-parity-21 in (14:5x BST), ahead of ui-overlap-fixes-21 in the order because it was green on the exact tip (50ffa562) while ui-overlap-fixes still rebases:** 20c9153b (0d75bc1f the shared scene/ folder and live-dag.js 2.0.3, c1334faf the app side, 20c9153b the parity harness and its gate line). Lines: build-2 app gate 228 + 32 + 8; UI 72; pre-push 56 with four new chain-scene checks (sync byte-equal, the paint-on-push known-failed test, the feed contract, the parity render on build-2: home fold = /live = app Inspect at T+0, +2, +4 s). live-dag.js 2.0.2 → 2.0.3 (paint on every push whatever the visibility, the blank /live fix; the phone rule on the viewport width); proof-core.js unchanged 2.0.0; the site's copies moved on master f7743534 (igneum.network/live-dag.js reads 2.0.3); the source is scene/live-dag.js, both copies written by `node tools/scene/sync.mjs`, the gate refuses drift, so the 0.3.21 pack carries the 2.0.3 bytes. Users see: the light theme's ember at the brand package's #D0420D, the chain feed asking 300 s, Inspect 420 px tall (seven lanes), the phone layout no longer frozen at launch width, api/live keeping the key id in `miner`. Captures: build-2 /srv/builds/scene-parity/igneum-wt-scene-parity/_out and scratchpad/scene/parity-out.
**scene-parity-21-sizing in (15:0x BST):** 7e15bf2f on 3dc0832a: live-dag.js 2.0.4 (the box's height follows the lanes through onSize and autoHeight, for /live; the app passes neither, its frames byte-identical, the parity test equal on every comparison); scene/, site/ and the app copy byte-equal; no Rust change, the app gate of 20c9153b stands; pre-push 56 green. The same 2.0.4 is on master at b9017422 and served by igneum.network.
**pool-finish-21 in (15:0x BST):** 2eea335f (the ten pool commits rebased onto 3dc0832a, no conflict; the pool-fee sentence in site/miner.html). Lines at that tip on build-2: igneum-pool 28; the app gate 229 + 32 + 8. Merged after scene-parity-21-sizing (JS only, so the Rust gate stands). The app side of 0.3.21 now carries: driver-check, miner-reliability-21 (cbd6f3a4), gpu-logos-21 with the Prove switch fix, earnings-tidy-21, scene-parity-21 and its sizing (live-dag.js 2.0.4), pool-finish-21; still mine: the under-12 GB prove-instead switch (section 2). The app gate on the final tree runs on build-2 about 19:30 BST or as soon as the switch lands.
**N15 rides 0.3.21's node line (the node lane, 15:1x BST):** branch numbering-fix at d8bceca5 (a6864e36 then d8bceca5, from 52e96c94): chain_path checks the first added block's selected parent against the tip record before anything is appended and hands the orphan records above the fork point to the reorg unwind (the shape that left p1-5090 two high: a reorg's removed list one short at 15:51Z on 6 October); a start-time self-check once per process walks the records from the restart pin against the DAG's selected parents, names the first break, unwinds above it and lets the follower re-walk (the shape that left p2-3090-3 46 high from a snapshot carrying its source's break); recordsContinuous and continuityBreak on igneum_getProvingStatus and igneum_getExecStatus (null, true, or false with the break's block), box-prover claiming only on true. The number is canonical by construction from the pin; the carrier's refusal by number stands (the statement binds the number). Two unit tests known-failed first; the exec suite 35 and the kaspad check green on build-2 at 14:13Z; the live line is the fleet's restart of p1-5090 and p2-3090-3 on the 0.3.21 candidate (the self-check naming #155958 and #158875, the unwind, offset 0 after). The 0.3.21 node order, final, on byte 5: 52e96c94, f067f7c1, b0444f51, 437f0438, 2e32d5f6, f95178a1, a6864e36, d8bceca5.
**update-return-21 in (15:3x BST):** 9d838ae5, one commit on b4289c4a (the app half: ota.rs, engine.rs, jobrun.rs, ember.rs, platform.rs, main.rs, bootcheck.rs; app/windows/host.cpp; Igneum-Miner.iss /IGNOTA=2; one round, main.rs's mod list by union). Lines on the tree: build-2 app tests 247 + 32 + 8; UI 74; the Windows cross green on build-1 (igneum-app.exe 4,172,288 B sha256 5b0598ad…, system DLLs only). host.cpp compiles in the cut's windows.yml run: the named gate line. The relay half stays on update-return for the relay lane's line through master.
**first-block-21 in (16:0x BST):** 6e555d47 on 064fb02b (ladder.rs: first_block_shown persisted in ladder.json, Ladder::start_run; engine.rs start_run at load and started_at on the state; app.js staleCard, firstWait, the first rung reading the flag; the ui-mock secondblock scenario). Lines: build-2 app gate 254 + 32 + 8 (seven new ladder tests); UI 67 (view 46, one new known-failed first); pre-push 56. No installer, node or host change. Captures ~/Desktop/igneum-previews-2026-10-07/first-block/01 and 02.
**Three more in (16:2x BST):** update-return-21b a64c193f (the eGPU card kind from a USB4 or Thunderbolt router in the device's parent chain, the Power Helper's fault line and its stale-prefix fix; app 255 + 32 + 8, UI 76); first-block-21 at cc9141cb replacing 6e555d47 (main's "seen once": the first-block card waits until a window has shown it, POST api/card/seen, ladder.rs card_seen; app 255 + 32 + 8, UI 67); miner-reliability-21 at 018440ae (the register: MF-11 in the update-return lane's words, MF-12 the pool stall, MF-13 the Power Helper's stale count; code unchanged since 4a28eb59, CI success). UI tests on the merged tree 76 of 76; the app gate on build-2 at the merged tip below. GitHub refused pushes with "Internal Server Error" from about 15:25 to 16:18 BST, transient.
**The node order, final (16:3x BST):** on 55768f88: c631c64b first (the test-only fix of the two stale integration targets, 1026 → 1282; both green on build-2 at 15:34Z), then 52e96c94, f067f7c1, b0444f51, 437f0438, 2e32d5f6, f95178a1, a6864e36, d8bceca5; the tip's suite set runs the consensus crate whole (`-p kaspa-consensus` without `--lib`: the lib and both integration targets) beside consensus-core, the miner, kaspa-pow, the exec suite and the three checks (the suite rule from the 0.3.20 known-red finding). era-vdf-node 394a5902 is 0.3.22's.

View file

@ -428,7 +428,7 @@ Designed at the level of a sentence in the design document ("a new instruction m
### 1.13.1 Era seed and draw
The era seed `E_n` is the 32-byte output of the 1-hour VDF of section 4.4. One SplitMix64 stream seeded from `seed_words_from_bytes("igneum-era/" || n_le64 || E_n)` words 0 and 1, drawn in a fixed order, sets the era parameters within genesis-fixed bounds:
The era seed `E_n` is the 32-byte output of the 1-hour VDF of section 4.4 (in the node from `era_vdf_activation_daa`, 7 October 2026, era VDF lane: the delay over the hash of the cut block's day under the genesis scheme byte; before the activation, and on every network today, the devnet stand-in below). One SplitMix64 stream seeded from `seed_words_from_bytes("igneum-era/" || n_le64 || E_n)` words 0 and 1, drawn in a fixed order, sets the era parameters within genesis-fixed bounds:
| Parameter | Base (era 0) | Draw | Bound |
|---|---|---|---|
@ -448,7 +448,7 @@ The table layout and the working-set window (Counter ASIC 2.0 layers 4 and 8, de
3. `R = 1 + below(31)`: the stride rotation.
4. to 7. `r_i = next()` for `i` in 0..3: the interleave draws. With `b = log2(W)` and `free = 4 - b`, `c = [b, ..., 15]`; for `i` in `0..free`: `j = i + (r_i mod (16 - b - i))`, swap `c[i]` and `c[j]`; the interleave is `pos = [0, ..., b - 1] ++ sort(c[0..free])`, four ascending bit positions below 16.
The era parameters are `(W, M, R, pos)`. Dataset mapping under class v3: word `w` holds word `j(w)` of item `t(w)`, where bit `i` of `j(w)` is bit `pos[i]` of `w` and `t(w)` is `w` with bits `pos[0..3]` removed; with `pos = [0, 1, 2, 3]` this is `dataset[w] = item(w >> 4)[w AND 15]` byte for byte; an item keeps its value at every dataset size of at least 2^16 words, and the `W` words of one aligned load lie in one item, so the 4,096-item verifier bound of 1.11 holds. Load address under class v3, for a load site with window draws `(k_off, o)` and a dataset of `2^D` words: `k = min(k_off, D - 26)`, `y = rotl(x * M, R)`, `idx = ((y AND (MASK >> k)) OR ((o AND (2^k - 1)) << (D - k))) AND MASK` (uniform on the window, branch-free, three operations before the mask), one text form in Metal, CUDA and OpenCL. The window draws per instruction (layer 8), after the nine draws of 1.4.3: `k_off = below(3)` (the dataset, a half or a quarter) and `o = low32(next()) AND (2^k_off - 1)`, used only on a load slot, so a class v3 program takes 720 draws; the window never goes below 2^26 words (256 MiB, above the largest on-chip cache in the benchmark) nor above the dataset, and sixteen sites with drawn offsets cover the dataset with high probability (a windows-union census over 300 programs: the SRAM mirror a chip would need is the whole dataset in every hour). The acceptance rule of 1.4.6 is unchanged in its tests and mirrors this address at its constant `D = 28`. Devnet stand-in for `E_n` until the VDF of 4.4 is in the node: era 0 the genesis block hash; era `n >= 1` the hash of the last selected-chain block whose DAA score is below `15,552,000 n - 7,200`. What the interleave buys and does not: a chip that hard-wires one layout reads the wrong 15 words with every word once the era draws another; a chip whose address decoder can permute its address lines pays nothing (stated in the plan). The stride is a bijection with no cryptanalysis yet (Open).
The era parameters are `(W, M, R, pos)`. Dataset mapping under class v3: word `w` holds word `j(w)` of item `t(w)`, where bit `i` of `j(w)` is bit `pos[i]` of `w` and `t(w)` is `w` with bits `pos[0..3]` removed; with `pos = [0, 1, 2, 3]` this is `dataset[w] = item(w >> 4)[w AND 15]` byte for byte; an item keeps its value at every dataset size of at least 2^16 words, and the `W` words of one aligned load lie in one item, so the 4,096-item verifier bound of 1.11 holds. Load address under class v3, for a load site with window draws `(k_off, o)` and a dataset of `2^D` words: `k = min(k_off, D - 26)`, `y = rotl(x * M, R)`, `idx = ((y AND (MASK >> k)) OR ((o AND (2^k - 1)) << (D - k))) AND MASK` (uniform on the window, branch-free, three operations before the mask), one text form in Metal, CUDA and OpenCL. The window draws per instruction (layer 8), after the nine draws of 1.4.3: `k_off = below(3)` (the dataset, a half or a quarter) and `o = low32(next()) AND (2^k_off - 1)`, used only on a load slot, so a class v3 program takes 720 draws; the window never goes below 2^26 words (256 MiB, above the largest on-chip cache in the benchmark) nor above the dataset, and sixteen sites with drawn offsets cover the dataset with high probability (a windows-union census over 300 programs: the SRAM mirror a chip would need is the whole dataset in every hour). The acceptance rule of 1.4.6 is unchanged in its tests and mirrors this address at its constant `D = 28`. Devnet stand-in for `E_n` below the activation of the VDF of 4.4 (the VDF is in the node since 7 October 2026, behind `era_vdf_activation_daa`, never until Josh sets it per network): era 0 the genesis block hash; era `n >= 1` the hash of the last selected-chain block whose DAA score is below `15,552,000 n - 7,200`, the same block the VDF reads its input from once active. The attack pass's F7 harness showed the stand-in grindable with one block of hash at no delay (1 of 6 cuts) and the VDF closing it (`docs/analysis/era-vdf-2026-10-07.md`). What the interleave buys and does not: a chip that hard-wires one layout reads the wrong 15 words with every word once the era draws another; a chip whose address decoder can permute its address lines pays nothing (stated in the plan). The stride is a bijection with no cryptanalysis yet (Open).
"Memory pattern" in the design document is read here as the item-address pattern (the cache line index word, `s[0]` in 1.8.5, and the XOR-all-sixteen rule); the proposal is to leave it fixed at era 0 and let the unlocked families change the kernel instead, because every change to the item derivation changes the verify time and must be re-measured.

View file

@ -1,6 +1,6 @@
# Igneum protocol specification, section 4: epoch and era seeds through the class-group VDF
Spec version 0.1, 3 October 2026. Status of this section: Measured for the VDF primitive on one machine (`proto-vdf/`, `docs/bench-log.md` entry "proto-vdf, Wesolowski VDF"); Designed for the pipeline; Open where marked. The class-group code has not been reviewed by a second cryptographer (`proto-vdf/README.md`, open item 1).
Spec version 0.1, 3 October 2026. Status of this section: Measured for the VDF primitive on one machine (`proto-vdf/`, `docs/bench-log.md` entry "proto-vdf, Wesolowski VDF"); the ERA path Implemented in the node on 7 October 2026 (era VDF lane, fork branch `era-vdf-node` on the 0.3.19 line: `consensus/core/src/era_vdf/`, `consensus/src/processes/era_vdf.rs`; record `docs/analysis/era-vdf-2026-10-07.md`), behind `era_vdf_activation_daa` (never on every network until Josh sets it per network), with the scheme byte of 4.2 and the measured reference rates of 4.6; the EPOCH path (4.3) still Designed. The class-group code has not been reviewed by a second cryptographer (O-4.1; the node's port is a second implementation of the same algorithms on a fixed-width integer, checked against `num-bigint` and against the textbook composition, not a review).
## 4.1 Why a delay
@ -24,7 +24,16 @@ proof = (T, y, pi)
verify: rederive D and x, recompute l, check pi^l * x^(2^T mod l) == y, recompute output
```
Tags (Implemented in `proto-vdf/src/seed.rs` for the epoch path): `tag_D = "igneum-epoch-discriminant"`, `tag_l = "igneum-vdf-challenge"`, `tag_out = "igneum-program-seed"`. The era path uses `"igneum-era-discriminant"` and `"igneum-era-seed"` (Designed, not yet in code). Prime |D| kills the 2-torsion, the low-order element Wesolowski must exclude. Whether to mirror chiavdf's byte layout for HashPrime exactly, and whether the proof should carry D (the verifier otherwise pays a 17 ms average prime search), are Open (O-4.5).
Tags (Implemented in `proto-vdf/src/seed.rs` for the epoch path): `tag_D = "igneum-epoch-discriminant"`, `tag_l = "igneum-vdf-challenge"`, `tag_out = "igneum-program-seed"`. The era path (Implemented, `kaspa_consensus_core::era_vdf`) uses `"igneum-era-discriminant"` and `"igneum-era-seed"`, with the scheme byte in the seed preimage: `E_n = SHA-256("igneum-era-seed" || scheme || input || T_be64 || y)`. Prime |D| kills the 2-torsion, the low-order element Wesolowski must exclude. The node derives D itself once per era (the search is a per-era one-off, measured in 4.6) and verifies with the group held; HashPrime's byte layout is this specification's (a 64-bit big-endian counter suffix, not chiavdf's in-place increment), which O-4.5 keeps open only for tooling compatibility.
**The scheme byte and the fallback (7 October 2026, era VDF lane; mission item 8, `docs/analysis/mission/future.md` 7.4 item 5).** The era VDF is one of two schemes behind a genesis byte `vdf_scheme` (`Params::vdf_scheme`, in the digest with the activation and T once the activation is set), the same shape as the vote keys' `sig_scheme`: a flip is a class change under the 95 percent signal with a floor height, never a fork. The bytes are coordinated with the genesis-forward lane's `sig_scheme` (0 = BLS12-381 there; the two bytes are separate fields with the same mechanism, `era_vdf::scheme_known` and `igneum::sig_scheme_of_class` the two tables a class change fills).
| Byte | Scheme | Delay | Proof | Verify | Why it exists |
|---|---|---|---|---|---|
| 0 | Wesolowski over the class group of a 1,024-bit prime discriminant derived from the input (this section) | T squarings | (y, pi), 516 bytes (13 bytes of framing on the wire, `EraVdfProof::to_bytes`) | two short exponentiations, milliseconds (4.6) | the production choice: no trusted setup, Chia precedent |
| 1 | SHA-256 hash chain: `s_0 = SHA-256("igneum-era-hash-chain" || input)`, `s_{i+1} = SHA-256(s_i)`, `y = s_T` | T hashes | the end state, 32 bytes | recomputation: the full delay on one core | the post-quantum fallback: a class group's order is computable on a cryptographically relevant quantum computer (Hallgren's algorithm, polynomial time for imaginary quadratic class groups), and a known order collapses `x^(2^T)` to one short exponentiation; a sequential hash has no known quantum shortcut (Grover does not apply to a sequence) |
The fallback costs every verifier the full delay, which 4.5 already asks of every mining node (one core for an hour per era); what it loses is the 5 ms check for light clients and syncing nodes, who then take `E_n` from the chain's own work (a block mined under the wrong `E_n` fails its proof of work under the right program) or recompute. The flip is sized, not scheduled: `T` for scheme 1 at the reference core is in 4.6.
| Parameter | Value | Label |
|---|---|---|
@ -56,12 +65,14 @@ Determinism of step 1 is the point of the rule: which block is "the checkpoint a
## 4.4 The era seed pipeline
Designed (design document: "The era draw applies a one-hour delay function to the hash of all blue blocks in the day ending at the last certified checkpoint one epoch before the boundary"). Era n is the DAA-score interval `[15,552,000 n, 15,552,000 (n + 1))` (section 1.12).
Implemented in the node on 7 October 2026 (era VDF lane; design document: "The era draw applies a one-hour delay function to the hash of all blue blocks in the day ending at the last certified checkpoint one epoch before the boundary"; `consensus/src/processes/era_vdf.rs`, `consensus/core/src/era_vdf/`). Era n is the DAA-score interval `[15,552,000 n, 15,552,000 (n + 1))` (section 1.12; `igneum::pow_era_blocks`, a constant on every network that a private test network's override file may shorten with `pow_era_blocks` and `pow_era_lead`, in the digest when it does). The pipeline applies to every era whose start is at or above `Params::era_vdf_activation_daa` and never to era 0 (genesis seeds it); below the activation the devnet stand-in of `docs/plans/era-layout.md` section 2 stands (the cut block's hash itself).
1. `C_era(n)` is the highest-index checkpoint whose block has DAA score at most `15,552,000 n - 7,200` (2 hours of lead, 2x the 1-hour evaluation).
2. `input = Hash(chain_id || n_le64 || h_1 || h_2 || ... || h_m)` where `h_1..h_m` are the hashes of the blue blocks in `C_era(n)`'s past with DAA score in `(daa(C_era(n)) - 86,400, daa(C_era(n))]`, in ascending (blue score, hash) order, and `Hash` is the chain's BLAKE2b-based hash. This is a function of `C_era(n)`'s past, so it is as deterministic as 4.3 step 1.
3. Run 4.2 with `T = T_era = 6 x T_epoch` and the era tags. `E_n` is the output.
4. The era draw of section 1.13.1 consumes `E_n` as its only randomness (`proto-vdf/README.md` open item 6: check that nothing else enters the draw).
1. **The cut block.** The cut of era n is the DAA score `15,552,000 n - 7,200` (2 hours of lead, 2x the 1-hour evaluation; `igneum::pow_era_seed_score`). `C_era(n)` is the last selected-chain block below the cut on the chain of the header being validated (`class_signal::seed_below`, the block the stand-in used as `E_n`). This is the checkpoint block the cut rule names under the O-4.3 decision of 3 October 2026 (3.11 item 6: the seed checkpoint is the chain block the lead rule names, certified or not, so a finality pause never stops the program): a function of the header's own past, so two nodes validating one header derive one `C_era(n)`, and a header on a chain that reorged across the cut names another block and is validated under that block's seed. Binding `C_era(n)` to the certified checkpoint instead (the certificate carried by a block in the header's past, the design document's wording) is a change to one function, `EraVdfManager::cut_block`; it would couple the era seed to finality liveness (a pause across the cut leaves the era without a seed) and needs a deterministic rule for a certificate that lands late, which the record (`docs/analysis/era-vdf-2026-10-07.md`, section 4) spells out for the finality lane; the grinding defence does not depend on it, since the delay is what makes any candidate's draw unknowable.
2. **The input.** `input = Hash(chain_id || n_le64 || h_1 || h_2 || ... || h_m)` where `h_1..h_m` are the hashes of the blue blocks in `C_era(n)`'s past with DAA score in `(daa(C_era(n)) - day, daa(C_era(n))]`, `day` being the dataset day (`pow_day_ms / 1,000`, 86,400 DAA s on the devnet) at the network's block rate, in ascending (blue score, hash) order, and `Hash` the chain's BLAKE2b-256 keyed `"IgneumEraVdfInput"` (`era_vdf::era_vdf_input`; `chain_id` the prefixed network name the finality votes use). Walked as the class signal walks a window: every chain block's mergeset blues once each, down to the merge depth below the day's start. A function of `C_era(n)`'s past, memoised per cut block.
3. **The delay.** `era_vdf::evaluate(scheme, input, T_era)` with `scheme = Params::vdf_scheme` and `T_era = Params::era_vdf_t` (4.6): under scheme 0, 4.2 with the era tags; under scheme 1, the hash chain. Every node runs it itself on one thread (the prover's residue classes on up to 8), started by the virtual processor once the sink is a quarter of the lead past the cut (the epoch seed's confirm margin: right at the cut the selected chain still flips between sibling tips), and persisted (`DbEraVdfStore`, one row per era: input, proof, `E_n`) when it ends, an hour before any header of the era can exist under the 2x lead. A header that arrives before the node holds the record (a node syncing across an era boundary with no peer's proof, 4.5) waits for the evaluation on the header processor's thread.
4. **The seed.** `E_n = SHA-256("igneum-era-seed" || scheme || input || T_be64 || y)`, which the era draw of section 1.13.1 consumes as its only randomness (O-4.8: the draw's preimage is `"igneum-era/" || E_n`, nothing else; the node hands the generator `E_n` alone through `EpochSeeds::era`). Every block template reports `E_n` as `era_seed`, the input, the scheme, T and the evaluation state (`PowEpochInfo`), and reports no `era_seed` while the node is still evaluating, on which the miner holds (`igneum-miner`: "era VDF: the node is still evaluating").
The gate the attack pass set (F7 sub-row a, `docs/analysis/attack-pass/f7-era.md`): the re-roll harness fires with the VDF off and is silent with it on. Measured 7 October 2026 on the box, `tools/era-vdf/reroll.mjs` against the real era cut on a fast-time network (era 120 DAA, lead 20): the record's section 3 carries both runs.
## 4.5 Who evaluates, who verifies
@ -69,6 +80,8 @@ Every mining node evaluates both VDFs itself: one CPU core for 10 minutes each h
There is no timelord role and no race: unlike Chia, the chain does not wait for the VDF; it uses a VDF output that was fixed 20 minutes (epoch) or 2 hours (era) earlier. "No node evaluates" means no miner is running a CPU, which means nobody is mining.
Implemented for the era (7 October 2026): every node evaluates (4.4 step 3) and holds the record; `EraVdfManager::submit` accepts a record from outside when its input is the one this node derives for the era, its scheme and T are the network's, its proof verifies and its output is the seed of the proof. What is still owed before era 1 of any network with the switch set (180 days after that network's genesis at the earliest): the P2P message type that gossips the record and serves it to a syncing peer, and the RPC that imports one; until then a node syncing across an era boundary evaluates the delay itself on its header processor's thread (4.4 step 3).
## 4.6 T from a reference core rate, fixed at genesis
Designed (`proto-vdf/README.md`, parameter recommendation). Before genesis, run `vdf bench` (or chiavdf's `vdf_bench`) on every devnet node type that will mine, take the fastest honest single-core NUDUPL rate observed as `r_ref` (squarings per second), and fix at genesis:
@ -80,6 +93,20 @@ Designed (`proto-vdf/README.md`, parameter recommendation). Before genesis, run
Choosing the fastest honest core, not the median, keeps the stated 10 minutes an upper bound for honest nodes and leaves the attacker margin intact. T MUST NOT be derived from on-chain timing, which is manipulable. T is a genesis constant; hardware will get faster over the years and the margin will erode slowly from 300x, which a fixed T covers for decades, and the upgrade path of section 5.7 exists if it is ever needed.
**The era constants as set (7 October 2026, era VDF lane; `docs/analysis/era-vdf-2026-10-07.md` section 2).** The reference rate is the NODE's own evaluator (the fixed-width-integer class group of `kaspa_consensus_core::era_vdf`), not chiavdf's, because an honest node runs the node's code and must finish inside the lead: the two rules are "T from the honest evaluator's rate" and "the margin against the fastest prover anyone can run". Measured on one core of igneum-build-2 (AMD EPYC 9454P, nice 10, the box under load):
| Constant | Value | Basis |
|---|---|---|
| `ERA_VDF_REFERENCE_SQUARINGS_PER_S` | 30,000 | the node's evaluator at 30,589 and 40,117 squarings/s in two runs; the lower run is the reference, so the hour is an upper bound at it |
| `T_era`, scheme 0 (`ERA_VDF_T_CLASS_GROUP`, `Params::era_vdf_t`) | 108,000,000 squarings | 3,600 x 30,000; 45 to 60 min on the box core, about 72 min on a 2019-class core (the F6 calibration), inside the 2-hour lead |
| `ERA_VDF_REFERENCE_HASHES_PER_S` | 16,000,000 | SHA-256 chain at 16.2 and 17.0 million/s (SHA-NI) |
| `T_era`, scheme 1 (`ERA_VDF_T_HASH_CHAIN`) | 57,600,000,000 hashes | 3,600 x 16,000,000 |
| Verify, scheme 0 | 21.9 to 22.6 ms with the group held; the discriminant derivation 161 to 167 ms once per era | the 10-ms gate is missed by 2.2x on the box core; the record's section 5 says what closes it (O-4.6's reducer, a limb-level NUDUPL, or GMP behind a feature on x86-64) |
| Prove, scheme 0 | about 14 percent of the evaluation single-threaded, parallel over residue classes | at T 401,167: eval 10.0 s, prove 1.6 s |
| The fastest prover measured | chiavdf's NUDUPL over GMP at 208.8 K squarings/s on the same core (its AVX-512 IFMA path was not established in this build) | the delay at that prover: 517 s, 517x the 1-s block interval and 259x the 2-s window; at a hardware prover 10x faster, 52 s, still 26x the window |
The epoch constants (`T_epoch`) stay as this section designs them until the epoch path is implemented; a measured `T_epoch` would be 600 x the same reference rate, 18,000,000 squarings.
| Attacker evaluator speed vs reference | Epoch delay | Era delay | Beats the 2-s window |
|---|---|---|---|
| 1x | 600 s | 3,600 s | no, margin 300x |

View file

@ -77,7 +77,10 @@ An item closes when its measurement is in `docs/bench-log.md` or its decision is
| O-4.5 | HashPrime byte layout (mirror chiavdf or not); carry D in the proof so the verifier skips the 17 ms prime search; compact form encoding before the wire format freezes | Decision, cryptographer | 3 |
| O-4.6 | Reduction runs every squaring; chiavdf reduces only when `a` exceeds 8 limbs | Port; re-measure r_ref | 3 |
| O-4.7 | No fuzzing of `deserialize` on hostile bytes beyond validity checks; no VDF rate measured on NVIDIA or AMD hosts' CPUs | Fuzz the deserializer; bench on the devnet hosts (same run as O-4.2) | 3 |
| O-4.8 | The era draw must take the VDF output as its only randomness (`proto-vdf/README.md` item 6) | Check against section 1.13.1 once O-1.11 is fixed | 1, with 3 |
| O-4.8 | The era draw must take the VDF output as its only randomness (`proto-vdf/README.md` item 6) | CLOSED 7 October 2026 (era VDF lane): the node hands the generator `E_n` alone (`EpochSeeds::era`), the draw's preimage is `"igneum-era/" || E_n`, nothing else; `E_n = SHA-256(tag || scheme || input || T || y)` (spec 4.4 step 4) | 1, with 3 |
| O-4.10 | The P2P relay of an era record (spec 4.5): the message type that gossips `(era, input, proof, E_n)` and serves it to a syncing peer, and the RPC import; until then a node syncing across an era boundary without the record evaluates the delay itself on its header processor's thread (4.4 step 3) | Owed before era 1 of any network with `era_vdf_activation_daa` set (180 days after that network's genesis at the earliest); the node lane, on top of `EraVdfManager::submit` | 3 |
| O-4.11 | Whether `C_era(n)` binds to the certified checkpoint (the design document's wording) or stays the chain block the cut names, certified or not (the O-4.3 reading the node implements, 4.4 step 1): the certified binding couples the era seed to finality liveness and needs a rule for a certificate that lands late (`docs/analysis/era-vdf-2026-10-07.md` section 4) | Decision, Josh with the finality lane; one function (`EraVdfManager::cut_block`) either way | 3 |
| O-4.12 | The era VDF's activation per network (`era_vdf_activation_daa`, never everywhere today) and the scheme byte at genesis (0): Josh's word per network; the fast-time gate ran at activation 0 | Decision, Josh | 3 |
| O-4.9 | Grinding model assumptions: advantage uniform on 0 to 15% per program (the review's measured range), top-quartile keep rule, one block burned per candidate | Re-run `vdf grind` with the advantage distribution from the O-1.3 census | 3 |
## 6.5 Section 5, fees and economics
@ -151,4 +154,6 @@ Section 3.11 states the finality guarantees with their assumptions and derives t
Count after this addition: section 3 has 19 items (O-3.15 to O-3.19 added; O-3.6 narrowed to the devnet test), section 4 keeps 9 with O-4.3 decided and awaiting implementation; total 66.
Added 7 October 2026 (era VDF lane): O-4.8 closed, O-4.10 to O-4.12 added; section 4 has 12 items; total 69.
Update of 4 October 2026 (floor 2/3): O-3.15 decided and O-3.16 closed as text (both kept in the table with their resolution), O-3.18 and O-3.19 narrowed as stated in their rows. Section 3 keeps 17 open items.

View file

@ -9,7 +9,7 @@
//! One deliberate difference from the Swift: `program_json` writes the cache line mask inside the `"item"` string
//! as a bare `0x003fffff`. The Swift writes it quoted (`jhex`), which is not valid JSON.
use crate::generator::{EraParams, Instr, Op, Program, ProgramClass, GENERATOR_VERSION, INSTR_COUNT, ITERATIONS, LOAD_SLOTS};
use crate::generator::{EraParams, Instr, Op, Program, ProgramClass, GENERATOR_VERSION, INSTR_COUNT, ITERATIONS, LOAD_SLOTS, PROGRAM_SUBVERSION_V4};
use crate::derive::{instr_line as derive_instr_line, instr_text as derive_instr_text, DERIVE_PROGRAMS};
use crate::memhard::{
hot_key, hot_segments, hot_words, Layout, MixParams, Shape, CACHE_LINES_PER_SEGMENT, CACHE_SEGMENT_LOG2_LINES, CACHE_TAG,
@ -173,6 +173,10 @@ fn program_class_header_lines(p: &Program) -> String {
s.push_str("// runs another class refuses this pack, and a job line names the class it wants (class=v3 era=<hex>).\n");
}
s.push_str(&format!("#define IGNEUM_PROGRAM_CLASS {}\n", jstr(p.program_class().name())));
if p.program_class() == ProgramClass::V4 {
// the class v4 stream sub-version (AP-F8-1 amendment): a worker ignores it, packcheck requires it
s.push_str(&format!("#define IGNEUM_PROGRAM_SUBVERSION {}\n", PROGRAM_SUBVERSION_V4));
}
if let Some(era) = &p.era_bytes {
s.push_str(&format!("#define IGNEUM_ERA_SEED_HEX {}\n", jstr(&hex_bytes(era))));
}
@ -1825,6 +1829,9 @@ pub fn program_json(p: &Program, day: &str, ds: &DatasetSource) -> String {
s.push_str(&format!(" \"loads_per_hash\": {},\n", p.loads_per_hash()));
if p.program_class() != ProgramClass::V2 {
s.push_str(&format!(" \"program_class\": {},\n", jstr(p.program_class().name())));
if p.program_class() == ProgramClass::V4 {
s.push_str(&format!(" \"sub_version\": {},\n", PROGRAM_SUBVERSION_V4));
}
if let Some(era) = &p.era_bytes {
s.push_str(&format!(" \"era_seed_bytes\": {},\n", jstr(&hex_bytes(era))));
}

View file

@ -1076,16 +1076,29 @@ impl Program {
}
pub fn program_id(generator: u32, seed: &[u32; 8], attempt: u32) -> u64 {
let mut b = Vec::with_capacity(PROGRAM_ID_TAG.len() + 4 + 32 + 4);
let mut b = Vec::with_capacity(PROGRAM_ID_TAG.len() + 4 + 32 + 4 + 6);
b.extend_from_slice(PROGRAM_ID_TAG);
b.extend_from_slice(&generator.to_le_bytes());
for w in seed {
b.extend_from_slice(&w.to_le_bytes());
}
b.extend_from_slice(&attempt.to_le_bytes());
if generator == GENERATOR_VERSION_V4 {
// The class v4 sub-version (AP-F8-1 amendment, 7 October 2026): `"sub/" || sub_version as little-endian u16`
// appended for generator 4 only, so a binary from before the load-source rule (sub-version 0, no suffix) and
// one after it never share a program id for one seed; the node's id check then catches a split. v2 and v3
// ids are byte-identical. The node reads the sub-version from [`PROGRAM_SUBVERSION_V4`]; packs carry it as
// IGNEUM_PROGRAM_SUBVERSION and program.json "sub_version".
b.extend_from_slice(b"sub/");
b.extend_from_slice(&PROGRAM_SUBVERSION_V4.to_le_bytes());
}
fnv1a64(&b)
}
/// The sub-version of class v4's program stream, in every generator-4 program id and pack (AP-F8-1: 1 = the
/// load-source rule of `candidate_from_words_class`; 0 was the stream of 6 October 2026, never stamped).
pub const PROGRAM_SUBVERSION_V4: u16 = 1;
/// Domain tag of the program id of a read-width class (never collides with [`PROGRAM_ID_TAG`]).
pub const PROGRAM_ID_TAG_RW: &[u8] = b"igneum-program-rw/";
@ -1224,7 +1237,19 @@ pub fn candidate_from_words_class(
is_hot[slot as usize] = true;
}
// (2) The instructions. `fresh[r]`: r was written by an earlier instruction and no load has read it since.
// Class v4's chain draw (AP-F8-1, 7 October 2026, `docs/analysis/ca3-v4-uniform.md`): a load's source is drawn
// only from registers whose last writer injects or is a rotate (`entropy_kept[r]`), never from one last written
// by `or`, `mul` or `mulhi` (an `or`-written source is all-ones with probability (3/4)^32 per read and made the
// 153x item of the finding). Keyed on the era-composed V4_CLASS so the generator-2 ladder packs keep their stream;
// v2 and v3 take no part. The draw order and the stream are otherwise the same, draw for draw.
// Keyed on the class with the shadow's pass count set aside (the latency ladder draws the same base program at
// every rung: `of_load_class` compares the pass count too and answered None at every rung but 27, found by the
// fork's ladder test, 7 October 2026 10:06Z), the same comparison the fork's "v4 is v3 plus the shadow" test makes.
let source_rule_v4 = class.era.is_some()
&& matches!(class.shadow, Some(ShadowClass { instrs: V4_SHADOW_INSTRS, .. }))
&& LoadClass { era: None, shadow: None, ..class } == LoadClass { shadow: None, ..V4_CLASS };
let mut fresh = [false; 8];
let mut entropy_kept = [false; 8];
let mut instrs = Vec::with_capacity(INSTR_COUNT);
for k in 0..INSTR_COUNT {
let mut roll = rng.below(75);
@ -1250,7 +1275,7 @@ pub fn candidate_from_words_class(
let mut eligible = [0u64; 8];
let mut n = 0usize;
for r in 0..8u64 {
if r != dst && fresh[r as usize] {
if r != dst && fresh[r as usize] && (!source_rule_v4 || entropy_kept[r as usize]) {
eligible[n] = r;
n += 1;
}
@ -1298,6 +1323,7 @@ pub fn candidate_from_words_class(
fresh[src as usize] = false;
}
fresh[dst as usize] = true;
entropy_kept[dst as usize] = op.injects() || matches!(op, Op::Rotl | Op::Rotr);
instrs.push(Instr { op, dst: dst as u8, src: src as u8, src2: b as u8, imm, imm2, rot, bit: bit as u8, mask, width, win, off });
}
// (3) The latency-shadow block (Counter ASIC 3.0 item 8): drawn after the base program from the same stream, so
@ -1785,8 +1811,9 @@ mod tests {
}
/// Counter ASIC 3.0 (6 October 2026): class v4 is class v3 with the latency-shadow block `sh256x27`, drawn after
/// the base program, so a v4 program's base instructions, attempt and era draw are the v3 program's of the same
/// seed and era, draw for draw; its generator is 4, its id `program_id(4, seed, attempt)`, its era recorded;
/// the base program; since the AP-F8-1 amendment (7 October 2026) its base program draws a load's source only
/// from registers whose last writer keeps entropy, so it is its own stream over class v3's load slots and era
/// draw; its generator is 4, its id `program_id(4, seed, attempt)` with the sub-version suffix, its era recorded;
/// v2 and v3 are untouched.
#[test]
fn program_class_v4_is_class_v3_with_the_shadow_block() {
@ -1803,9 +1830,24 @@ mod tests {
assert_eq!(v4.generator, GENERATOR_VERSION_V4);
assert_eq!(v4.program_class(), ProgramClass::V4);
assert_eq!(v4.era_bytes.as_deref(), Some(&era[..]));
assert_eq!(v4.instrs, v3.instrs, "the base program is class v3's, draw for draw");
assert_eq!(v4.attempt, v3.attempt);
// The AP-F8-1 amendment (7 October 2026): class v4's chain draw takes a load's source only from registers
// whose last writer injects or rotates, so its base program is its own stream (the 6 October stream, equal
// to class v3's draw for draw, is sub-version 0 and never stamped); the load slots, the op draws and the era
// draw are still class v3's, and every load site obeys the rule
assert_eq!(v4.seed, v3.seed);
assert_eq!(
v4.instrs.iter().map(|i| i.op.is_load()).collect::<Vec<_>>(),
v3.instrs.iter().map(|i| i.op.is_load()).collect::<Vec<_>>(),
"the load slots are class v3's"
);
let mut kept = [false; 8];
for (k, i) in v4.instrs.iter().enumerate() {
if i.op.is_load() {
assert!(kept[i.src as usize], "load #{k} reads r{} whose last writer does not keep entropy", i.src);
}
kept[i.dst as usize] = i.op.injects() || matches!(i.op, Op::Rotl | Op::Rotr);
}
assert_ne!(v4.instrs, v3.instrs, "the amended v4 base program is not class v3's (a lossy-sourced load was redrawn)");
assert!(v3.shadow.is_empty() && !v3.has_shadow());
assert_eq!(v4.shadow.len(), 256);
assert_eq!(v4.shadow_reps(), 27);

View file

@ -195,6 +195,15 @@ pub fn verify_pack_texts_chain(
let shadow = define_u32(program_h, "IGNEUM_SHADOW_INSTRS").unwrap_or(0);
match (class, shadow > 0) {
(ProgramClass::V4, false) => return Err(PackFault::Disagree("IGNEUM_GENERATOR 4 (class v4) without IGNEUM_SHADOW_INSTRS: not a class v4 pack".into())),
// the class v4 stream sub-version (AP-F8-1 amendment, 7 October 2026): a generator 4 pack from before the
// load-source rule carries no IGNEUM_PROGRAM_SUBVERSION and its program id is another stream's; refused
(ProgramClass::V4, true) if define_u32(program_h, "IGNEUM_PROGRAM_SUBVERSION") != Some(u32::from(crate::generator::PROGRAM_SUBVERSION_V4)) => {
return Err(PackFault::Disagree(format!(
"IGNEUM_GENERATOR 4 (class v4) with IGNEUM_PROGRAM_SUBVERSION {}: this software runs sub-version {} (a class v4 pack from before the load-source rule, or after another amendment)",
define_u32(program_h, "IGNEUM_PROGRAM_SUBVERSION").map(|v| v.to_string()).unwrap_or_else(|| "absent".into()),
crate::generator::PROGRAM_SUBVERSION_V4
)))
}
(ProgramClass::V3, true) => {
return Err(PackFault::Disagree(format!("IGNEUM_GENERATOR 3 (class v3) with a shadow block (IGNEUM_SHADOW_INSTRS {shadow}): a class v4 program is generator 4 (export the pack as class v4)")))
}

View file

@ -92,8 +92,21 @@ fn contract(p: &Program, seed: &str, class: LoadClass, era: Option<[u8; 32]>) {
assert_eq!((i.width, i.win, i.off), (1, 0, 0), "shadow #{k}: no load fields");
}
let base = program_of(seed, LoadClass { shadow: None, ..class }, era);
assert_eq!(p.instrs, base.instrs, "{seed}: the base program is the class's without the shadow");
assert_eq!(p.attempt, base.attempt);
if p.generator == GENERATOR_VERSION_V4 {
// the amended class v4 (AP-F8-1): the chain draw takes a load's source only from registers whose
// last writer injects or rotates, so its base program is its own stream, not class v3's; what holds
// is the rule itself, checked here on every load site in draw order
let mut kept = [false; 8];
for (k, i) in p.instrs.iter().enumerate() {
if i.op.is_load() {
assert!(kept[i.src as usize], "{seed}: load #{k} reads r{} whose last writer does not keep entropy", i.src);
}
kept[i.dst as usize] = i.op.injects() || matches!(i.op, Op::Rotl | Op::Rotr);
}
} else {
assert_eq!(p.instrs, base.instrs, "{seed}: the base program is the class's without the shadow");
assert_eq!(p.attempt, base.attempt);
}
if era.is_none() || p.generator == GENERATOR_VERSION_V4 {
// a generator-2 program carries the shadow in its id bytes; a class v4 program is generator 4
// (ca3-v4-node 7c22d0d), so its id differs from the generator-3 id of the same seeds

View file

@ -132,6 +132,9 @@ fn program_ids_differ_between_class_v3_and_class_v4_of_one_seed() {
let v3 = load("packs-ca2-mixer/mx8-devnet-epoch0", ProgramClass::V3);
let v4 = load("packs-ca3-v4/v4-devnet-epoch0", ProgramClass::V4);
assert_eq!(v3.reference.program.program_id(), 0x73bc_bfe8_ccf9_88f1, "the v3 control's id as pinned");
assert_eq!(v4.reference.program.program_id(), 0xc120_d796_3abd_cd96, "the v4 candidate's id as pinned");
// the amended class v4 (AP-F8-1, sub-version 1): the id of 6 October 2026, c120d7963abdcd96, is the must-differ
// vector (a binary from before the load-source rule), the pinned id below the must-equal one
assert_ne!(v4.reference.program.program_id(), 0xc120_d796_3abd_cd96, "the pre-amendment v4 id must differ");
assert_eq!(v4.reference.program.program_id(), 0x1a42_3069_9a6b_9c60, "the amended v4 candidate's id as pinned");
assert_ne!(v3.reference.program.program_id(), v4.reference.program.program_id());
}

View file

@ -82,7 +82,7 @@ run_cores() {
say "holding cores $set (waited $waited s): $label"
# the keeper closes the lock descriptors first: an inherited flock would outlive the release in its orphaned sleep
( for fd in "${fds[@]}"; do exec {fd}>&-; done; while kill -0 $$ 2>/dev/null; do touch "$LOCKS/lease-$$" 2>/dev/null; [ -s "$LOCKS/lease-$$" ] || printf '%s\n' "$line" > "$LOCKS/lease-$$"; sleep 20; done ) & local keeper=$!
nice -n "$nice" taskset -c "$set" "$@"; local rc=$?
if [ "${LEASE_NO_PIN:-0}" = 1 ]; then "$@"; else nice -n "$nice" taskset -c "$set" "$@"; fi; local rc=$? # LEASE_NO_PIN: the self-test (lock semantics only; a pinned fixture on a loaded box ran seconds late)
pkill -P "$keeper" 2>/dev/null; kill "$keeper" 2>/dev/null; wait "$keeper" 2>/dev/null
rm -f "$LOCKS/lease-$$"; say "released cores $set after $(( $(date +%s) - t0 )) s, exit $rc"
exit $rc
@ -110,29 +110,34 @@ run_quiet() {
}
self_test() {
t=$(mktemp -d); trap 'rm -rf "$t"' EXIT
export IGNEUM_BUILD_SLOTS_DIR="$t/locks" IGNEUM_BUILD_LOG_DIR="$t/log" LEASE_REAP_S=2; mkdir -p "$t/locks"; echo 2 > "$t/locks/slots"
# a PRIVATE lock directory (never the live _locks) and no pinning: the test is about the locks, and it must read the same on an
# idle box and on one at load 120 (the horizon lane, 7 Oct 2026: one red on a full gate, green a minute later)
export IGNEUM_BUILD_SLOTS_DIR="$t/locks" IGNEUM_BUILD_LOG_DIR="$t/log" LEASE_REAP_S=2 LEASE_NO_PIN=1; mkdir -p "$t/locks"; echo 2 > "$t/locks/slots"
local me="$0" fail=0
f() { echo "lease self-test: FAIL: $*"; fail=1; }
# 1. two leases on disjoint cores run together; the same core waits
bash "$me" cores 0-1 --label A -- bash -c "date +%s.%N > '$t/a.start'; sleep 2; date +%s.%N > '$t/a.end'" 2>/dev/null &
sleep 0.5; bash "$me" cores 2-3 --label B -- bash -c "date +%s.%N > '$t/b.start'" 2>/dev/null; sleep 0.2
python3 -c "import sys; sys.exit(0 if float(open('$t/b.start').read()) < float(open('$t/a.start').read()) + 1.5 else 1)" || f "a lease on other cores waited for an unrelated lease"
bash "$me" cores 0-1 --label A -- bash -c "date +%s.%N > '$t/a.start'; sleep 6; date +%s.%N > '$t/a.end'" 2>/dev/null &
for i in $(seq 1 50); do [ -f "$t/a.start" ] && break; sleep 0.1; done
bash "$me" cores 2-3 --label B -- bash -c "date +%s.%N > '$t/b.start'" 2>/dev/null; sleep 0.2
# B ran while A still held its cores (A sleeps 6 s): B's start is before A's end
python3 -c "import sys; sys.exit(0 if float(open('$t/b.start').read()) < float(open('$t/a.start').read()) + 5.5 else 1)" || f "a lease on other cores waited for an unrelated lease"
bash "$me" cores 1-2 --label C -- bash -c "date +%s.%N > '$t/c.start'" 2>/dev/null
python3 -c "import sys; sys.exit(0 if float(open('$t/c.start').read()) >= float(open('$t/a.end').read()) else 1)" || f "a lease sharing a core started before the holder released it"
wait
[ -z "$(ls "$t/locks" | grep -E '^(lease|wait)-')" ] || f "lease or wait files left behind: $(ls "$t/locks")"
# 2. quiet is refused while a slot is held, and runs on an idle box with a holder line naming the owner
( exec 9>>"$t/locks/build-0"; flock 9; echo "pid $BASHPID since x waited 0 s: fake build" > "$t/locks/build-0"; sleep 2 ) &
sleep 0.3; bash "$me" quiet --label Q --owner tester -- true 2>/dev/null; [ $? = 73 ] || f "quiet was not refused while a slot was held"
( exec 9>>"$t/locks/build-0"; flock 9; echo "pid $BASHPID since x waited 0 s: fake build" > "$t/locks/build-0"; sleep 6 ) &
for i in $(seq 1 50); do [ -s "$t/locks/build-0" ] && break; sleep 0.1; done
bash "$me" quiet --label Q --owner tester -- true 2>/dev/null; [ $? = 73 ] || f "quiet was not refused while a slot was held"
wait; : > "$t/locks/build-0"
bash "$me" quiet --label Q --owner tester -- bash -c "grep -q 'owner=tester' '$t/locks/quiet'" || f "quiet did not run on an idle box with the owner in its holder line"
bash "$me" quiet --label Q --owner tester --cap-s 1 -- sleep 5; [ $? = 124 ] || f "the quiet cap did not end the command"
# 3. a quiet is refused while a core is leased
bash "$me" cores 0 --label L -- sleep 2 2>/dev/null & sleep 0.5
bash "$me" cores 0 --label L -- sleep 6 2>/dev/null & for i in $(seq 1 50); do ls "$t"/locks/lease-* >/dev/null 2>&1 && break; sleep 0.1; done
bash "$me" quiet --label Q --owner tester -- true 2>/dev/null; [ $? = 73 ] || f "quiet was not refused while a core was leased"
wait
# 4. reap: a stopped holder older than the window is killed and its file cleared, with a line
bash "$me" cores 5 --label S -- sleep 60 2>/dev/null & local lp=$!; sleep 0.6
bash "$me" cores 5 --label S -- sleep 60 2>/dev/null & local lp=$!; for i in $(seq 1 50); do ls "$t"/locks/lease-* >/dev/null 2>&1 && break; sleep 0.1; done; sleep 0.3
local hp; hp=$(sed -n 's/^pid \([0-9]*\) .*/\1/p' "$t"/locks/lease-* | head -1); kill -STOP "$hp"; sleep 2.5
touch -d '-10 seconds' "$t"/locks/lease-* 2>/dev/null
[ "$(bash "$me" reap 2>/dev/null)" = 1 ] || f "the stopped lease holder was not reaped"
@ -140,7 +145,7 @@ self_test() {
[ -z "$(ls "$t/locks" | grep '^lease-')" ] || f "the reaped lease file remains"
kill -KILL "$lp" 2>/dev/null; wait 2>/dev/null
# 5. a running (not stopped) holder is left alone
bash "$me" cores 6 --label R -- sleep 3 2>/dev/null & sleep 0.6; touch -d '-10 seconds' "$t"/locks/lease-*
bash "$me" cores 6 --label R -- sleep 6 2>/dev/null & for i in $(seq 1 50); do ls "$t"/locks/lease-* >/dev/null 2>&1 && break; sleep 0.1; done; sleep 0.3; touch -d '-10 seconds' "$t"/locks/lease-*
[ "$(bash "$me" reap 2>/dev/null)" = 0 ] || f "a running holder was reaped"; wait
[ "$fail" = 0 ] && echo "lease self-test: disjoint leases run together, a shared core waits, quiet is refused beside a slot or a lease and capped, a stopped holder is reaped after the window, a running one is kept"
return $fail

View file

@ -28,9 +28,10 @@
# `git worktree list` on the Mac; tools/build-remote.sh creates a missing one on first use)
# SLOTS 2 remote build slots (tools/build-remote.sh takes one; remote-run.sh sets CARGO_BUILD_JOBS 90 when it holds
# the only taken slot and 45 when both are held; a measurement takes the `measure` file and excludes builds)
# P2P_PORTS "26611 26811" TCP ports ufw opens beside 22: the devnet seed's p2p (infra/seed-nodes/config.sh devnet
# P2P_PORTS "26611 26811 26621" TCP ports ufw opens beside 22: the devnet seed's p2p (infra/seed-nodes/config.sh devnet
# P2P_PORT=26611) and the testnet seed's (26811). A suffixed devnet (Devnet 2, the fleet's staging
# chain) listens on the same 26611 (infra/cloud-devnet/config.sh P2P_PORT=26611). RPC ports
# chain) listens on the same 26611 (infra/cloud-devnet/config.sh P2P_PORT=26611); the Devnet 2 seed that
# runs on build-1 as a bare process listens on 26621. RPC ports
# (26610, 28610, 26790 and the 268xx set) stay on loopback as on every seed, so they are not opened.
# BOX_HOSTNAME igneum-build-1
# WORKERS_HOST build.igneum.network Caddy serves /srv/workers/{workers,headline}.json there (read-only, all else 404)
@ -54,9 +55,9 @@ SCCACHE_VERSION="${SCCACHE_VERSION:-}"
NODE_MAJOR="${NODE_MAJOR:-22}"
WORKTREES="${WORKTREES:-}"
SLOTS_GIVEN="${SLOTS:-}"; SLOTS="${SLOTS:-2}" # 2 since main's ruling of 6 October 2026 (20:3x UK); remote-run.sh gives 90 jobs alone, 45 beside another
P2P_PORTS="${P2P_PORTS:-26611 26811}"
P2P_PORTS="${P2P_PORTS:-26611 26811 26621}" # 26621: the Devnet 2 seed on build-1 (blocked by ufw until 7 Oct 2026 16:40 BST; the fleet lane found it closed from outside)
BOX_HOSTNAME="${BOX_HOSTNAME:-igneum-build-1}"
case "$BOX_HOSTNAME" in *-2) [ -n "$SLOTS_GIVEN" ] || SLOTS=3 ;; esac # build-2: three slots (main, 7 Oct 2026; the bounded runs take one 32-core band each)
case "$BOX_HOSTNAME" in *-2|*-4) [ -n "$SLOTS_GIVEN" ] || SLOTS=3 ;; esac # build-2 and build-4 (AX162-1, 96 threads): three slots (main, 7 Oct 2026; the bounded runs take one 32-core band each)
WORKERS_HOST="${WORKERS_HOST:-build.igneum.network}" # the dashboard feed's HTTPS name (A record in deSEC, 6 Oct 2026)
SSH_PUBKEY="${SSH_PUBKEY:-}"
BUILD_USER=build
@ -74,7 +75,7 @@ RUNNER_LABELS="${RUNNER_LABELS:-igneum-build-1,ci-red}" # added to the defau
# holds the red watcher's record file and poster. A second box: BOX_HOSTNAME=igneum-build-2
# RUNNER_LABELS=igneum-build-1,igneum-build-2 RUNNER_CPUS=0-31 RUNNER_JOBS=32 (register.sh --host)
RUNNER_CPUS="${RUNNER_CPUS:-}" # AllowedCPUs for the runner's service when set (a second box is bounded like a suite: 32 cores, nice 10)
case "$BOX_HOSTNAME" in *-2|*-3) [ -n "$RUNNER_CPUS" ] || RUNNER_CPUS="0-31" ;; esac # build-2 and build-3 join the pool (label igneum-build-1) bounded to 32 cores at Nice 10 (main, 7 Oct 2026)
case "$BOX_HOSTNAME" in *-2|*-3|*-4) [ -n "$RUNNER_CPUS" ] || RUNNER_CPUS="0-31" ;; esac # build-2, build-3 and build-4 join the pool (label igneum-build-1) bounded to 32 cores at Nice 10 (main, 7 Oct 2026)
RUNNER_JOBS="${RUNNER_JOBS:-48}" # cargo jobs for a CI job: half the box, the agents' builds keep the rest
RUNNER_TOKEN="${RUNNER_TOKEN:-}" # a registration token (1 h), from infra/build-server/runner/register.sh over stdin; never logged
RUNNER_SCCACHE_PORT="${RUNNER_SCCACHE_PORT:-4227}" # the runner's own sccache server; 4226 is the build user's

View file

@ -178,7 +178,8 @@ fi
if [ "${1:-}" = --self-test-slots ]; then
me="${IGNEUM_REMOTE_RUN_UNDER_TEST:-$0}"
t=$(mktemp -d); trap 'rm -rf "$t"' EXIT
mkdir -p "$t/locks" "$t/log" "$t/dir"; echo 2 > "$t/locks/slots"
# a PRIVATE lock directory, never the live _locks; a 3 s settle so two fakes that start seconds apart on a loaded box still see each other
mkdir -p "$t/locks" "$t/log" "$t/dir"; echo 2 > "$t/locks/slots"; export BR_SETTLE_S=3
fake() { # <name> <seconds> [BR_MEASURE=1]: a fake run that records its start and end epoch and the job count it was given
local name="$1" secs="$2" measure="${3:-0}"
IGNEUM_BUILD_SLOTS_DIR="$t/locks" IGNEUM_BUILD_LOG_DIR="$t/log" BR_MEASURE="$measure" BR_CORES="${BR_CORES:-0}" BR_NICE="${BR_NICE:-0}" BR_DIR="$t/dir" BR_CMD="date +%s.%N > '$t/$name.start'; echo JOBS=\${CARGO_BUILD_JOBS:-none} > '$t/$name.jobs'; sleep $secs; date +%s.%N > '$t/$name.end'" \
@ -188,19 +189,19 @@ if [ "${1:-}" = --self-test-slots ]; then
fail() { echo "self-test-slots: FAIL: $*"; exit 1; }
after() { python3 -c "import sys; sys.exit(0 if float(open(sys.argv[1]).read()) >= float(open(sys.argv[2]).read()) else 1)" "$1" "$2"; }
# 1. two concurrent builds: 45 jobs each
fake a 3 & fake b 3 & wait
fake a 6 & fake b 6 & wait
[ "$(cat "$t/a.jobs")" = JOBS=45 ] && [ "$(cat "$t/b.jobs")" = JOBS=45 ] || fail "two concurrent builds got $(cat "$t/a.jobs" "$t/b.jobs" | tr '\n' ' ') (want JOBS=45 JOBS=45)"
# 2. one build alone: 90
fake c 1
[ "$(cat "$t/c.jobs")" = JOBS=90 ] || fail "a lone build got $(cat "$t/c.jobs") (want JOBS=90)"
# 3. a quiet measurement blocks an unbounded build (it starts only after the quiet ended) and lets a bounded suite run beside it
fake m 3 1 & sleep 0.5; fake d 1 & BR_CORES=1 BR_NICE=10 fake s 1 & wait
fake m 9 1 & sleep 0.5; fake d 1 & BR_CORES=1 BR_NICE=10 fake s 1 & wait # the quiet holds 9 s: longer than a slot take plus the 3 s settle
after "$t/d.start" "$t/m.end" || fail "an unbounded build started while a quiet measurement held the box (build start $(cat "$t/d.start"), quiet end $(cat "$t/m.end"))"
python3 -c "import sys; sys.exit(0 if float(open('$t/s.start').read()) < float(open('$t/m.end').read()) else 1)" || fail "a bounded suite waited for the quiet measurement"
[ "$(cat "$t/m.jobs")" = JOBS=none ] || fail "a measurement was given a job count"
grep -q 'owner=self-test' "$t/m.out" "$t/log/builds.jsonl" 2>/dev/null || fail "the quiet holder line lacks its owner"
# 4. a quiet measurement is REFUSED (exit 73) while a build holds a slot or a core is leased
fake e 3 & sleep 0.5; fake n 1 1; rc=$?; wait
fake e 6 & sleep 0.5; fake n 1 1; rc=$?; wait
[ "$rc" = 73 ] && [ ! -f "$t/n.start" ] || fail "a quiet measurement was not refused while a build ran (rc $rc)"
( exec 9>>"$t/locks/core-7"; flock 9; sleep 6 ) & sleep 0.5; fake o 1 1; rc=$?; wait
[ "$rc" = 73 ] || fail "a quiet measurement was not refused while a core was leased (rc $rc)"
@ -382,8 +383,9 @@ else
fi
waited=$(( $(date +%s) - BR_T0 ))
holder_line "$waited" > "$SLOTS_DIR/build-$got"
# the job count: let a build that started in the same second take its slot, then count the slots held (this one included)
sleep 1
# the job count: let a build that started in the same second take its slot, then count the slots held (this one included);
# BR_SETTLE_S widens it for the self-test on a loaded box
sleep "${BR_SETTLE_S:-1}"
held=0
for k in $(seq 0 $((slots - 1))); do
if [ "$k" = "$got" ]; then held=$((held + 1)); continue; fi

View file

@ -66,6 +66,11 @@
"proving_v1_segment_blocks": 8,
"proving_v1_unproven_daa": 10,
"proving_v1_aggregator_share_bps": 1000,
"pow_era_blocks": 259200,
"pow_era_lead": 120,
"era_vdf_activation_daa": 18446744073709551615,
"vdf_scheme": 0,
"era_vdf_t": 108000000,
"fees_v1_activation_daa": 0,
"difficulty_v3_activation_daa": 18446744073709551615,
"finality_daa_rule_activation_daa": 18446744073709551615,

View file

@ -1 +1 @@
4c6b129d75c3d77a3689d22f1e1dc721b556aebb
c4459193ff5ada38df21f900c91026d3f07c963f

View file

@ -66,6 +66,17 @@
"source": "bench log: 4 October 2026, first outside machine on the devnet: an Apple silicon laptop through the Igneum Miner app",
"by": "reported by the fleet",
"note": "21.0 MH/s average over 7 minutes, 24.3 MH/s at the moment of the report, 33 accepted blocks"
},
{
"card": "NVIDIA RTX 5060 Ti (16 GB)",
"generator": "v2",
"mh_s": 30.9,
"mh_per_w": 0.269,
"miner": "Igneum Miner 0.3.19 package, prebuilt NVRTC worker (bench mode, class v4 program)",
"date": "2026-10-07",
"source": "bench log: 7 October 2026, the first 16 GB card: an RTX 5060 Ti in a Thunderbolt enclosure (run b)",
"by": "measured by the team",
"note": "class v4 program, 128 loads per hash, 1 GiB dataset, 10-minute window on the card alone at the stock 180 W limit: 114.8 W mean; PCIe 4.0 x4 through the enclosure"
}
]
}

View file

@ -2,7 +2,7 @@
second is the target, so the wait is network hashes per second over the card's rate. The rates are the bench table's
measured numbers (site/miners.html); nothing here is a promise. Reads /api/live every 15 s. */
(function () {
var CARDS = [['NVIDIA RTX 5090', 127.7], ['NVIDIA RTX 4070', 28.8], ['Apple M5 Max', 26.7], ['AMD RX 9070 XT', 17.8]];
var CARDS = [['NVIDIA RTX 5090', 127.7], ['NVIDIA RTX 5060 Ti', 30.9], ['NVIDIA RTX 4070', 28.8], ['Apple M5 Max', 26.7], ['AMD RX 9070 XT', 17.8]];
var els = document.querySelectorAll('[data-yourcard]'); if (!els.length) return;
var net = null, chosen = (function () { try { return localStorage.getItem('igneum-card'); } catch (e) { return null; } })();
function dur(sec) { if (!(sec > 0)) return 'pending'; if (sec < 90) return Math.round(sec) + ' s'; if (sec < 5400) return Math.round(sec / 60) + ' min'; if (sec < 172800) { var h = Math.floor(sec / 3600), m = Math.round((sec % 3600) / 60); return h + ' h' + (m ? ' ' + m + ' min' : ''); } return Math.round(sec / 86400) + ' days'; }

View file

@ -0,0 +1,32 @@
# The RTX 5060 Ti in a Razer Core X V2 on PC 2: the jobs (7 October 2026, branch bench-5060ti)
The card: an ASUS Dual GeForce RTX 5060 Ti (16 GB, PnP `PCI\VEN_10DE&DEV_2D04&SUBSYS_8A111043`) in a Razer Core X V2 Thunderbolt enclosure ("USB4 Router (2.0), Razer - Core X V2") on PC 2 (`1ccfe586`, DESKTOP-KMCV30N), beside the RTX 5090 on its own supply. PC 2 lost power twice on 7 October, so NOTHING here writes a power limit: the playbook reads `power.limit` against `power.default_limit` and says whether they were equal; it carries no `nvidia-smi -pl`, `-lgc` or `-lmc`. Every job is a signed `run` job through `packaging/ota/publish-jobs.sh` from the main checkout (its signer binary lives there), published under the PC 2 mkdir lock (`/tmp/igneum-devnet/pc2-ca3.lock`, a holder note inside, removed when the bench ends), with the RUNNER's `--cards-off` for the 5060 Ti alone (the 5090 keeps mining), never `--stop-miners`, never pausing, resuming or quitting the installed app (`tools/ci/playbook-quit-check.sh`).
## 1. Detection (the intake, no job needed)
The app's first poll after the restart that followed the 15:10 UK freeze (run `win-1ccfe586-20261007-143611`, 14:36:16Z): `GPUs: NVIDIA GeForce RTX 5090 (CUDA); NVIDIA GeForce RTX 5060 Ti (CUDA); AMD Radeon(TM) Graphics (OpenCL, gfx1036)` and `cards: NVIDIA GeForce RTX 5090 [discrete, off] | NVIDIA GeForce RTX 5060 Ti [discrete, off] | AMD Radeon(TM) Graphics [integrated, off]`; the app then started a miner on it (`nvidia-1ccfe586-2`, `--device 1`, 8 identities, the default). The kind reads `discrete`, not `external`: the app does not know it is an eGPU. The freeze lane's `pc2-freeze-check-20261007` (14:38:22Z): nvidia-smi index 1, driver 610.47 (WDDM 32.0.16.1047, the 5090's driver, nothing to install), 16,311 MiB, bus `0B:00.0`, status OK; the freeze was NOT the card (no TDR, no Thunderbolt or PCIe link event; Kernel-Power 41 + 6008, no bugcheck: the power shape again).
## 2. The kit and the bench
The kit (packs only; the INSTALLED `igneum-worker-cuda.exe` is the worker under test): the class v4 pack `v4-devnet-epoch0` at SUB-VERSION 1 (the stream 0.3.20 ships at object byte 5, program id 1a4230699a6b9c60, fingerprint 867dbc45cfb36b4d) and the v3 control `mx8-devnet-epoch0` (90f794dd556f7a3b), both as commit a0aaca92 on ca3-v4-amend exported them (master's `proto-cuda/packs-ca3-v4` is the older 6 October stream, f410c731b6bc2d31, not the one to measure against).
tools/bench-5060ti/make-kit.sh "$TMPDIR/igneum-5060ti-packs.zip" # prints sha256 and bytes; checks the vectors prefix 756301bf
packaging/ota/publish-jobs.sh add --kind fetch --target 1ccfe586 --id fetch-5060ti-packs-20261007 \
--file "$TMPDIR/igneum-5060ti-packs.zip" --dir jobs --extract --title "RTX 5060 Ti bench packs (class v4 sub-version 1 and the v3 control)" --deploy
Published 14:40:24Z: sha256 `fd8393ed37c4f311b4e34b131440f9e606e0557b7006e12d1696686753d3ba71`, 105,892 bytes; landed 14:41:11Z, sha256 ok (an app update wipes the jobs folder: republish under a new id after one).
Then the bench, the live network rate and the kit id substituted:
NET=$(curl -s "https://igneum.network/api/live?window=30" | python3 -c "import json,sys;print(round(json.load(sys.stdin)['state']['hashes_per_second_estimate']/1e6,1))")
sed -e "s/__NETWORK_MHS__/$NET/" -e "s/__KIT_ID__/fetch-5060ti-packs-20261007/" tools/bench-5060ti/pc2-5060ti-bench.ps1 > "$TMPDIR/pc2-5060ti-bench.ps1"
packaging/ota/publish-jobs.sh add --kind run --target 1ccfe586 --id run-5060ti-bench-<date> \
--cards-off "nvidia:NVIDIA GeForce RTX 5060 Ti,nvidia:1:NVIDIA GeForce RTX 5060 Ti" \
--script "$TMPDIR/pc2-5060ti-bench.ps1" --timeout-minutes 45 --title "RTX 5060 Ti (Razer Core X V2) on PC 2: ..." --deploy
What it prints (`node tools/jobs.mjs <job id>`): `RESULT gpu ...` (every card's nvidia-smi row: driver, VRAM, the PCIe gen and width = the Thunderbolt link, power.limit against power.default_limit), `RESULT DETECT ...`, `RESULT app_card ...` (read only), `RESULT card ... quiet` (no compute process on the card's UUID: the runner's `--cards-off` took), `RESULT G1 pack=... fingerprint= match=yes|no` for both packs (the worker sees the 5060 Ti alone through `CUDA_VISIBLE_DEVICES` on its UUID and every row names the device it ran on; a row on another card is void), `RESULT sampler stock ...` (nvidia-smi every 2 s on the card over the 10-minute window: mean, p50 and max watts at utilisation 90 percent and over, clocks, temperature), `RESULT bench v4-stock-window ...` (the worker's `--bench` with enough 2^24 dispatches for 600 s: the MH/s is the mean over them), `RESULT tune ...` (the app's own TUNE lines for the card, read only: the efficient point is Ember Tune's to find, its ladder only steps DOWN), `RESULT PROVE ...` (16 GB only: the app's WSL2 host aimed at the 5060 Ti with `IGNEUM_CUDA_DEVICE` on one live shard while a bench loop hashes on the card, VRAM peak from the sampler; BLOCKED with the reason when the shipped host carries no selector, since aiming the stock host proves on CUDA device 0, the 5090, through the app's own socket), then `RESULT ROW ...`, `RESULT PICKER ['NVIDIA RTX 5060 Ti', <mhs>]`, `RESULT CONSEQUENCES ...` and a `SUMMARY {json}` line.
| Run | Published | Outcome |
|---|---|---|
| `run-5060ti-bench-20261007` | 14:42:27Z | VOID in 1 s: the nvidia-smi query and its format flag were one argument (inside `@( ... )` the comma binds before `+`), "temperature.gpu --format=csv is not a valid field"; the query string is built first now. Run b's sampler line was swallowed the same way the Intel lane's Bench lines were (a bare string in a function that returns a value goes into the caller's variable); `[Console]::Out.WriteLine` now |
| `run-5060ti-bench-20261007-b` | 14:44:19Z | ran 14:45:05 to 14:58:11Z, exit 0: the card alone from 0 s, G1 PASS with 867dbc45cfb36b4d and 90f794dd556f7a3b equal to the Mac's (30.879 and 30.895 MH/s at five dispatches), the window 1,105 dispatches in 602 s at 30.882 MH/s and 114.8 W mean (p50 115) at the stock 180 W limit, 0.269 MH/W, SM 2,753 MHz, 60 C; no TUNE line for the card; the prove read BLOCKED (the shipped host has no `IGNEUM_CUDA_DEVICE` selector; the floor server is on PC 2 at `/opt/igneum-floor/bin`, WSL device 1); the bench-log entry, the picker entry and the table row |

34
tools/bench-5060ti/make-kit.sh Executable file
View file

@ -0,0 +1,34 @@
#!/usr/bin/env bash
# The packs kit for tools/bench-5060ti/pc2-5060ti-bench.ps1 (7 October 2026, branch bench-5060ti): the class v4 pack
# v4-devnet-epoch0 at SUB-VERSION 1 (generator 4, program id 1a4230699a6b9c60, the stream 0.3.20 ships at object byte 5;
# 2^24 fingerprint 867dbc45cfb36b4d, Metal = Apple OpenCL = the RTX 5090 on PC 2, job run-ca3-v4-amend-g1-pc2-20261007)
# and the class v3 control mx8-devnet-epoch0 (73bcbfe8ccf988f1, fingerprint 90f794dd556f7a3b), both as commit a0aaca92
# on ca3-v4-amend exported them (the branch moved on to sub-versions 2 and 3; master's proto-cuda/packs-ca3-v4 is the
# 6 October stream c120d7963abdcd96, fingerprint f410c731b6bc2d31, which is NOT the one the bench measures against).
# Packs only: the INSTALLED igneum-worker-cuda.exe on PC 2 is the worker under test, so no exe travels.
# tools/bench-5060ti/make-kit.sh [out.zip] default $TMPDIR/igneum-5060ti-packs.zip
# Prints the zip's sha256 and size: the README's fetch line takes them. Checks the vectors.json sha256 prefixes the
# ledger recorded for sub-version 1 (756301bf for the devnet epoch 0 pack) before packing.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$HERE/../.." && pwd)"
OUT="${1:-${TMPDIR:-/tmp}/igneum-5060ti-packs.zip}"
COMMIT=a0aaca92
STAGE="$(mktemp -d)"
git -C "$ROOT" cat-file -e "$COMMIT^{commit}" 2>/dev/null || { echo "commit $COMMIT (ca3-v4-amend, the sub-version 1 packs) is not in this repository" >&2; exit 1; }
git -C "$ROOT" archive "$COMMIT" proto-cuda/packs-ca3-v4/mx8-devnet-epoch0 proto-cuda/packs-ca3-v4/v4-devnet-epoch0 | tar -x -C "$STAGE"
mkdir -p "$STAGE/packs"
mv "$STAGE/proto-cuda/packs-ca3-v4/mx8-devnet-epoch0" "$STAGE/proto-cuda/packs-ca3-v4/v4-devnet-epoch0" "$STAGE/packs/"
rm -rf "$STAGE/proto-cuda"
v="$(shasum -a 256 "$STAGE/packs/v4-devnet-epoch0/vectors.json" | cut -c1-8)"
[ "$v" = 756301bf ] || { echo "v4-devnet-epoch0/vectors.json sha256 prefix $v, expected 756301bf (sub-version 1)" >&2; exit 1; }
grep -q '"sub_version": 1' "$STAGE/packs/v4-devnet-epoch0/program.json" || { echo "v4-devnet-epoch0 is not sub-version 1" >&2; exit 1; }
grep -q '"program_id": "0x1a4230699a6b9c60"' "$STAGE/packs/v4-devnet-epoch0/program.json" || { echo "v4-devnet-epoch0 program id is not 1a4230699a6b9c60" >&2; exit 1; }
# a copied tree is re-stamped before anything uses it (CLAUDE.md, the stale-build class); the packs are read, not built
find "$STAGE" -type f -exec touch {} +
( cd "$STAGE" && find . -type f ! -name SHA256SUMS | sort | xargs shasum -a 256 > SHA256SUMS )
rm -f "$OUT"
( cd "$STAGE" && zip -X -q -r "$OUT" . )
rm -rf "$STAGE"
echo "kit $OUT (packs from $COMMIT: v4-devnet-epoch0 sub-version 1, mx8-devnet-epoch0 the control)"
echo "sha256 $(shasum -a 256 "$OUT" | cut -c1-64) bytes $(wc -c < "$OUT" | tr -d ' ')"

View file

@ -0,0 +1,289 @@
# Igneum run job: the ASUS Dual GeForce RTX 5060 Ti in a Razer Core X V2 (Thunderbolt) on PC 2 (machine 1ccfe586),
# 7 October 2026 (branch bench-5060ti). Published as a signed `run` job with the RUNNER's --cards-off <the 5060 Ti's key>
# (NOT --stop-miners): the runner switches the 5060 Ti off through the app's own card path before this script and puts
# it back on ANY exit; the RTX 5090 keeps mining. This script never switches a card, never stops, holds or ends the
# installed app (the playbook-quit rule, tools/ci/playbook-quit-check.sh); it reads api/state once, read only.
# POWER RULE (Josh, 7 October 2026: PC 2 lost power twice today): nothing here raises any card's power limit. There is
# no nvidia-smi -pl, -lgc or -lmc anywhere in this file; the limits are READ (power.limit, power.default_limit) and the
# row says whether they were equal. The efficient point is the app's own Ember Tune's to find (its ladder only steps
# DOWN from the stock limit); this job sets nothing and reads the app's TUNE lines for the card, if any.
# After the fetch job that places the two packs (tools/bench-5060ti/make-kit.sh: v4-devnet-epoch0 at sub-version 1,
# the stream 0.3.20 ships, fingerprint 867dbc45cfb36b4d; mx8-devnet-epoch0 the class v3 control, 90f794dd556f7a3b).
# Steps: detect (nvidia-smi: name, VRAM, driver, the PCIe link gen and width = the Thunderbolt link; the Windows
# adapter rows; the app's own card row, read only), the card quiet (no compute process on its UUID), G1 (self-test and
# the 2^24 fingerprint on both packs, bit-exact against the Mac), the 10-minute window at the stock limit (the worker's
# --bench with enough dispatches for 600 s, nvidia-smi sampled every 2 s on the card alone: MH/s and mean watts at
# utilisation 90 percent and over), the app's tune lines (read only), then, on a 16 GB card, the prove-beside-miner
# read: the app's own WSL2 prover host aimed at the 5060 Ti (IGNEUM_CUDA_DEVICE, the prover-floor host's selector)
# on one live shard while a bench loop hashes on the card, VRAM peak from the sampler; when the shipped host carries
# no selector the read is BLOCKED and the line says so (aiming the stock host would prove on the 5090 instead).
# Lines start with RESULT; a SUMMARY {json} line ends the job. Read back with `node tools/jobs.mjs <job id>`.
$ErrorActionPreference = 'Continue'
$jobName = 'pc2-5060ti-bench'
$kitId = '__KIT_ID__' # the publisher substitutes the fetch job id that placed the packs
$started = Get-Date
function Stamp { (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') }
function Summary([string] $status, [hashtable] $extra) {
$o = [ordered]@{ job = $jobName; status = $status; duration_s = [int]((Get-Date) - $started).TotalSeconds; finished_at = (Stamp) }
foreach ($k in $extra.Keys) { $o[$k] = $extra[$k] }
'SUMMARY ' + ($o | ConvertTo-Json -Compress -Depth 4)
}
$expected = @{ 'v4-devnet-epoch0' = '867dbc45cfb36b4d'; 'mx8-devnet-epoch0' = '90f794dd556f7a3b' } # sub-version 1: Metal = Apple OpenCL = the RTX 5090 (job run-ca3-v4-amend-g1-pc2-20261007)
$networkMhs = [double] '__NETWORK_MHS__' # the publisher substitutes live_state.hashes_per_second_estimate / 1e6; 0 = unknown, the line says so
if ($networkMhs -isnot [double] -or [double]::IsNaN($networkMhs)) { $networkMhs = 0 }
$subsidyIgn = 31.69 # IGN a block, spec 02 section 2.5 (1 block a second)
$ref5090Mhs = 122.0; $ref5090W = 308.0 # the 5090's row on PC 2 (main's figures for the per-tier sentence)
$windowS = 600
"RESULT start $(Stamp) job=$jobName machine=$env:COMPUTERNAME app_version=$env:IGNEUM_APP_VERSION network_mhs=$networkMhs"
$jobs = Split-Path $env:IGNEUM_JOB_DIR
$kit = Join-Path $jobs $kitId
if (-not (Test-Path (Join-Path $kit 'packs\v4-devnet-epoch0\program.h'))) { "RESULT error kit missing at $kit (the fetch job $kitId runs first; an app update wipes the jobs folder: republish it under a new id after one)"; Summary 'failed' @{ error = 'kit missing' }; exit 2 }
$job = $env:IGNEUM_JOB_DIR; if (-not $job) { $job = Join-Path $env:TEMP 'igneum-5060ti' }; New-Item -ItemType Directory -Force -Path $job | Out-Null
$inst = @("$env:LOCALAPPDATA\Programs\Igneum Miner", "$env:ProgramFiles\Igneum Miner") | Where-Object { Test-Path (Join-Path $_ 'igneum-worker-cuda.exe') } | Select-Object -First 1
$appDir = if ($env:IGNEUM_APP_DIR) { $env:IGNEUM_APP_DIR } else { Join-Path $env:LOCALAPPDATA 'igneum\app' }
if (-not $inst) { "RESULT error no installed igneum-worker-cuda.exe"; Summary 'failed' @{ error = 'no worker' }; exit 2 }
$exe = Join-Path $inst 'igneum-worker-cuda.exe'
"RESULT app install=$inst app_dir=$appDir worker=$exe sha256=$((Get-FileHash -Algorithm SHA256 $exe).Hash.ToLower())"
foreach ($f in (Get-ChildItem (Join-Path $kit 'packs') -Recurse -File | Sort-Object FullName)) { "RESULT kitfile $($f.FullName.Substring($kit.Length + 1).Replace('\', '/')) sha256 $((Get-FileHash -Algorithm SHA256 $f.FullName).Hash.ToLower()) bytes $($f.Length)" }
# ---- detect: nvidia-smi's rows (the Thunderbolt link is the PCIe gen and width the card reports), Windows' adapter rows, the app's row ----
function Smi([string[]] $argv) { try { @(& nvidia-smi @argv 2>&1 | ForEach-Object { "$_" }) } catch { @("nvidia-smi failed: $($_.Exception.Message)") } }
$gpuFields = 'index,uuid,name,driver_version,memory.total,memory.used,pcie.link.gen.current,pcie.link.gen.max,pcie.link.width.current,pcie.link.width.max,power.draw,power.limit,power.default_limit,power.min_limit,power.max_limit,clocks.max.sm,clocks.max.mem,temperature.gpu'
# the query is ONE string built first: inside @( ... ) the comma binds before +, so '--query-gpu=' + $f, '--format=...'
# became a single argument carrying the format words (run a, 14:43:29Z: "temperature.gpu --format=csv is not a valid field")
$gpuQuery = "--query-gpu=$gpuFields"
$rows = @(Smi @($gpuQuery, '--format=csv,noheader,nounits'))
$rows | ForEach-Object { "RESULT gpu $_" }
$card = $null
foreach ($r in $rows) {
$c = @($r -split ',\s*')
if ($c.Count -ge 18 -and $c[2] -match '5060 Ti') { $card = [pscustomobject]@{ index = [int]$c[0]; uuid = $c[1]; name = $c[2]; driver = $c[3]; mem_total = [int]$c[4]; gen = $c[6]; gen_max = $c[7]; width = $c[8]; width_max = $c[9]; plimit = [double]$c[11]; pdefault = [double]$c[12]; pmin = $c[13]; pmax = $c[14]; clk_sm_max = $c[15]; clk_mem_max = $c[16] }; break }
}
try {
foreach ($a in @(Get-CimInstance Win32_VideoController -ErrorAction Stop)) { "RESULT adapter name=`"$($a.Name)`" driver=$($a.DriverVersion) status=$($a.Status) code=$($a.ConfigManagerErrorCode) pnp=$($a.PNPDeviceID)" }
} catch { "RESULT adapter error=$($_.Exception.Message)" }
if ($null -eq $card) { "RESULT error no RTX 5060 Ti in nvidia-smi's list (the rows above are what the driver sees; is the enclosure's Thunderbolt link up and the card seated?)"; Summary 'failed' @{ error = 'no 5060 Ti in nvidia-smi' }; exit 2 }
$tier = if ($card.mem_total -ge 15000) { 16 } elseif ($card.mem_total -ge 7000) { 8 } else { [math]::Round($card.mem_total / 1024) }
"RESULT DETECT name=`"$($card.name)`" vendor=nvidia vram_mib=$($card.mem_total) tier_gb=$tier driver=$($card.driver) pcie_link=gen$($card.gen)x$($card.width) pcie_max=gen$($card.gen_max)x$($card.width_max) power_limit_w=$($card.plimit) power_default_w=$($card.pdefault) power_range_w=$($card.pmin)-$($card.pmax) limit_is_stock=$(if ($card.plimit -eq $card.pdefault) { 'yes' } else { 'NO' }) smi_index=$($card.index) uuid=$($card.uuid)"
# READ ONLY: the app's view of the card (its key is what the publisher's --cards-off named; the runner, not this script, switched it)
$cardKey = ''; $appDevice = ''
try {
$urlFile = Join-Path $appDir 'app.url'
if (Test-Path $urlFile) {
$url = (Get-Content -LiteralPath $urlFile -Raw).Trim()
$st = Invoke-RestMethod -Uri ($url + 'api/state') -Method GET -TimeoutSec 10
$all = $st.mining.cards; if (-not $all) { $all = $st.cards }
foreach ($c in @($all | Where-Object { $_.name -match '5060' })) { $cardKey = $c.key; $appDevice = "$($c.device)"; "RESULT app_card key=$($c.key) vendor=$($c.vendor) kind=$($c.kind) enabled=$($c.enabled) identities=$($c.identities) state=$($c.state) device=$($c.device) hash_now=$($c.hash_now) vram_mb=$($c.vram_mb) reason=`"$($c.reason)`" message=`"$($c.message)`"" }
if (-not $cardKey) { "RESULT app_card none: the app's state lists no card named 5060 (cards: $(@($all | ForEach-Object { $_.name }) -join ' | '))" }
} else { "RESULT app_card no app.url under $appDir" }
} catch { "RESULT app_card error=$($_.Exception.Message)" }
# the card quiet? read only, by the driver's compute list on the card's UUID: the runner's --cards-off took, or not
function CardApps { @(Smi @('--query-compute-apps=pid,process_name,used_memory,gpu_uuid', '--format=csv,noheader,nounits') | Where-Object { $_ -match [regex]::Escape($card.uuid) }) }
$cardAlone = $false; $t = 0
while ($t -lt 150) { if ((@(CardApps)).Count -eq 0) { $cardAlone = $true; break }; Start-Sleep -Seconds 5; $t += 5 }
if ($cardAlone) { "RESULT card $(Stamp) quiet after $t s: no compute process on $($card.uuid) (the card to itself)" } else { "RESULT card $(Stamp) UNCONFIRMED after $t s: a compute process still runs on the card ($((CardApps) -join '; ')); was the job published with --cards-off $cardKey ? the rows below are LOADED-card figures" }
# The worker sees ONLY the 5060 Ti (CUDA_VISIBLE_DEVICES on its UUID) and takes --device 0; every RESULT line names
# the device it ran on, and a line naming another card voids the row. The 5090's process is never touched.
$env:CUDA_VISIBLE_DEVICES = $card.uuid
# Runs the worker as a child under a wall-clock cap; a child past its cap is ended by ITS OWN pid (taskkill /PID, never
# a name). Output comes back through a file; a trailing "timeout <cap>s" line says when the cap fired.
function Capped([string] $tag, [string[]] $argv, [int] $capS) {
$out = Join-Path $job ("$tag.out"); $errf = Join-Path $job "$tag.err"
Remove-Item -LiteralPath $out, $errf -Force -ErrorAction SilentlyContinue
$p = Start-Process -FilePath $exe -ArgumentList $argv -NoNewWindow -PassThru -RedirectStandardOutput $out -RedirectStandardError $errf
$done = $p.WaitForExit($capS * 1000)
if (-not $done) { try { & taskkill /T /F /PID $p.Id 2>&1 | Out-Null } catch { }; Start-Sleep -Seconds 2 }
$lines = @(); if (Test-Path -LiteralPath $out) { $lines = @(Get-Content -LiteralPath $out -ErrorAction SilentlyContinue) }
if (Test-Path -LiteralPath $errf) { $lines += @(Get-Content -LiteralPath $errf -ErrorAction SilentlyContinue) }
if (-not $done) { $lines += "timeout ${capS}s: the worker was ended by its pid $($p.Id)" }
if ($done) { try { $p.Refresh() } catch { } }
$script:lastExit = if ($done) { $p.ExitCode } else { 124 }
return $lines
}
$script:lastExit = 0
# the nvidia-smi sampler on the card's index, 2 s, to a file; ended by its pid
function SamplerStart([string] $tag) {
$f = Join-Path $job "$tag.csv"; Remove-Item -LiteralPath $f -Force -ErrorAction SilentlyContinue
$p = Start-Process -FilePath 'nvidia-smi' -ArgumentList @('-i', "$($card.index)", '--query-gpu=timestamp,power.draw,power.limit,clocks.sm,clocks.mem,temperature.gpu,utilization.gpu,memory.used', '--format=csv,noheader,nounits', '-lms', '2000') -NoNewWindow -PassThru -RedirectStandardOutput $f -RedirectStandardError (Join-Path $job "$tag.smi.err")
return [pscustomobject]@{ pid = $p.Id; file = $f }
}
function SamplerStop($s) { try { Stop-Process -Id $s.pid -Force -ErrorAction SilentlyContinue } catch { }; Start-Sleep -Seconds 1 }
# the window's numbers from a sampler file: samples at utilisation 90 and over are the loaded ones
function SamplerRead($s, [string] $tag) {
$all = @(); if (Test-Path -LiteralPath $s.file) { $all = @(Get-Content -LiteralPath $s.file -ErrorAction SilentlyContinue | Where-Object { $_ -match ',' }) }
$pw = @(); $pl = @(); $sm = @(); $mem = @(); $tmp = @(); $used = @(); $n = 0
foreach ($l in $all) { $c = @($l -split ',\s*'); if ($c.Count -lt 8) { continue }; $n++; $u = [double]$c[6]; $used += [double]$c[7]; if ($u -ge 90) { $pw += [double]$c[1]; $pl += [double]$c[2]; $sm += [double]$c[3]; $mem += [double]$c[4]; $tmp += [double]$c[5] } }
$o = [ordered]@{ samples = $n; loaded = $pw.Count; w_mean = 0; w_p50 = 0; w_max = 0; limit_max = 0; sm_mhz = 0; mem_mhz = 0; temp_max = 0; mem_used_peak_mib = 0 }
if ($pw.Count -gt 0) { $sorted = $pw | Sort-Object; $o.w_mean = [math]::Round(($pw | Measure-Object -Average).Average, 1); $o.w_p50 = [math]::Round($sorted[[int][math]::Floor($sorted.Count / 2)], 1); $o.w_max = [math]::Round(($pw | Measure-Object -Maximum).Maximum, 1); $o.limit_max = ($pl | Measure-Object -Maximum).Maximum; $o.sm_mhz = [math]::Round(($sm | Measure-Object -Average).Average); $o.mem_mhz = [math]::Round(($mem | Measure-Object -Average).Average); $o.temp_max = ($tmp | Measure-Object -Maximum).Maximum }
if ($used.Count -gt 0) { $o.mem_used_peak_mib = ($used | Measure-Object -Maximum).Maximum }
# the line goes to the host, not the pipeline: a bare string here was swallowed into the caller's variable (run b, 14:55Z: the ROW carried the watts, the sampler line never reached the report)
[Console]::Out.WriteLine("RESULT sampler $tag samples=$($o.samples) loaded=$($o.loaded) watts_mean=$($o.w_mean) watts_p50=$($o.w_p50) watts_max=$($o.w_max) limit_seen_w=$($o.limit_max) sm_mhz=$($o.sm_mhz) mem_mhz=$($o.mem_mhz) temp_max=$($o.temp_max) mem_used_peak_mib=$($o.mem_used_peak_mib) file=$($s.file)")
return $o
}
# ---- the benches ----
# A function's uncaptured output is its return value: Bench writes its lines into $script:benchOut; Flush prints them.
$script:benchOut = @()
function Say([string] $l) { $script:benchOut += $l }
function Flush { $script:benchOut | ForEach-Object { $_ }; $script:benchOut = @() }
function Bench([string] $label, [string] $packDir, [int] $batches, [int] $capS) {
if (-not (Test-Path $packDir)) { Say "RESULT bench $label error=pack missing at $packDir"; return $null }
$t0 = (Get-Date).ToUniversalTime()
Say "RESULT bench $label start $(Stamp) pack=$packDir batches=$batches"
$lines = @(Capped "bench-$label" @('--bench', '--pack', $packDir, '--batches', "$batches", '--batch-log2', '24', '--block-warps', '1', '--device', '0') $capS)
$rc = $script:lastExit
$wall = [int]((Get-Date).ToUniversalTime() - $t0).TotalSeconds
$lines | Where-Object { $_ -match '^RESULT|^warm-up|^info|^pack |^nvrtc|^self-test|FAIL|error|MISMATCH|^timeout' } | Select-Object -First 30 | ForEach-Object { Say "RESULT bench $label $_" }
Say "RESULT bench $label end $(Stamp) exit=$rc wall_s=$wall"
$res = $lines | Where-Object { $_ -match '^RESULT pack=' } | Select-Object -First 1
if ($null -eq $rc -or "$rc" -eq '') { $rc = 0 } # a null exit after WaitForExit on a -PassThru process: the RESULT line is the verdict
if (-not $res -or $rc -ne 0) {
$err = ($lines | Where-Object { $_ -match 'FAIL|error|MISMATCH' } | Select-Object -First 1); if (-not $err) { $err = "exit $rc, no RESULT line" }
Say "RESULT G1 pack=$label error=$($err -replace '\s+', ' ')"
return $null
}
$fp = ''; $mhs = ''; $check = ''; $dev = ''
if ($res -match 'fingerprint=([0-9a-f]{16})') { $fp = $Matches[1] }
if ($res -match 'mhs=([\d.]+)') { $mhs = $Matches[1] }
if ($res -match 'check=(\S+)') { $check = $Matches[1] }
if ($res -match 'device=(\S+)') { $dev = $Matches[1] }
if ($dev -notmatch '5060') { Say "RESULT G1 pack=$label error=ran on device `"$dev`", not the 5060 Ti: the row is void"; return $null }
$want = $expected[$label]
$match = if ($want) { if ($fp -eq $want) { 'yes' } else { 'no' } } else { 'n/a' }
Say "RESULT G1 pack=$label fingerprint=$fp match=$match expected=$want check=$check device=$dev batches=$batches wall_s=$wall harness=cuda-installed"
return [pscustomobject]@{ label = $label; mhs = [double]$mhs; fp = $fp; match = $match; check = $check; wall_s = $wall; device = $dev }
}
# G1: five dispatches each, the fingerprints
$v4 = Bench 'v4-devnet-epoch0' (Join-Path $kit 'packs\v4-devnet-epoch0') 5 300; Flush
$v3 = Bench 'mx8-devnet-epoch0' (Join-Path $kit 'packs\mx8-devnet-epoch0') 5 300; Flush
# the 10-minute window at the stock limit: as many 2^24 dispatches as the five-batch rate says fit in 600 s
$stock = $null; $stockSmi = $null
if ($v4 -and $v4.mhs -gt 0) {
$n = [int][math]::Ceiling($windowS * $v4.mhs * 1e6 / 16777216); if ($n -lt 50) { $n = 50 }; if ($n -gt 6000) { $n = 6000 }
$s = SamplerStart 'stock'
Start-Sleep -Seconds 4
$stock = Bench 'v4-stock-window' (Join-Path $kit 'packs\v4-devnet-epoch0') $n ($windowS + 300); Flush
SamplerStop $s
$stockSmi = SamplerRead $s 'stock'
} else { "RESULT window skipped: no class v4 rate from G1" }
# ---- the efficient point: the app's Ember Tune's, read only (this job sets nothing; the ladder only ever steps DOWN) ----
try {
$logDir = $env:IGNEUM_LOG_DIR; if (-not $logDir) { $logDir = Join-Path $appDir 'logs' }
$newest = Get-ChildItem -Path $logDir -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1
$tuneLines = @()
if ($newest) { $tuneLines = @(Get-Content -LiteralPath $newest.FullName -ErrorAction SilentlyContinue | Where-Object { $_ -match '^\d+(\.\d+)? (TUNE|tune) ' -and $_ -match '5060' } | Select-Object -Last 8) }
if ($tuneLines.Count -gt 0) { $tuneLines | ForEach-Object { "RESULT tune $_" } } else { "RESULT tune none: the app has no TUNE line for the 5060 Ti yet (the card was off for this job; the app's tune runs when the card mines and the Power Helper answers)" }
} catch { "RESULT tune error=$($_.Exception.Message)" }
# ---- prove beside the miner, 16 GB only: the app's own WSL2 host aimed at the 5060 Ti while a bench loop hashes on it ----
$prove = [ordered]@{ ran = $false; verdict = 'not attempted'; peak_mib = 0; base_mib = 0; prove_s = 0; why = '' }
if ($tier -ge 16) {
$hostWin = Join-Path $inst 'wsl2\bin\igneum-prove-host'
if (-not (Test-Path -LiteralPath $hostWin)) { $prove.why = "no WSL2 host at $hostWin"; "RESULT prove blocked why=$($prove.why)" }
else {
function WslPath($p) { $w = (& wsl.exe -d Ubuntu-24.04 -- wslpath -a ($p -replace '\\', '/') 2>$null); if ($w) { ($w -replace "`0", '').Trim() } else { '/mnt/c' + ($p.Substring(2) -replace '\\', '/') } }
$hostW = WslPath $hostWin; $jobW = WslPath $job
# the fixture: one live block from PC 2's own node (read only, the exec RPC on loopback), cut with the app's exporter
$evmPort = $null
foreach ($p in 26790, 26800, 26810) { try { $r = Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:$p" -ContentType 'application/json' -Body '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' -TimeoutSec 20; if ($r.result) { $evmPort = $p; $tipHex = $r.result; break } } catch { } }
if (-not $evmPort) { $prove.why = 'no exec RPC on 26790/26800/26810'; "RESULT prove blocked why=$($prove.why)" }
else {
$tip = [Convert]::ToInt64($tipHex, 16); $blk = $tip - 30
$body = (@{jsonrpc='2.0'; id=1; method='igneum_exportSegments'; params=@('0x0', ('0x{0:x}' -f $blk))} | ConvertTo-Json -Compress)
$bodyFile = Join-Path $job 'export-request.json'; $seqFile = Join-Path $job 'seq.json'
[IO.File]::WriteAllText($bodyFile, $body, (New-Object System.Text.UTF8Encoding $false))
& curl.exe -s -S -m 600 -X POST "http://127.0.0.1:$evmPort" -H 'Content-Type: application/json' --data-binary "@$bodyFile" -o $seqFile 2>&1 | ForEach-Object { "RESULT prove curl $_" }
$seqLen = if (Test-Path $seqFile) { (Get-Item $seqFile).Length } else { 0 }
"RESULT prove export port=$evmPort tip=$tip block=$blk bytes=$seqLen"
# the bash side (LF, no BOM, run as the WSL default user = the app's prover user, never as the root user: the
# socket rule of tools/ci/prover-socket-check.sh; the server this run starts is ended by the pid on ITS socket).
# Inventory first; the prove only when the host carries the selector and the card is not WSL device 0 (device 0's
# socket /tmp/sp1-cuda-0.sock is the app's own server on the 5090 and is never touched).
$bashBody = @'
set -uo pipefail
H="$1"; JOB="$2"; UUID="$3"; BLK="$4"
X="$(dirname "$H")/igneum-prove-export"
CUDA_DIR="$(ls -d /usr/local/cuda-12.* 2>/dev/null | sort -V | tail -1 || true)"; [ -n "$CUDA_DIR" ] && export PATH="$CUDA_DIR/bin:$PATH" && export LD_LIBRARY_PATH="$CUDA_DIR/lib64:/usr/lib/wsl/lib:${LD_LIBRARY_PATH:-}"
export PATH="$HOME/.sp1/bin:$PATH"
stamp() { date -u +%Y-%m-%dT%H:%M:%SZ; }
echo "RESULT prove host=$H exists=$([ -x "$H" ] && echo yes || echo no) sha=$(sha256sum "$H" 2>/dev/null | cut -c1-16) export_exists=$([ -x "$X" ] && echo yes || echo no) user=$(id -un) home=$HOME"
SEL=$(grep -c IGNEUM_CUDA_DEVICE "$H" 2>/dev/null || true); SEL=${SEL:-0}
echo "RESULT prove selector IGNEUM_CUDA_DEVICE strings_in_host=$SEL"
for s in "$HOME/.sp1/bin/sp1-gpu-server" /opt/igneum-floor/bin/sp1-gpu-server; do if [ -x "$s" ]; then echo "RESULT prove server $s bytes=$(stat -c %s "$s") sha=$(sha256sum "$s" | cut -c1-16) version=$("$s" --version 2>&1 | head -1)"; else echo "RESULT prove server $s absent"; fi; done
echo "RESULT prove sockets_before $(ls /tmp/sp1-cuda-*.sock 2>/dev/null | tr '\n' ' ')"
nvidia-smi --query-gpu=index,uuid,name,memory.total --format=csv,noheader 2>/dev/null | sed 's/^/RESULT prove wslgpu /'
IDX="$(nvidia-smi --query-gpu=index,uuid --format=csv,noheader 2>/dev/null | awk -F', ' -v u="$UUID" '$2==u {print $1}' | head -1)"
echo "RESULT prove wsl_index_of_5060ti=${IDX:-none}"
if [ "$SEL" = 0 ]; then echo "RESULT prove blocked why=the shipped host has no IGNEUM_CUDA_DEVICE selector: aimed at nothing it proves on CUDA device 0 (the 5090) through the app's own socket; the prover-floor host carries the selector (proof_system.rs) and is the owed cut"; exit 0; fi
if [ -z "$IDX" ] || [ "$IDX" = 0 ]; then echo "RESULT prove blocked why=the 5060 Ti is WSL CUDA device '${IDX:-none}': device 0 is the app's own prover socket and is never touched by a job"; exit 0; fi
SOCK="/tmp/sp1-cuda-$IDX.sock"
rm -f "$SOCK"
python3 -c "import json; d=json.load(open('$JOB/seq.json')); json.dump(d['result'], open('$JOB/export.json','w'))" || { echo "RESULT prove blocked why=the export reply did not unwrap"; exit 0; }
FIX="$JOB/block-$BLK.json"
if ! "$X" "$JOB/export.json" "$BLK" "$FIX" --source "PC 2 live devnet export, the 5060 Ti prove-beside read" 2>&1 | tail -1 | sed 's/^/RESULT prove cut /'; then echo "RESULT prove blocked why=the cut failed"; exit 0; fi
echo "RESULT prove start $(stamp) fixture=$(basename "$FIX") device=$IDX socket=$SOCK"
t0=$(date +%s)
IGNEUM_CUDA_DEVICE="$IDX" SP1_PROVER=cuda RUST_LOG=off timeout 900 "$H" "$FIX" --mode compressed --shard 0 --prover 0xCAfc6e74000000000000000000000000000000c2 --out "$JOB/prove-5060ti.json" > "$JOB/prove-5060ti.log" 2>&1; rc=$?
echo "RESULT prove end $(stamp) rc=$rc wall_s=$(( $(date +%s) - t0 ))"
grep -E '^(RESULT|STAGE|segment proof|FLOOR|thread|Error|error)' "$JOB/prove-5060ti.log" | head -20 | sed 's/^/RESULT prove host-out /'
# the server this run started answers on ITS socket: ended by that pid, never by a name; the socket unlinked
PID="$(fuser "$SOCK" 2>/dev/null | tr -d ' ' || true)"
if [ -n "$PID" ]; then kill "$PID" 2>/dev/null; sleep 2; kill -9 "$PID" 2>/dev/null; echo "RESULT prove server_ended pid=$PID"; fi
rm -f "$SOCK"
echo "RESULT prove sockets_after $(ls /tmp/sp1-cuda-*.sock 2>/dev/null | tr '\n' ' ')"
'@
$bashFile = Join-Path $job 'prove-5060ti.sh'
[IO.File]::WriteAllText($bashFile, ("#!/bin/bash`n" + ($bashBody -replace "`r`n", "`n").TrimEnd() + "`n"), (New-Object System.Text.UTF8Encoding $false))
$bashW = WslPath $bashFile
# the miner-shaped load on the 5060 Ti for the read: a bench loop of about six minutes, by pid, file output
$loopN = if ($v4 -and $v4.mhs -gt 0) { [int][math]::Ceiling(360 * $v4.mhs * 1e6 / 16777216) } else { 300 }
$loopOut = Join-Path $job 'beside-loop.out'
$loop = Start-Process -FilePath $exe -ArgumentList @('--bench', '--pack', (Join-Path $kit 'packs\v4-devnet-epoch0'), '--batches', "$loopN", '--batch-log2', '24', '--block-warps', '1', '--device', '0') -NoNewWindow -PassThru -RedirectStandardOutput $loopOut -RedirectStandardError (Join-Path $job 'beside-loop.err')
$s2 = SamplerStart 'beside'
Start-Sleep -Seconds 45 # the loop's build, self-test and warm-up: the base reading is the miner-shaped resident set
$baseRow = @(Get-Content -LiteralPath $s2.file -ErrorAction SilentlyContinue | Where-Object { $_ -match ',' } | Select-Object -Last 1)
if ($baseRow.Count -gt 0) { $c = @($baseRow[0] -split ',\s*'); if ($c.Count -ge 8) { $prove.base_mib = [double]$c[7] } }
"RESULT prove base_mib=$($prove.base_mib) (the bench loop resident on the card before the host starts)"
$wslOut = @(& wsl.exe -d Ubuntu-24.04 -- bash $bashW $hostW $jobW $card.uuid "$blk" 2>&1 | ForEach-Object { ("$_" -replace "`0", '') })
$wslOut | ForEach-Object { $_ }
$prove.ran = ($wslOut | Where-Object { $_ -match '^RESULT prove end ' }).Count -gt 0
$endLine = $wslOut | Where-Object { $_ -match '^RESULT prove end ' } | Select-Object -First 1
if ($endLine -and $endLine -match 'rc=(\d+) wall_s=(\d+)') { $prove.prove_s = [int]$Matches[2]; $prove.verdict = if ($Matches[1] -eq '0') { 'proved' } else { "failed rc=$($Matches[1])" } }
$blocked = $wslOut | Where-Object { $_ -match '^RESULT prove blocked ' } | Select-Object -First 1
if ($blocked) { $prove.verdict = 'blocked'; $prove.why = ($blocked -replace '^RESULT prove blocked why=', '') }
if (-not $loop.HasExited) { $loop.WaitForExit(120000) | Out-Null }
if (-not $loop.HasExited) { try { & taskkill /T /F /PID $loop.Id 2>&1 | Out-Null } catch { } }
SamplerStop $s2
$besideSmi = SamplerRead $s2 'beside'
$prove.peak_mib = $besideSmi.mem_used_peak_mib
$loopRes = @(Get-Content -LiteralPath $loopOut -ErrorAction SilentlyContinue | Where-Object { $_ -match '^RESULT pack=' } | Select-Object -First 1)
$loopMhs = if ($loopRes.Count -gt 0 -and $loopRes[0] -match 'mhs=([\d.]+)') { $Matches[1] } else { 'none' }
"RESULT prove loop_mhs=$loopMhs (the bench loop's mean over its dispatches, the prover beside it when it ran)"
$own = if ($prove.ran -and $prove.verdict -eq 'proved') { [math]::Round(($prove.peak_mib - $prove.base_mib) / 1024, 1) } else { 0 }
"RESULT PROVE tier_gb=$tier verdict=$($prove.verdict) prove_s=$($prove.prove_s) peak_mib=$($prove.peak_mib) base_mib=$($prove.base_mib) own_gb=$own vram_mib=$($card.mem_total) spare_gb=$(if ($prove.peak_mib -gt 0) { [math]::Round(($card.mem_total - $prove.peak_mib) / 1024, 1) } else { 'n/a' }) why=`"$($prove.why)`""
}
}
} else { "RESULT PROVE tier_gb=$tier verdict=not-a-16gb-card: the prove-beside read is for the 16 GB tier; an 8 GB card mines only (the 12 GB floor is 13.9 GB for an empty shard)" }
# ---- the row, the picker entry and the consequences ----
$rate = if ($stock) { $stock.mhs } elseif ($v4) { $v4.mhs } else { 0 }
$watts = if ($stockSmi -and $stockSmi.loaded -gt 0) { $stockSmi.w_mean } else { 0 }
$mhw = if ($watts -gt 0 -and $rate -gt 0) { [math]::Round($rate / $watts, 3) } else { 0 }
$blocksDay = if ($networkMhs -gt 0 -and $rate -gt 0) { [math]::Round($rate / $networkMhs * 86400, 2) } else { 0 }
$hoursBlock = if ($blocksDay -gt 0) { [math]::Round(24 / $blocksDay, 1) } else { 0 }
$ignDay = [math]::Round($blocksDay * $subsidyIgn, 1)
$shadowCost = if ($v4 -and $v3 -and $v3.mhs -gt 0) { [math]::Round((1 - $v4.mhs / $v3.mhs) * 100, 1) } else { 'owed' }
$of5090 = if ($rate -gt 0) { [math]::Round($rate / $ref5090Mhs * 100, 1) } else { 0 }
$ref5090Mhw = [math]::Round($ref5090Mhs / $ref5090W, 3)
$mhwVs5090 = if ($mhw -gt 0) { [math]::Round($mhw / $ref5090Mhw * 100) } else { 0 }
"RESULT ROW card=`"$($card.name)`" vram_gb=$tier driver=$($card.driver) pcie=gen$($card.gen)x$($card.width) g1_v4_mhs=$(if ($v4) { $v4.mhs } else { 'failed' }) g1_v3_mhs=$(if ($v3) { $v3.mhs } else { 'failed' }) stock_window_mhs=$(if ($stock) { $stock.mhs } else { 'failed' }) stock_window_s=$(if ($stock) { $stock.wall_s } else { 0 }) watts_mean=$watts watts_p50=$(if ($stockSmi) { $stockSmi.w_p50 } else { 0 }) limit_w=$($card.plimit) default_w=$($card.pdefault) mhw=$mhw sm_mhz=$(if ($stockSmi) { $stockSmi.sm_mhz } else { 0 }) mem_mhz=$(if ($stockSmi) { $stockSmi.mem_mhz } else { 0 }) temp_max=$(if ($stockSmi) { $stockSmi.temp_max } else { 0 }) shadow_cost_pct=$shadowCost efficient_point=app-tune-owed network_mhs=$networkMhs blocks_per_day=$blocksDay hours_per_block=$hoursBlock ign_per_day=$ignDay card_alone=$cardAlone"
"RESULT PICKER ['NVIDIA RTX 5060 Ti', $rate] (site/yourcard.js CARDS entry; measured on the card alone, so it may go on the site)"
"RESULT CONSEQUENCES a 5060 Ti owner ($tier GB, Windows, in a Thunderbolt enclosure at gen$($card.gen)x$($card.width)): $rate MH/s at $watts W ($mhw MH/W), $of5090 percent of the 5090's $ref5090Mhs MH/s at $ref5090W W ($ref5090Mhw MH/W: $mhwVs5090 percent of its hash per watt); $blocksDay blocks a day at today's $networkMhs MH/s network (one every $hoursBlock h, about $ignDay IGN a day at $subsidyIgn IGN a block, approximate: the network rate moves); prove-beside verdict $($prove.verdict); the efficient point is the app's tune to find and is owed (nothing set by this job)"
$status = if ($v4 -and $v4.match -eq 'yes' -and $v3 -and $v3.match -eq 'yes' -and $stock) { 'done' } elseif ($v4 -or $stock) { 'partial' } else { 'failed' }
"RESULT end $(Stamp)"
Summary $status @{ name = $card.name; tier_gb = $tier; driver = $card.driver; pcie = "gen$($card.gen)x$($card.width)"; rate_mhs = $rate; watts = $watts; mhw = $mhw; limit_w = $card.plimit; default_w = $card.pdefault; v4_fp = $(if ($v4) { $v4.fp } else { '' }); v4_match = $(if ($v4) { $v4.match } else { 'failed' }); v3_match = $(if ($v3) { $v3.match } else { 'failed' }); prove = $prove.verdict; prove_peak_mib = $prove.peak_mib; blocks_per_day = $blocksDay; card_alone = $cardAlone }
if ($status -eq 'failed') { exit 1 }
exit 0

View file

@ -19,9 +19,18 @@ stamp="ci-$$-$(date +%s)"
scp -q "${BS_SSH_OPTS[@]}" infra/build-server/lease.sh "$BS_HOST:/tmp/lease-$stamp.sh"
scp -q "${BS_SSH_OPTS[@]}" infra/build-server/remote-run.sh "$BS_HOST:/tmp/rr-$stamp.sh"
rc=0
# the two self-tests run in parallel in one ssh session (each against its own scratch lock directory); their last lines are printed
bs_ssh "set -o pipefail; (bash /tmp/lease-$stamp.sh --self-test 2>&1 | grep -E '^lease self-test'; echo lease=\${PIPESTATUS[0]} >> /tmp/locks-$stamp.rc) & (IGNEUM_REMOTE_RUN_UNDER_TEST=/tmp/rr-$stamp.sh bash /tmp/rr-$stamp.sh --self-test-slots 2>&1 | grep -E '^self-test-slots'; echo slots=\${PIPESTATUS[0]} >> /tmp/locks-$stamp.rc) & wait; cat /tmp/locks-$stamp.rc; rm -f /tmp/lease-$stamp.sh /tmp/rr-$stamp.sh /tmp/locks-$stamp.rc" | tee /tmp/box-locks-$$.out || rc=1
grep -q '^lease=0$' /tmp/box-locks-$$.out && grep -q '^slots=0$' /tmp/box-locks-$$.out || rc=1
rm -f /tmp/box-locks-$$.out
# one at a time (two fixtures at once on a box at load 120 ran seconds late), each retried once on a failure (a transient ssh or
# a momentary stall; the fixtures use private lock directories, never the live _locks, so the box's state is not in the result)
try_twice() { # <label> <remote command>
local label="$1" cmd="$2" out i
for i in 1 2; do
if out=$(bs_ssh "$cmd" 2>&1); then echo "$out" | grep -E "^(lease self-test|self-test-slots)" | tail -1; return 0; fi
echo "box-locks: $label failed on try $i: $(echo "$out" | grep -E 'FAIL|rror' | tail -2 | tr '\n' ' ')" >&2; sleep 3
done
return 1
}
try_twice lease "bash /tmp/lease-$stamp.sh --self-test 2>&1" || rc=1
try_twice slots "IGNEUM_REMOTE_RUN_UNDER_TEST=/tmp/rr-$stamp.sh bash /tmp/rr-$stamp.sh --self-test-slots 2>&1" || rc=1
bs_ssh "rm -f /tmp/lease-$stamp.sh /tmp/rr-$stamp.sh" >/dev/null 2>&1 || true
[ "$rc" = 0 ] && echo "box-locks: the lease tool and the slot runner pass their self-tests on $BS_HOST"
exit $rc

331
tools/era-vdf/reroll.mjs Executable file
View file

@ -0,0 +1,331 @@
#!/usr/bin/env node
// Era VDF lane (7 October 2026), the F7 re-roll harness against the REAL era cut with the era VDF on and off
// (docs/plans/cryptanalysis.md 4.2 row F7; the attack lane's tools/attack/f7-era/reroll.mjs attacked the epoch cut
// because the era cut was a chain constant; the node now takes `pow_era_blocks` and `pow_era_lead` from the override
// file, so a fast-time network crosses an era every two minutes and the cut rule under test is the era's own).
//
// The network: three nodes on infra/fast-time/override-60x.json with skip_proof_of_work, era 120 DAA and lead 20
// (cuts at S = 120 n - 20, one per two minutes), own ports 30100 and up, own devnet suffix 1010, data under
// /tmp/igneum-fast-time-era-vdf. Two honest virtual miners share 1 block/s on nodes 0 and 1; the adversary on node 2
// holds a block A built on the tip at DAA score S - 1 (the cut block sits there) and tries to make its own block the
// era's cut block, steering the era seed to a value it knows.
//
// --vdf off the stand-in: E_n is the cut block's hash, so the draw of a candidate block is known the instant the
// block is built; the adversary publishes A at once. Known-pass: re-rolls fire (seed == hash(A)).
// --vdf on the era VDF (spec 04 section 4.4) at a fast T: E_n is the VDF output over the cut block's day, so the
// adversary must run the delay over its candidate input before it knows the draw of any choice; it runs
// the node's own evaluator (igneum-miner vdf eval, the same T as the network's) and publishes A when it
// ends. Known-fail: no re-roll (A arrives after the honest block interval, the input it evaluated is not
// the chain's, the seed it precomputed is not the chain's seed).
//
// A re-roll with the VDF off: the era's reported seed is hash(A). A re-roll with the VDF on: the seed the adversary
// precomputed for its candidate is the era's reported seed (the known-draw re-roll), or A became the cut block (a
// steer, which without the known draw is a coin flip the VDF makes blind). The gate is 0 known-draw re-rolls.
//
// node reroll.mjs --vdf on|off [--scheme 0|1] [--t N | --delay-secs 5] [--cuts 6] [--era-blocks 120] [--era-lead 20]
// [--secs 1500] [--genesis-bits 0x1d100000] [--tag name]
// IGNEUMD and IGNEUM_MINER name the binaries (default: this lane's release build on igneum-build-1).
// IGNEUM_EV_TMP and IGNEUM_EV_OUT name the data dir and the log dir.
import { spawn, spawnSync } from 'node:child_process';
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync } from 'node:fs';
import { connectRpc } from '../finality-attacks/lib/rpc.mjs';
import { Miner, voteKeyHashFor } from '../harness/lib/miner.mjs';
import { submitReport } from '../harness/lib/rpc.mjs';
import { devAddress } from '../harness/lib/address.mjs';
const ROOT = new URL('../../', import.meta.url).pathname;
const FILE = process.env.IGNEUM_OVERRIDE_60X || `${ROOT}infra/fast-time/override-60x.json`;
const BIN = '/srv/builds/igneum-wt-era-vdf/vendor/igneum-node-ev/target/release';
const IGNEUMD = process.env.IGNEUMD || `${BIN}/igneumd`;
const MINER = process.env.IGNEUM_MINER || `${BIN}/igneum-miner`;
const TMP = process.env.IGNEUM_EV_TMP || '/tmp/igneum-fast-time-era-vdf';
const OUT = process.env.IGNEUM_EV_OUT || TMP;
const BASE = 30100, SUFFIX = 1010;
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, dflt) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : dflt; };
const VDF = sflag('vdf', null);
const SCHEME = flag('scheme', 0);
const DELAY_SECS = flag('delay-secs', 5);
let T = flag('t', 0);
const CUTS = flag('cuts', 6);
const ERA = flag('era-blocks', 120);
const LEAD = flag('era-lead', 20);
const SECS = flag('secs', 1500);
const GENESIS_BITS = flag('genesis-bits', 0x1d100000);
const TAG = sflag('tag', `vdf-${VDF}-s${SCHEME}`);
if (!['on', 'off'].includes(VDF)) { console.error('usage: --vdf on|off [--scheme 0|1] [--t N | --delay-secs S] [--cuts N] [--era-blocks N] [--era-lead N]'); process.exit(2); }
for (const b of [IGNEUMD, MINER]) if (!existsSync(b)) { console.error(`missing ${b}`); 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));
// ---- the era draw in JS (spec 01 sections 1.3.1, 1.3.2, 1.13.1), checked against the Rust census (attack-f7) ----
const M64 = (1n << 64n) - 1n;
function fnvSalt0(bytes) {
let h = 0xcbf29ce484222325n;
for (const x of bytes) { h = ((h ^ BigInt(x)) * 0x100000001b3n) & M64; }
h ^= h >> 33n; h = (h * 0xff51afd7ed558ccdn) & M64; h ^= h >> 33n;
return h;
}
class SplitMix { constructor(s) { this.s = s & M64; } next() { this.s = (this.s + 0x9E3779B97F4A7C15n) & M64; let z = this.s; z = ((z ^ (z >> 30n)) * 0xBF58476D1CE4E5B9n) & M64; z = ((z ^ (z >> 27n)) * 0x94D049BB133111EBn) & M64; return z ^ (z >> 31n); } below(n) { return this.next() % BigInt(n); } }
function eraDraw(eraBytes) {
const seed = fnvSalt0([...Buffer.from('igneum-era/', 'utf8'), ...eraBytes]);
const s = new SplitMix(seed);
s.below(1);
const M = Number((s.next() & 0xffffffffn) | 1n) >>> 0;
const R = 1 + Number(s.below(31));
const r = [s.next(), s.next(), s.next(), s.next()];
const c = []; for (let i = 0; i < 16; i++) c.push(i);
for (let i = 0; i < 4; i++) { const n = c.length - i; const j = i + Number(r[i] % BigInt(n)); [c[i], c[j]] = [c[j], c[i]]; }
return { M, R, pos: c.slice(0, 4).sort((a, b) => a - b) };
}
function selfCheck() {
const e = eraDraw(Buffer.from('b62532bc9bb83b386be48f9df264bad3f8afb70fbb0db775b18f687b2da7f8e2', 'hex'));
const ok = e.M === 0x558c0543 && e.R === 4 && e.pos.join() === '0,1,2,3';
log(`draw self-check against the Rust census: M ${e.M.toString(16)} R ${e.R} pos [${e.pos}] -> ${ok ? 'OK' : 'MISMATCH'}`);
if (!ok) process.exit(3);
}
const drawOf = (hex) => { const d = eraDraw(Buffer.from(hex, 'hex')); return `M ${d.M.toString(16)} R ${d.R} pos [${d.pos}]`; };
// ---- the evaluator (the node's own code through igneum-miner vdf) ----
function bench(secs) {
const r = spawnSync(MINER, ['vdf', 'bench', '--secs', String(secs), '--scheme', String(SCHEME)], { encoding: 'utf8' });
if (r.status !== 0) { log(`bench failed: ${r.stderr}`); process.exit(3); }
const m = /= ([0-9]+) (squarings|hashes)\/s/.exec(r.stdout);
log(`bench: ${r.stdout.trim().split('\n').join(' | ')}`);
return m ? +m[1] : 0;
}
// asynchronous, so the honest virtual miners in this process keep mining while the adversary computes (a
// synchronous spawn froze the chain for the length of the evaluation, which is not the attack)
function adversaryEval(inputHex) {
const t0 = Date.now();
return new Promise((resolve) => {
const r = spawn(MINER, ['vdf', 'eval', '--input', inputHex, '--t', String(T), '--scheme', String(SCHEME), '--threads', '2'], { stdio: ['ignore', 'pipe', 'pipe'] });
let out = '', err = '';
r.stdout.on('data', (d) => { out += d; });
r.stderr.on('data', (d) => { err += d; });
r.on('close', (status) => {
const secs = (Date.now() - t0) / 1000;
if (status !== 0) return resolve({ secs, seed: null, error: err.slice(0, 200) });
try { const j = JSON.parse(out.trim().split('\n').pop()); resolve({ secs, seed: j.seed, proof_bytes: j.proof.length / 2 }); } catch (e) { resolve({ secs, seed: null, error: String(e) }); }
});
});
}
// ---- network ----
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true }); mkdirSync(OUT, { recursive: true });
const baseText = readFileSync(FILE, 'utf8');
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`);
}
selfCheck();
if (VDF === 'on' && T === 0) {
const rate = bench(3);
T = Math.max(1, Math.floor(rate * DELAY_SECS));
log(`T ${T} for a ${DELAY_SECS} s delay at ${rate}/s on one core of this box (scheme ${SCHEME})`);
}
const fields = { genesis_bits: GENESIS_BITS, skip_proof_of_work: true, pow_era_blocks: ERA, pow_era_lead: LEAD };
if (VDF === 'on') Object.assign(fields, { era_vdf_activation_daa: 0, vdf_scheme: SCHEME, era_vdf_t: T });
else Object.assign(fields, { era_vdf_activation_daa: '18446744073709551615' });
const override = `${TMP}/override.json`;
writeFileSync(override, mergeOverrideText(baseText, fields));
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.connect = connect; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`;
}
get json() { return `ws://127.0.0.1:${this.jsonPort}`; }
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}`,
`--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(`${OUT}/pids-${TAG}.txt`, started.map(p => p.pid).join('\n') + '\n');
await sleep(1200);
this.rpc = await connectRpc(this.json);
log(`n${this.i} up pid ${this.proc.pid} json ${this.jsonPort} p2p ${this.p2pPort}`);
return this;
}
grepLog(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } }
}
async function stopAll() {
for (const m of miners) { try { m.stop(); } catch { } }
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); });
const t0 = Date.now();
const n0 = await new Node(0).start();
const n1 = await new Node(1, [`127.0.0.1:${n0.p2pPort}`]).start();
const n2 = await new Node(2, [`127.0.0.1:${n0.p2pPort}`]).start();
const nodes = [n0, n1, n2];
log(`n0: ${n0.grepLog(/Era VDF/).map(l => l.replace(/^.*?Era VDF/, 'Era VDF')).join(' | ') || '(no era VDF line)'}; cuts at S = ${ERA} n - ${LEAD}`);
const miners = [];
for (const [n, label] of [[n0, 'honest-a'], [n1, 'honest-b']]) {
const m = new Miner({ node: n, share: 0.5, bps: 1, label });
await m.start(); miners.push(m);
}
const advRpc = n2.rpc;
const advAddr = devAddress('era-vdf-adversary');
const advKey = voteKeyHashFor('era-vdf-adversary');
async function dagInfo(n) { return n.rpc.call('getBlockDagInfo'); }
async function getBlock(n, hash) { const r = await n.rpc.call('getBlock', { hash, includeTransactions: false }); return r.block || r; }
const hdr = (b) => b.header || {};
const vd = (b) => b.verboseData || b.verbose_data || {};
const daaOf = (b) => +(hdr(b).daaScore ?? hdr(b).daa_score);
const spOf = (b) => vd(b).selectedParentHash ?? vd(b).selected_parent_hash;
const hashOf = (b) => vd(b).hash;
async function powEpoch(n) { try { const t = await n.rpc.call('getBlockTemplate', { payAddress: advAddr, extraData: [] }); return t.powEpoch || t.pow_epoch || {}; } catch { return {}; } }
async function virtualDaa(n) { return +((await powEpoch(n)).virtualDaaScore || 0); }
// the node's seed_below: from the sink down the selected parents to the first block with DAA score under `score`
async function chainBlockBelow(n, score) {
const d = await dagInfo(n);
let cur = d.sink;
for (let k = 0; k < 4096; k++) {
const b = await getBlock(n, cur);
if (daaOf(b) < score) return b;
const sp = spOf(b);
if (!sp || sp === cur) return b;
cur = sp;
}
return null;
}
// A by its nonce: from the submitting node first, reading from A's own selected parent (getBlocks from a block far
// below the tip answers a short window that misses A), then from n0 the same way, then from the settled anchor
async function findAdversaryHash(tries, nonce) {
// the node logs every proof-of-work check with the block's hash, DAA score and nonce: the one line that names A
// whatever its place in the DAG (a side block getBlocks never lists)
const re = new RegExp('PoW rejected ([0-9a-f]{64}) by [a-z0-9-]+ \\(daa ([0-9]+), nonce 0x' + nonce.toString(16) + '\\)');
for (const n of [n0, n2, n1]) {
for (const line of n.grepLog(re)) { const m = re.exec(line); if (m) return { hash: m[1], daa: +m[2] }; }
}
for (const [n, lowHash] of tries) {
if (!lowHash) continue;
try {
const r = await n.rpc.call('getBlocks', { lowHash, includeBlocks: true, includeTransactions: false });
for (const b of (r.blocks || [])) if (String(hdr(b).nonce) === String(nonce)) return { hash: hashOf(b), daa: daaOf(b) };
} catch { }
}
return { hash: null, daa: null };
}
// the era's reported seed for era e, once the node has it (the VDF's record, or the stand-in at once)
async function reportedEraSeed(n, e, waitMs) {
const t1 = Date.now();
let states = [];
while (Date.now() - t1 < waitMs) {
const pe = await powEpoch(n);
if (+pe.eraIndex === e) {
states.push(+pe.eraVdfState);
if (pe.eraSeed) return { seed: String(pe.eraSeed), input: pe.eraVdfInput ? String(pe.eraVdfInput) : null, state: +pe.eraVdfState, states, waited: (Date.now() - t1) / 1000 };
}
await sleep(250);
}
return { seed: null, input: null, state: null, states, waited: (Date.now() - t1) / 1000 };
}
async function attackCut(e) {
const score = e * ERA - LEAD;
const target = score - 1;
let tmpl = null;
for (let k = 0; k < 2400; k++) {
try {
tmpl = await advRpc.call('getBlockTemplate', { payAddress: advAddr, extraData: [] });
const s = +(tmpl.powEpoch || tmpl.pow_epoch || {}).virtualDaaScore;
if (s >= target) break;
} catch { }
await sleep(100);
}
const A = tmpl.block;
A.header.voteKeyHash = advKey;
const nonce = 0xE7A0000000 + e;
A.header.nonce = nonce;
// the anchor for finding A afterwards: a settled chain block well below the cut (the sink of this moment may be
// reorged off the chain by A or by an honest sibling, and getBlocks from a non-chain block answers nothing)
const anchorBlock = await chainBlockBelow(n0, target - 30);
const anchor = anchorBlock ? hashOf(anchorBlock) : (await dagInfo(n0)).sink;
const tHold = Date.now();
const daaAtHold = await virtualDaa(n0);
// the adversary's candidate input: with the VDF off the draw of hash(A) is known at once (the stand-in); with it on
// the adversary runs the delay over the candidate it can name (A's selected parent: the real input is the hash over
// the cut block's day, fixed only when the cut settles, and no candidate's draw is known before T steps)
const candidate = String(A.header.parents?.[0]?.[0] || anchor);
let adv = { secs: 0, seed: null };
if (VDF === 'on') adv = await adversaryEval(candidate);
const daaAfterEval = await virtualDaa(n0);
let submit;
try { submit = submitReport(await advRpc.call('submitBlock', { block: A, allowNonDaaBlocks: false })); }
catch (e2) { submit = `error:${e2.message}`; }
const tPublish = (Date.now() - tHold) / 1000;
const aParent = A.header.parents?.[0]?.[0] || null;
await sleep(1500);
const a = await findAdversaryHash([[n2, aParent], [n0, aParent], [n0, anchor]], nonce);
// wait for the era to start, then for the node's seed of the era
for (let k = 0; k < 400; k++) { if (await virtualDaa(n0) >= e * ERA + 2) break; await sleep(250); }
const cutBlock = await chainBlockBelow(n0, score);
const cutHash = cutBlock ? hashOf(cutBlock) : null;
const rep = await reportedEraSeed(n0, e, 120_000);
const rep1 = await reportedEraSeed(n1, e, 20_000);
const rep2 = await reportedEraSeed(n2, e, 20_000);
const steer = !!(cutHash && a.hash && cutHash === a.hash);
const knownDraw = VDF === 'off' ? !!(rep.seed && a.hash && rep.seed === a.hash) : !!(rep.seed && adv.seed && rep.seed === adv.seed);
return {
era: e, cut_score: score, cut_block: cutHash, adversary_block: a.hash, adversary_block_daa: a.daa, submit,
hold_daa: daaAtHold, publish_daa: daaAfterEval, blocks_during_eval: daaAfterEval - daaAtHold, adversary_eval_secs: adv.secs, publish_after_secs: tPublish,
adversary_candidate: candidate, adversary_precomputed_seed: adv.seed,
era_seed: rep.seed, era_input: rep.input, era_vdf_state: rep.state, states_seen: [...new Set(rep.states)], seed_wait_secs: rep.waited,
nodes_agree: !!(rep.seed && rep.seed === rep1.seed && rep.seed === rep2.seed),
draw: rep.seed ? drawOf(rep.seed) : null,
steer_to_adversary_block: steer, reroll_known_draw: knownDraw,
};
}
let startDaa = 0;
for (let k = 0; k < 60; k++) { startDaa = await virtualDaa(n0); if (startDaa > 0) break; await sleep(500); }
const firstE = Math.floor(startDaa / ERA) + 2;
log(`start virtual daa ${startDaa}; attacking cuts for eras ${firstE}..${firstE + CUTS - 1} (S = ${firstE * ERA - LEAD} and up); vdf ${VDF}${VDF === 'on' ? ` scheme ${SCHEME} T ${T}` : ''}`);
const records = [];
for (let e = firstE; e < firstE + CUTS && Date.now() - t0 < SECS * 1000; e++) {
try {
const r = await attackCut(e);
records.push(r);
log(`cut era ${e} (S ${r.cut_score}): cut ${String(r.cut_block).slice(0, 12)} A ${String(r.adversary_block).slice(0, 12)} submit ${r.submit} eval ${r.adversary_eval_secs.toFixed(2)} s (${r.blocks_during_eval} blocks) seed ${String(r.era_seed).slice(0, 12)} state ${r.era_vdf_state} after ${r.seed_wait_secs.toFixed(1)} s ${r.nodes_agree ? 'agree' : 'DISAGREE'} ${r.reroll_known_draw ? 'RE-ROLLED (known draw)' : (r.steer_to_adversary_block ? 'steered blind' : 'held')} ${r.draw || ''}`);
} catch (e2) { log(`cut era ${e}: ${e2.message}`); }
}
await sleep(2000);
const sinks = (await Promise.all(nodes.map(n => dagInfo(n).catch(() => ({}))))).map(d => String(d.sink || '?').slice(0, 16));
const accepted = records.filter(r => r.submit === 'accepted');
const rerolls = records.filter(r => r.reroll_known_draw);
const steers = records.filter(r => r.steer_to_adversary_block);
const agree = records.every(r => r.nodes_agree);
const gatePass = rerolls.length === 0;
const expectReroll = VDF === 'off';
const harnessSound = expectReroll ? rerolls.length > 0 : rerolls.length === 0;
const evals = records.map(r => r.adversary_eval_secs);
const summary = {
tag: TAG, vdf: VDF, scheme: SCHEME, t: VDF === 'on' ? T : null, cuts_attempted: records.length, era_blocks: ERA, era_lead: LEAD, genesis_bits: GENESIS_BITS,
adversary_blocks_accepted: accepted.length, rerolls_known_draw: rerolls.length, steers_blind: steers.length,
adversary_eval_secs_min: evals.length ? Math.min(...evals) : 0, adversary_eval_secs_max: evals.length ? Math.max(...evals) : 0,
block_interval_secs: 1, all_nodes_agree: agree, gate_no_known_draw_reroll: gatePass, expect_reroll: expectReroll, harness_sound: harnessSound,
sinks, sinks_agree: new Set(sinks).size === 1, node: IGNEUMD, miner: MINER, records,
};
writeFileSync(`${OUT}/reroll-${TAG}.json`, JSON.stringify(summary, null, 2));
log(`SUMMARY ${TAG}: ${records.length} cuts, ${accepted.length} adversary blocks accepted, ${rerolls.length} known-draw re-rolls, ${steers.length} blind steers; adversary eval ${summary.adversary_eval_secs_min.toFixed(2)} to ${summary.adversary_eval_secs_max.toFixed(2)} s against a 1 s block interval; nodes ${agree ? 'agree on every era seed' : 'DISAGREE'}; gate(no known-draw re-roll) ${gatePass ? 'PASS' : 'FAIL'}; expect re-roll ${expectReroll} -> harness ${harnessSound ? 'SOUND' : 'UNSOUND'}; sinks ${sinks.join(' ')} (${summary.sinks_agree ? 'agree' : 'DIFFER'})`);
log(`summary: ${OUT}/reroll-${TAG}.json`);
await stopAll();
process.exit(harnessSound ? 0 : 1);