attack-pass F7: census and day-key seeding PASS at 2^24; re-roll harness INCOMPLETE pending the era VDF, named as a freeze precondition

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-07 08:55:30 +00:00
parent 5b023ca6c0
commit 91a169662f
5 changed files with 1013 additions and 0 deletions

View file

@ -0,0 +1,165 @@
# F7: the era-draw bias harness and the era-seed census
Attack-pass row F7 (`docs/plans/cryptanalysis.md` 4.2; the record `docs/analysis/attack-pass-2026-10.md`), both halves:
the fast-time re-roll harness (node lane) and the 2^20 era-seed census plus the 64-bit day-key check (hash lane).
Written 7 October 2026. Every number cites its log path on igneum-build-1.
## Target
| Item | Value |
|---|---|
| Commit | `924288d1` on branch `attack-pass` (`igneum-pow` is byte-identical at worktree HEAD `11b375a0`: `git diff --stat 924288d1..HEAD -- igneum-pow docs/spec infra/fast-time` is empty) |
| Spec | `docs/spec/01-lottery-hash.md` 1.13.1 (era seed and draw), 1.8.4 (the day-key mixer stream); `docs/spec/04-seeds-and-vdf.md` 4.4 (era seed pipeline) and 4.6 (T from a reference core); `docs/plans/era-layout.md` sections 1 and 8 (branch `ca2-era`); `docs/analysis/horizon/algorithm.md` 5.4 |
| Draw code | `igneum_pow::generator::era_draw` over `V3_ALLOWED = [1]` (the chain's path): the width draw (consumed, pinned at 4 bytes), the odd stride multiplier `M`, the rotation `R` in 1..31, the four interleave positions `pos` by partial Fisher-Yates |
| Day-key code | `igneum_pow::memhard::MixParams::with_shape`: `SplitMix64::new(K[0] \| (K[1] << 32))` draws ROT[0..7], MUL[0..15], RC[0..15]; `K = seed_words_from_bytes("igneum-day/" \|\| day_le64)` (node fork `consensus/pow/src/igneum.rs`, `bind::day_bytes`) |
| Node draw input (today) | `consensus/src/consensus/mod.rs` `seed_below`: `E_n` is the hash of the last selected-chain block below `15,552,000 n - 7,200` (era 0: genesis). The 1-hour VDF of spec 4.4 and the certified checkpoint it reads do NOT exist in the node (era-layout.md section 8, `proto-vdf` is a prototype) |
## Sub-row verdicts
| Sub-row | Verdict | Gate (plan 4.2 F7) |
|---|---|---|
| (a) re-roll harness | INCOMPLETE, with the written argument | no re-roll inside the publish window |
| (b) 2^20 era-seed census | PASS | no era class with gain over 1.1x at a fraction over 2^-20 |
| (c) 64-bit day-key seeding | PASS, within spec intent (one observation recorded) | the draw's input set as the spec states it |
## (a) The re-roll harness (node lane)
`tools/attack/f7-era/reroll.mjs`: a 3-node fast-time network (`infra/fast-time/override-60x.json` with
`skip_proof_of_work`, the `class-v4-signal.mjs` shape), own ports 29800 and up, own devnet suffix 980, own data dir
`/tmp/igneum-fast-time-attack-f7`. The node binary is the ladder fork `vendor/igneum-node-ladder` at `1591ee1d`
(`igneumd 2.1.0`, already built on the box; read-only). 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 seed block sits there), optionally waits
a stub VDF of `--vdf-ms`, then publishes `A` to try to make its own block the epoch's seed block (the last selected-chain
block below the cut `S`). A re-roll succeeds when the epoch's reported seed becomes `hash(A)`.
The era cut `15,552,000 n - 7,200` is 180 days of DAA score away on every profile (`POW_ERA_BLOCKS` is a chain constant,
not an override field), so the harness attacks the EPOCH cut (`60 e - 10` at 60x), which runs the identical `seed_below`
derivation at a reachable score, one cut per minute. The harness's own era draw (JS) is checked byte-for-byte against the
Rust census at start: seed `b62532bc...` draws `M 558c0543 R 4 pos [0,1,2,3]` on both (log line "draw self-check ... OK").
Firings (both runs 6 cuts, box cores 36-37,84-85 under the shared measure lock):
| Run | `--vdf-ms` | Re-rolls to A | Gate | Harness | Log |
|---|---|---|---|---|---|
| known-pass | 0 (no delay, the stand-in) | 1 of 6 (epoch 11, seed = A) | FAIL | SOUND (fires) | `/srv/builds/igneum-wt-attack/attack-f7/reroll-knownpass.log` |
| known-fail | 5,000 (a delay past one block interval) | 0 of 6 | PASS | SOUND (silent) | `/srv/builds/igneum-wt-attack/attack-f7/reroll-knownfail.log` |
Both runs: 6 of 6 adversary blocks accepted, all three sinks agree, no reorg of the honest chain. The harness fires on the
known-pass and is silent on the known-fail, so it is trusted.
Written argument (the plan allows one for the VDF's assumptions; the VDF's own delay soundness belongs to the finality
review row of `funding.md`). The re-roll is possible ONLY when the adversary can evaluate the draw of a candidate input
inside the block publish window. Today the node has no VDF: `E_n` is a plain block hash, so the input of any candidate
block is known the instant the block is built, and the harness shows the last-block-before-the-cut is grindable with one
block of hash (1 of 6 cuts steered in fast time, `--vdf-ms 0`). With any delay past one honest block interval the
re-roll is gone (`--vdf-ms 5000`: 0 of 6). The design closes this with the 1-hour class-group VDF of spec 4.4: re-rolling
by withholding needs the 3,600 s VDF evaluated inside the 2 s window, a 1,800x evaluator, and spec 4.6's margin table
gives 300x as the horizon (`algorithm.md` 5.4; `sim/horizon/algorithm/model.py --section era`). The forge route needs
2/3 of the 30-day weight, 20 days of 100 percent hash (CLAUDE.md headline). The sub-row is INCOMPLETE because the harness
cannot demonstrate the real gate: the VDF and the certified checkpoint it reads are not in the node yet (era-layout.md
section 8 states this). What the harness DOES establish: the C_era cut rule with no delay is grindable, so the era draw's
soundness rests entirely on the VDF landing before the draw procedure is frozen, and the delay-soundness measurement is
owed to the finality lane.
## (b) The 2^20 era-seed census (hash lane)
`tools/attack/f7-era/` (a cargo crate with `igneum-pow` as a path dependency and an empty `[workspace]`; ELF built on the
box, sha256 `a87818d8...`). `attack-f7 census` runs `era_draw` over `V3_ALLOWED` on `2^n` seeds and classifies each draw;
`attack-f7 all` runs the plant known-fail case, the census, the spec-stream op-weight census and the day-key check.
Known-fail / known-pass of the classifier (planted parameters through a test hook in this crate; log
`/srv/builds/igneum-wt-attack/attack-f7/census-2p20.log`): every planted weak draw fires its flag (M = 1, M = 2^32-1,
M = 2^16+1, a naf-2 multiplier, an even M, R = 0, R = 32, pos linear, pos contiguous, pos not ascending) and a sound draw
(igneum-era-test/0) raises nothing. "Plant verdict: every planted case fired and the sound draw did not."
Census results (2^24 = 16,777,216 draws, the stronger run; `census-2p24.log`; the 2^20 run agrees, `census-2p20.log`):
| Class | Count (2^24) | Fraction | Expected (uniform) | Chip gain |
|---|---|---|---|---|
| M even (bijection failure) | 0 | 0 | 0 | finding if present: none |
| R out of 1..31 | 0 | 0 | 0 | finding if present: none |
| pos invalid (not 4 ascending) | 0 | 0 | 0 | finding if present: none |
| M = 1 (identity stride) | 0 | 0 | 4.66e-10 | 1.0034x |
| M = 2^32 - 1 | 0 | 0 | 4.66e-10 | 1.0030x |
| popcount(M) <= 2 | 1 | 5.96e-8 (2^-24) | 1.49e-8 | 1.0030x |
| popcount(M) <= 4 | 43 | 2.56e-6 (2^-18.6) | 2.33e-6 | 1.0022x |
| popcount(M) <= 6 | 1,626 | 9.69e-5 | 9.61e-5 | 1.0014x |
| popcount(M) <= 8 | 27,749 | 1.65e-3 | 1.66e-3 | 1.0007x |
| naf(M) <= 2 | 1 | 5.96e-8 | - | 1.0030x |
| naf(M) <= 3 | 18 | 1.07e-6 | - | 1.0026x |
| M = 2^k + 1 | 1 | 5.96e-8 | 1.44e-8 | 1.0030x |
| pos linear [0,1,2,3] | 9,257 | 5.52e-4 | 5.50e-4 | 1.0000x |
| pos contiguous | 120,054 | 7.16e-3 | 7.14e-3 | 1.0000x |
| pos in the low byte | 645,856 | 3.85e-2 | 3.85e-2 | 1.0000x |
The gain metric is the datapath energy a chip saves per hash against the base weights, over the hash's datapath energy
(19.5 nJ at 100,000 ops x 0.195 pJ, the N5 floor of `algorithm.md` 5.4 / `model.py --section era`). The stride multiply is
one of three address operations, run 128 times per hash (16 loads x 8 iterations); a low-weight `M` replaces the multiplier
with a few shift-adds, worth at most 128 x 0.52 pJ = 67 pJ, so M = 1 is the richest corner at 1.0034x. The rotation is a
wire mux and the interleave an address-line permute, 0 pJ on the modelled chip. No drawn parameter touches the memory
bound, the item derivation, the load count or N.
Gate: no class with gain over 1.1x at a fraction over 2^-20. The richest gain in the whole classifier is 1.0034x (M = 1),
and M = 1 did not occur in 2^24 draws (expected 4.66e-10). Every class at a fraction over 2^-20 has gain 1.0000x to
1.0007x. PASS on both counts.
Uniformity of the draw (2^24): stride rotation R over 1..31 chi-square 38.5 on 30 dof (max bucket deviation 2.07 sigma,
R = 0 or 32 seen 0 times); interleave pos 1,820 of 1,820 four-subsets seen, chi-square 1,775.7 on 1,819 dof (max deviation
3.63 sigma, 0 draws with a non-4-subset); M bit 0 always set (odd by construction), bits 1..31 each set in 0.500 of draws
(worst bit 1.81 sigma); the stride bijection never failed (0 even M). The era stream's own 64-bit seed (words 0 and 1) was
distinct on all 2^24 draws.
Op-weight corners (spec 1.13.1 first stream, implemented in `attack-f7 spec` from the spec text because `igneum-pow` does
not draw the op-weight perturbation at this commit; 2^20 draws, `census-2p20.log`): the ten non-load weights each
perturbed by -2..+2 and renormalised to 75 move the multiply share (mul+mad+mulhi, base 22 of 75) between 15 and 31. The
richest corner for a chip is 15/75 (0.152 pJ per op, -22 percent of the base datapath), seen once in 2^20; 16/75 at
3.22e-3. The GPU's energy moves the same way (its IMAD is the chain's own op), so the chip-against-GPU gain of every
weight corner is 1.0x, with 0 memory effect. Renormalised sums were 75 on every draw (0 failures). Fold rotations: a triple
all equal 2.13e-3, both triples all equal 1.91e-6, all six equal 0; uniform over 1..31, rotation 0 never drawn; a wire
mux, 1.0x.
## (c) The 64-bit seeding of the day-key stream (hash lane)
`attack-f7 days` over days 0..131,072 (`census-2p20.log`). The day key `K` is `seed_words_from_bytes("igneum-day/" ||
day_le64)`: a calendar function, no chain state. All 256 bits of `K` enter the cache fill (spec 1.8.3, `K[0..7]` in every
block input), so the dataset depends on the full key; the mixer-constant stream (ROT, MUL, RC) is seeded from `K[0] |
(K[1] << 32)`, 64 bits, which is the spec's stated intent (spec 1.8.4).
| Quantity | Value |
|---|---|
| Days the chain can have | about 65,745 in 180 years at 1 block/s (2^16.0) |
| Distinct 256-bit keys K over 2^17 days | 131,072 (all) |
| Distinct 64-bit stream seeds over 2^17 days | 131,072 (0 duplicates) |
| Distinct (ROT, MUL, RC) tuples over 2^17 days | 131,072 |
| Birthday bound on a 64-bit collision among 2^16 days | 2^(32 - 65) = 2^-33 |
The spec intends 64 bits for the mixer-constant draw, and the truncation is not a reduction of the draw space the firm
would flag: at most 2^16 days are ever drawn, each a distinct calendar day with a distinct 64-bit seed (0 collisions in
2^17), so no two days share a mixer. One observation, within spec intent and recorded for the written argument of
`funding.md` B5 rank 6: the mixer-constant stream has 64 bits of seed entropy, so at most 2^64 distinct daily mixers are
reachable (not the ~2^1,047 nominal); this is not exploitable (the days used are 2^16, all distinct) and whether any
reachable tuple is weak is the separate weak-day census of row F4.
## Consequences per tier
The era draw and the day-key seeding are protocol-wide and do not differ by card tier: the load width and load count are
pinned, so every era is equally memory-bound and no 8, 12, 16 or 24/32 GB card is advantaged or disadvantaged by any draw
(the measured six-era hash-rate spread is 1.3 percent on the RTX 5090, 3.2 on the RX 9070 XT, 0.8 on the M5 Max,
`algorithm.md` 5.4). No drawn era parameter or day key makes a chip cheaper against a GPU: the richest datapath corner is
1.0034x and is shared with the GPU. The one operational consequence is for the protocol, not a miner tier: the era draw's
grinding resistance is not yet demonstrable because the 1-hour VDF and its certified checkpoint are not in the node, so
the freeze of the draw procedure and the C_era cut rule must wait on the VDF landing and the finality lane's delay-
soundness measurement.
## Gate line
- (a) harness: INCOMPLETE. No re-roll with a one-block delay (known-fail 0 of 6); a re-roll with no delay (known-pass 1 of
6). The real gate (no re-roll inside the 2 s window) rests on the 1-hour VDF, which is not in the node; written argument
above.
- (b) census: PASS. No era class with gain over 1.1x at any fraction (richest 1.0034x, M = 1, absent in 2^24); the draw is
a bijection on every sample and uniform in R, pos and the M bits.
- (c) 64-bit seeding: PASS within spec intent. The spec intends 64 bits for the mixer stream; 2^16 days are all distinct;
the one observation (2^64 reachable mixers) is recorded, not a flaw.
What a failure moves (plan 4.2 F7): the draw procedure or the C_era cut rule; a redraw rule for the era stream. Nothing in
(b) or (c) moves them. (a) moves nothing in shipped code but gates the freeze of the draw procedure on the VDF.

14
tools/attack/f7-era/Cargo.lock generated Normal file
View file

@ -0,0 +1,14 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "attack-f7"
version = "0.1.0"
dependencies = [
"igneum-pow",
]
[[package]]
name = "igneum-pow"
version = "0.2.0"

View file

@ -0,0 +1,19 @@
# Attack-pass row F7 (docs/plans/cryptanalysis.md 4.2): the era-seed census and the day-key seeding checks.
# Built on igneum-build-1 through tools/build-remote.sh (the Mac cannot build this repo's lock file).
[package]
name = "attack-f7"
version = "0.1.0"
edition = "2021"
publish = false
[[bin]]
name = "attack-f7"
path = "src/main.rs"
[dependencies]
igneum-pow = { path = "../../../igneum-pow" }
[workspace]
[profile.release]
opt-level = 3

View file

@ -0,0 +1,282 @@
#!/usr/bin/env node
// Attack-pass row F7 (docs/plans/cryptanalysis.md 4.2), the node-lane half: the era-draw re-roll harness on the fast-time
// 3-node network (infra/fast-time/override-60x.json with skip_proof_of_work, the class-v4-signal.mjs shape).
//
// What the node does today for the era draw input (0.3.17/0.3.18 line, consensus/src/consensus/mod.rs `seed_below`):
// era seed E_n = the hash of the last selected-chain block whose DAA score is below 15,552,000 n - 7,200 (era 0: genesis)
// epoch seed = the hash of the last selected-chain block whose DAA score is below 60 e - 10 on the 60x file
// The same function, two cut scores; no VDF and no certified checkpoint exist in the node yet (era-layout.md section 8).
// The era cut is 180 days of DAA score away on every profile (POW_ERA_BLOCKS is a constant, not an override field), so
// this harness attacks the epoch cut, which is the identical derivation at a reachable score, one cut per minute.
//
// The adversary (a miner with one block of hash at the right second): when the template's DAA score is S - 1 it takes a
// template and HOLDS the block A. When the honest block H at S - 1 lands (the sink passes the cut), it evaluates the draw
// of seed(H) after a stub VDF of --vdf-ms (0 = the stand-in, no delay; the real design needs the 3,600 s class-group VDF
// before the draw of a candidate input is known) and then either withholds A (policy pref: seed(H) is to its liking) or
// publishes A to re-roll the seed. A re-roll SUCCEEDS when the chain's reported seed for the epoch is hash(A): A beat H
// on the GHOSTDAG tie (equal blue work, the higher hash wins, consensus/src/processes/ghostdag/ordering.rs) before H had a
// child, i.e. inside the honest block interval, the 1 to 2 s publish window.
//
// Known-pass (the harness fires): --vdf-ms 0 --policy always: re-rolls succeed in about half the cuts (the tie).
// Known-fail (the honest case): --vdf-ms 5000 --policy always: every A arrives after H has children; 0 successes.
//
// node reroll.mjs --vdf-ms <n> [--policy always|pref] [--cuts 12] [--secs 1200] [--genesis-bits 0x1d100000]
// IGNEUMD names the node binary (default: the ladder fork's release build on igneum-build-1).
// Ports 29800 and up, network igneum-devnet-980, data /tmp/igneum-fast-time-attack-f7 (box scratch of this lane only).
import { spawn } 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 IGNEUMD = process.env.IGNEUMD || '/srv/builds/igneum-wt-ladder/vendor/igneum-node-ladder/target/release/igneumd';
const TMP = process.env.IGNEUM_F7_TMP || '/tmp/igneum-fast-time-attack-f7';
const OUT = process.env.IGNEUM_F7_OUT || TMP;
const BASE = 29800, SUFFIX = 980;
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_MS = flag('vdf-ms', 0);
const POLICY = sflag('policy', 'always');
const CUTS = flag('cuts', 12);
const SECS = flag('secs', 1500);
const GENESIS_BITS = flag('genesis-bits', 0x1d100000);
const TAG = sflag('tag', `vdf${VDF_MS}-${POLICY}`);
if (!['always', 'pref'].includes(POLICY)) { console.error('usage: --vdf-ms <n> [--policy always|pref] [--cuts N]'); process.exit(2); }
if (!existsSync(IGNEUMD)) { console.error(`missing ${IGNEUMD}`); 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 draw in JS (spec 01 sections 1.3.1, 1.3.2, 1.13.1; era-layout.md 1.1), checked against the Rust census at start ----
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; // words[0] | words[1] << 32 of seed_words_from_bytes: the era stream's seed
}
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 pre = [...Buffer.from('igneum-era/', 'utf8'), ...eraBytes];
const seed = fnvSalt0(pre);
const s = new SplitMix(seed);
s.below(1); // the width draw, pinned set {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]]; }
const pos = c.slice(0, 4).sort((a, b) => a - b);
return { seed, M, R, pos };
}
function selfCheck() {
// from census-2p20.log (attack-f7 census on igneum-build-1): seed b62532bc... draws M 558c0543 R 4 pos [0,1,2,3]
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);
}
// the adversary's preference: a balanced predicate on the draw (the low bit of the stride multiplier's bit 1 is as good as any)
const pref = (hashHex) => (eraDraw(Buffer.from(hashHex, 'hex')).M & 2) === 0;
// ---- network ----
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true }); mkdirSync(OUT, { recursive: true });
const baseText = readFileSync(FILE, 'utf8');
const field = (name) => { const m = new RegExp(`"${name}":\\s*([0-9]+)`).exec(baseText); return m ? +m[1] : undefined; };
const EPOCH = field('pow_epoch_blocks');
const LEAD = field('pow_epoch_lead');
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`);
}
const override = `${TMP}/override.json`;
writeFileSync(override, mergeOverrideText(baseText, { genesis_bits: GENESIS_BITS, skip_proof_of_work: true }));
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); });
selfCheck();
const t0 = Date.now();
const since = () => ((Date.now() - t0) / 1000).toFixed(1);
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 PoW schedule: ${n0.grepLog(/PoW schedule/).map(l => l.replace(/^.*?PoW schedule/, 'PoW schedule')).join(' | ') || '(no line)'}; epoch ${EPOCH} DAA, lead ${LEAD}; cuts at S = ${EPOCH} e - ${LEAD}`);
// honest production: two virtual miners sharing 1 block/s (the devnet rate) on n0 and n1
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('attack-f7-adversary');
const advKey = voteKeyHashFor('attack-f7-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 chainBlockBelow(n, score) {
// the node's seed_below: from the sink down the selected parents to the first block with DAA score under `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;
}
// the reported epoch seed the node would use for epoch e: its own template field, cross-checked with the walk
async function reportedEpochSeed(n, e) {
try {
const t = await n.rpc.call('getBlockTemplate', { payAddress: advAddr, extraData: [] });
const pe = t.powEpoch || t.pow_epoch || {};
if (+pe.epochIndex === e && pe.epochSeed) return String(pe.epochSeed);
} catch { }
const score = e * EPOCH - LEAD;
const b = await chainBlockBelow(n, score);
return b ? hashOf(b) : null;
}
// the adversary's block A, found on node n by its unique nonce and DAA score, read for its hash
async function findAdversaryHash(n, lowHash, nonce) {
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 };
}
async function virtualDaa(n) {
try { const t = await n.rpc.call('getBlockTemplate', { payAddress: advAddr, extraData: [] }); return +(t.powEpoch || t.pow_epoch || {}).virtualDaaScore; } catch { return 0; }
}
// Hold one block at the cut and try to make it the epoch's seed block. Returns a record for the cut.
async function attackCut(e) {
const score = e * EPOCH - LEAD; // the node's seed_below cut for epoch e: the last chain block below `score`
const target = score - 1; // the seed block sits at this DAA score
let tmpl = null;
for (let k = 0; k < 600; 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);
}
// A: the adversary's candidate block built on the current tip, held private (nonce unique per cut so A is findable)
const A = tmpl.block;
A.header.voteKeyHash = advKey;
const nonce = 0xA77ac70000 + e;
A.header.nonce = nonce;
// the honest seed block the node sees for this epoch right now (its hash is below `score`, used as the getBlocks anchor)
const before = await reportedEpochSeed(n0, e);
// the stub VDF the design requires before the draw of a candidate input is known (0 = the stand-in, no delay; the real
// design needs the 3,600 s class-group VDF, so a candidate's draw is not known for an hour and the window is 2 s)
if (VDF_MS > 0) await sleep(VDF_MS);
let publish = true;
if (POLICY === 'pref') {
// publish only when A's own hash would give a preferred draw and the honest seed would not (needs A's hash: resolve it
// from a dry build on node 2 first). Kept simple: in 'pref' the adversary still must have A on hand, so publish and judge
// after; the distinguishing run is 'always'.
publish = true;
}
let submit = 'not-published';
if (publish) {
try { submit = submitReport(await advRpc.call('submitBlock', { block: A, allowNonDaaBlocks: false })); }
catch (e2) { submit = `error:${e2.message}`; }
}
const a = await findAdversaryHash(n0, before, nonce);
const aHash = a.hash;
// wait until the chain has advanced a few blocks past the cut, so the "last chain block below score" is stable
for (let k = 0; k < 160; k++) { if (await virtualDaa(n0) >= score + 3) break; await sleep(250); }
const after = await reportedEpochSeed(n0, e);
// a re-roll by the adversary: its own block A is the epoch's seed block (it steered the draw to a value it chose)
const toA = !!(after && aHash && after === aHash);
// the seed also differs from the honest one it first read (context: the cut was not yet settled), not itself an attack
const changed = !!(before && after && after !== before);
return { epoch: e, cut_score: score, honest_seed: before, final_seed: after, adversary_block: aHash, adversary_block_daa: a.daa, submit, published: publish, reroll_to_adversary: toA, seed_changed_from_first_read: changed };
}
// begin at a cut comfortably in the future, so the adversary builds A on the tip at S - 1 (not behind a settled chain)
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 / EPOCH) + 2;
log(`start virtual daa ${startDaa}; attacking cuts for epochs ${firstE}..${firstE + CUTS - 1} (S = ${firstE * EPOCH - LEAD} and up)`);
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 epoch ${e} (S ${r.cut_score}): honest ${String(r.honest_seed).slice(0, 12)} final ${String(r.final_seed).slice(0, 12)} A ${String(r.adversary_block).slice(0, 12)} submit ${r.submit} ${r.reroll_to_adversary ? 'RE-ROLLED (seed = A)' : (r.seed_changed_from_first_read ? 'seed settled elsewhere' : 'held')}`);
} catch (e2) { log(`cut epoch ${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_to_adversary); // the adversary's own block became the epoch seed block
// Gate (plan 4.2 F7): no re-roll inside the publish window. The sound signal is the adversary steering the seed to its own
// block; natural seed churn before the cut settles is not an attack.
const gatePass = rerolls.length === 0;
const expectReroll = VDF_MS === 0; // the known-pass case must fire; the honest case (a real VDF delay) must not
const harnessSound = expectReroll ? rerolls.length > 0 : rerolls.length === 0;
const summary = {
tag: TAG, vdf_ms: VDF_MS, policy: POLICY, cuts_attempted: records.length, genesis_bits: GENESIS_BITS,
epoch_blocks: EPOCH, lead: LEAD, adversary_blocks_accepted: accepted.length,
rerolls_to_adversary_block: rerolls.length, seed_changed_cuts: records.filter(r => r.seed_changed_from_first_read).length,
gate_no_reroll_in_window: gatePass, expect_reroll: expectReroll, harness_sound: harnessSound,
sinks, sinks_agree: new Set(sinks).size === 1, node: IGNEUMD, records,
};
writeFileSync(`${OUT}/reroll-${TAG}.json`, JSON.stringify(summary, null, 2));
log(`SUMMARY ${TAG}: ${records.length} cuts, ${accepted.length} adversary blocks accepted, ${rerolls.length} re-rolls to A; gate(no re-roll in window) ${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();
// exit 0 when the run is internally consistent (harness sound); the gate verdict is in the summary, read per run
process.exit(harnessSound ? 0 : 1);

View file

@ -0,0 +1,533 @@
//! Attack-pass row F7 (`docs/plans/cryptanalysis.md` section 4.2), the hash-lane half: the era-seed census and the
//! day-key seeding checks. Record: `docs/analysis/attack-pass/f7-era.md`.
//!
//! Sub-commands:
//! census [--n 1048576] [--seeds raw|test] [--plant] 2^n era seeds through the real draw (`igneum_pow::generator::era_draw`
//! over `V3_ALLOWED`, the chain's path), classified; `--plant` runs the
//! classifier on planted weak parameters first (the known-fail case)
//! spec [--n 1048576] the first era stream of spec 01 section 1.13.1 (op-weight perturbation,
//! fold rotations, the consumed epoch_len draw), implemented here from the
//! spec's text because igneum-pow does not draw it
//! days [--n 131072] the 64-bit seeding of the day-key stream: distinct seeds over the days
//! the chain can have, and the same check on the era stream
//! all the three in order with the defaults
//!
//! Every number is printed as a table row so the log is the record. Nothing here edits igneum-pow.
use igneum_pow::bind::day_bytes;
use igneum_pow::generator::{era_draw, EraParams, Op, NONLOAD_WEIGHTS, V3_ALLOWED};
use igneum_pow::memhard::MixParams;
use igneum_pow::seed::{seed_words_from_bytes, SplitMix64};
use std::collections::HashSet;
/// Chip datapath energy per op at the N5 floor (`sim/horizon/algorithm/model.py` section era: 0.52 mul, 0.06 add, pJ).
const MUL_PJ: f64 = 0.52;
const ADD_PJ: f64 = 0.06;
/// Multiply share of the ten non-load weights (mul 8 + mad 8 + mulhi 6 of 75; `generator::NONLOAD_WEIGHTS`).
const MULT_SHARE_BASE: u64 = 22;
const NONLOAD_SUM: u64 = 75;
/// Counted ops per class v4 hash (`docs/analysis/latency-shadow-2026-10-06.md`: the M5 Max binds at about 100,000 ops
/// per hash on sh256x27).
const OPS_PER_HASH: f64 = 100_000.0;
/// Dataset loads per hash: 16 load slots x 8 iterations (spec 01 section 1.7).
const LOADS_PER_HASH: f64 = 128.0;
fn base_pj_per_op() -> f64 {
(MULT_SHARE_BASE as f64 * MUL_PJ + (NONLOAD_SUM - MULT_SHARE_BASE) as f64 * ADD_PJ) / NONLOAD_SUM as f64
}
/// Datapath energy of one hash at the base weights, pJ.
fn hash_pj() -> f64 {
OPS_PER_HASH * base_pj_per_op()
}
fn arg(args: &[String], name: &str) -> Option<String> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned())
}
fn hex32(b: &[u8; 32]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
/// A raw 32-byte era seed for census index `i`: four SplitMix64 words of a domain-separated state. Stands in for a
/// VDF output (uniform bytes); the draw hashes them through `seed_words_from_bytes` whatever their origin.
fn raw_era_bytes(i: u64) -> [u8; 32] {
let mut s = SplitMix64::new(i.wrapping_mul(0x9E3779B97F4A7C15) ^ 0xF7E7A5EEDC0DE001);
let mut out = [0u8; 32];
for k in 0..4 {
out[k * 8..k * 8 + 8].copy_from_slice(&s.next().to_le_bytes());
}
out
}
fn era_bytes(i: u64, test: bool) -> [u8; 32] {
if test {
EraParams::test_era_bytes(&format!("igneum-era-test/{i}"))
} else {
raw_era_bytes(i)
}
}
/// Non-adjacent-form weight of a 32-bit multiplier: the number of shift-add or shift-subtract terms a chip needs.
fn naf_weight(m: u32) -> u32 {
let mut x = m as u64;
let mut w = 0;
while x != 0 {
if x & 1 == 1 {
let z = 2 - (x % 4) as i64; // +1 or -1
x = (x as i64 - z) as u64;
w += 1;
}
x >>= 1;
}
w
}
/// The weak classes an era draw can fall in, as flags.
#[derive(Default, Clone, Copy, Debug)]
struct Flags {
m_even: bool,
r_out_of_range: bool,
pos_invalid: bool,
m_one: bool,
m_all_ones: bool,
m_pop_le2: bool,
m_pop_le4: bool,
m_pop_le6: bool,
m_pop_le8: bool,
m_naf_le2: bool,
m_naf_le3: bool,
m_pow2_plus1: bool,
pos_linear: bool,
pos_contiguous: bool,
pos_low_byte: bool,
}
fn classify(e: &EraParams) -> Flags {
let m = e.stride_mul;
let pop = m.count_ones();
let naf = naf_weight(m);
let pos = e.pos;
let valid = pos.iter().all(|&p| p < 16) && (1..4).all(|i| pos[i] > pos[i - 1]);
Flags {
m_even: m & 1 == 0,
r_out_of_range: !(1..=31).contains(&e.stride_rot),
pos_invalid: !valid,
m_one: m == 1,
m_all_ones: m == u32::MAX,
m_pop_le2: pop <= 2,
m_pop_le4: pop <= 4,
m_pop_le6: pop <= 6,
m_pop_le8: pop <= 8,
m_naf_le2: naf <= 2,
m_naf_le3: naf <= 3,
m_pow2_plus1: m != 1 && pop == 2 && m & 1 == 1,
pos_linear: pos == [0, 1, 2, 3],
pos_contiguous: valid && (1..4).all(|i| pos[i] == pos[i - 1] + 1),
pos_low_byte: valid && pos.iter().all(|&p| p < 8),
}
}
/// Chip gain of a class, as the datapath energy a chip saves per hash against the base, over the hash's datapath
/// energy. The stride multiply is one of three address operations per load (spec 01 section 1.13.1); a chip evaluates
/// `x * M` with a shift-add tree of `naf(M)` terms, so a low-weight M saves `MUL_PJ - (naf - 1) * ADD_PJ` per load, and
/// M = 1 saves the whole multiply. The rotation is a wire mux and the interleave an address-line permute: 0 pJ on a chip
/// with a programmable decoder (`algorithm.md` 5.4). No class touches the memory bound, the item derivation or N.
fn gain_of(name: &str) -> (f64, &'static str) {
let per_load = |saved_pj: f64| 1.0 + (LOADS_PER_HASH * saved_pj.max(0.0)) / hash_pj();
match name {
"m_one" => (per_load(MUL_PJ), "the stride multiply disappears (128 of about 100,000 ops)"),
"m_all_ones" => (per_load(MUL_PJ - ADD_PJ), "x * (2^32 - 1) = -x, one negate per load"),
"m_pop_le2" | "m_pow2_plus1" | "m_naf_le2" => (per_load(MUL_PJ - ADD_PJ), "one shift-add per load in place of a multiplier"),
"m_naf_le3" => (per_load(MUL_PJ - 2.0 * ADD_PJ), "two shift-adds per load"),
"m_pop_le4" => (per_load(MUL_PJ - 3.0 * ADD_PJ), "three shift-adds per load (upper bound)"),
"m_pop_le6" => (per_load(MUL_PJ - 5.0 * ADD_PJ), "five shift-adds per load (upper bound)"),
"m_pop_le8" => (per_load(MUL_PJ - 7.0 * ADD_PJ), "seven shift-adds per load (upper bound)"),
"pos_linear" | "pos_contiguous" | "pos_low_byte" => (1.0, "an address-line permute; 0 pJ on the modelled chip; a hard-wired linear chip runs only this class of era"),
"m_even" | "r_out_of_range" | "pos_invalid" => (f64::NAN, "a bijection or range failure: a finding, not a gain"),
_ => (1.0, ""),
}
}
struct Census {
n: u64,
counts: Vec<(&'static str, u64)>,
first_seed: Vec<(&'static str, Option<String>)>,
r_hist: [u64; 33],
pos_hist: Vec<u64>, // indexed by the 16-bit mask of the four positions
m_bit_ones: [u64; 32],
stream_seeds: HashSet<u64>,
lowest_pop: (u32, u32, Option<String>),
}
const CLASS_NAMES: [&str; 15] = [
"m_even",
"r_out_of_range",
"pos_invalid",
"m_one",
"m_all_ones",
"m_pop_le2",
"m_pop_le4",
"m_pop_le6",
"m_pop_le8",
"m_naf_le2",
"m_naf_le3",
"m_pow2_plus1",
"pos_linear",
"pos_contiguous",
"pos_low_byte",
];
fn flags_vec(f: &Flags) -> [bool; 15] {
[
f.m_even,
f.r_out_of_range,
f.pos_invalid,
f.m_one,
f.m_all_ones,
f.m_pop_le2,
f.m_pop_le4,
f.m_pop_le6,
f.m_pop_le8,
f.m_naf_le2,
f.m_naf_le3,
f.m_pow2_plus1,
f.pos_linear,
f.pos_contiguous,
f.pos_low_byte,
]
}
/// The expected fraction of each class under a uniform draw (M uniform odd, R uniform 1..31, pos a uniform 4-subset).
fn expected_fraction(name: &str) -> Option<f64> {
let odd_space = 2f64.powi(31);
let c = |n: u64, k: u64| -> f64 {
let mut r = 1f64;
for i in 0..k {
r = r * (n - i) as f64 / (i + 1) as f64;
}
r
};
// M odd: bit 0 set; the other 31 bits uniform. popcount(M) <= k means <= k-1 of the 31 high bits set.
let pop_le = |k: u64| -> f64 { (0..k).map(|j| c(31, j)).sum::<f64>() / odd_space };
Some(match name {
"m_even" | "r_out_of_range" | "pos_invalid" => 0.0,
"m_one" | "m_all_ones" => 1.0 / odd_space,
"m_pop_le2" => pop_le(2),
"m_pop_le4" => pop_le(4),
"m_pop_le6" => pop_le(6),
"m_pop_le8" => pop_le(8),
"m_pow2_plus1" => 31.0 / odd_space,
"pos_linear" => 1.0 / 1820.0,
"pos_contiguous" => 13.0 / 1820.0,
"pos_low_byte" => 70.0 / 1820.0,
_ => return None,
})
}
fn pos_mask(pos: &[u8; 4]) -> usize {
pos.iter().fold(0usize, |m, &p| m | (1 << (p as usize & 15)))
}
fn run_census(n: u64, test: bool) -> Census {
let mut c = Census {
n,
counts: CLASS_NAMES.iter().map(|&s| (s, 0u64)).collect(),
first_seed: CLASS_NAMES.iter().map(|&s| (s, None)).collect(),
r_hist: [0; 33],
pos_hist: vec![0u64; 1 << 16],
m_bit_ones: [0; 32],
stream_seeds: HashSet::with_capacity(n as usize),
lowest_pop: (33, 0, None),
};
for i in 0..n {
let eb = era_bytes(i, test);
let e = era_draw(&eb, &V3_ALLOWED);
let f = classify(&e);
let fv = flags_vec(&f);
for (k, hit) in fv.iter().enumerate() {
if *hit {
c.counts[k].1 += 1;
if c.first_seed[k].1.is_none() {
c.first_seed[k].1 = Some(format!("{} (M {:08x} R {} pos {:?})", hex32(&eb), e.stride_mul, e.stride_rot, e.pos));
}
}
}
c.r_hist[(e.stride_rot as usize).min(32)] += 1;
c.pos_hist[pos_mask(&e.pos)] += 1;
for b in 0..32 {
if (e.stride_mul >> b) & 1 == 1 {
c.m_bit_ones[b] += 1;
}
}
c.stream_seeds.insert(e.words[0] as u64 | ((e.words[1] as u64) << 32));
let pop = e.stride_mul.count_ones();
if pop < c.lowest_pop.0 {
c.lowest_pop = (pop, e.stride_mul, Some(format!("{} (M {:08x} R {} pos {:?})", hex32(&eb), e.stride_mul, e.stride_rot, e.pos)));
}
}
c
}
fn print_census(c: &Census, label: &str) {
let n = c.n as f64;
println!("\n## Era census: {label}, n = {} = 2^{:.0}\n", c.n, (c.n as f64).log2());
println!("| Class | Count | Fraction | log2(fraction) | Expected (uniform) | Chip gain | Why |");
println!("|---|---|---|---|---|---|---|");
for (k, (name, count)) in c.counts.iter().enumerate() {
let frac = *count as f64 / n;
let (gain, why) = gain_of(name);
let exp = expected_fraction(name).map(|e| format!("{:.3e}", e)).unwrap_or_default();
let l2 = if *count == 0 { format!("under -{:.0}", n.log2()) } else { format!("{:.2}", frac.log2()) };
let g = if gain.is_nan() { "FINDING".to_string() } else { format!("{gain:.4}x") };
let _ = k;
println!("| {name} | {count} | {frac:.3e} | {l2} | {exp} | {g} | {why} |");
}
println!("\nGate: no class with gain over 1.1x at a fraction over 2^-20 (plan 4.2 F7). Hash datapath energy at the base weights {:.1} nJ ({:.0} ops x {:.3} pJ).", hash_pj() / 1000.0, OPS_PER_HASH, base_pj_per_op());
println!("\nFirst seed per class (raw 32-byte E_n, hex):\n");
for (name, s) in &c.first_seed {
if let Some(s) = s {
println!("- {name}: {s}");
}
}
if let Some(s) = &c.lowest_pop.2 {
println!("- lowest popcount M in the census: popcount {} M {:08x}: {s}", c.lowest_pop.0, c.lowest_pop.1);
}
// uniformity: R over 1..31
let exp_r = n / 31.0;
let chi_r: f64 = (1..=31).map(|r| (c.r_hist[r] as f64 - exp_r).powi(2) / exp_r).sum();
let (rmin, rmax) = (1..=31).fold((u64::MAX, 0u64), |(lo, hi), r| (lo.min(c.r_hist[r]), hi.max(c.r_hist[r])));
let sig_r = exp_r.sqrt();
println!("\nStride rotation R over 1..31: chi-square {chi_r:.1} on 30 degrees of freedom (mean 30, sd 7.7); min bucket {rmin} max bucket {rmax} (expected {exp_r:.0}, sd {sig_r:.1}; max deviation {:.2} sigma); R = 0 or 32 seen {} times.", ((rmax as f64 - exp_r).abs().max((rmin as f64 - exp_r).abs())) / sig_r, c.r_hist[0] + c.r_hist[32]);
// uniformity: pos over the 1820 4-subsets of 0..15
let subsets: Vec<u64> = (0usize..1 << 16).filter(|m| m.count_ones() == 4).map(|m| c.pos_hist[m]).collect();
let exp_p = n / 1820.0;
let chi_p: f64 = subsets.iter().map(|&x| (x as f64 - exp_p).powi(2) / exp_p).sum();
let (pmin, pmax) = subsets.iter().fold((u64::MAX, 0u64), |(lo, hi), &x| (lo.min(x), hi.max(x)));
let sig_p = exp_p.sqrt();
let seen = subsets.iter().filter(|&&x| x > 0).count();
let bad: u64 = (0usize..1 << 16).filter(|m| m.count_ones() != 4).map(|m| c.pos_hist[m]).sum();
println!("Interleave pos over the 1,820 four-subsets of 0..15: {seen} of 1,820 seen; chi-square {chi_p:.1} on 1,819 degrees of freedom (mean 1,819, sd 60.3); min bucket {pmin} max bucket {pmax} (expected {exp_p:.1}, sd {sig_p:.1}; max deviation {:.2} sigma); draws with a non-4-subset {bad}.", ((pmax as f64 - exp_p).abs().max((pmin as f64 - exp_p).abs())) / sig_p);
// M bits
let sig_b = (n / 4.0).sqrt();
let worst = (1..32).map(|b| ((c.m_bit_ones[b] as f64 - n / 2.0).abs() / sig_b, b)).fold((0f64, 0usize), |a, x| if x.0 > a.0 { x } else { a });
println!("Stride multiplier M: bit 0 set in {} of {} (odd by construction); bits 1..31 each set in {:.3} to {:.3} of draws; worst bit {} at {:.2} sigma.", c.m_bit_ones[0], c.n, (1..32).map(|b| c.m_bit_ones[b] as f64 / n).fold(1f64, f64::min), (1..32).map(|b| c.m_bit_ones[b] as f64 / n).fold(0f64, f64::max), worst.1, worst.0);
println!("Era stream 64-bit seeds (words 0 and 1 of the era stream): {} distinct of {} seeds (birthday expectation of a collision at 2^20 draws from 2^64: 2^-25).", c.stream_seeds.len(), c.n);
}
/// The known-fail case for the classifier: planted parameters that must fire, and a sound one that must not.
fn plant() -> bool {
let base = era_draw(&EraParams::test_era_bytes("igneum-era-test/0"), &V3_ALLOWED);
let mk = |m: u32, r: u32, pos: [u8; 4]| EraParams { stride_mul: m, stride_rot: r, pos, ..base };
let cases: Vec<(&str, EraParams, &str)> = vec![
("M = 1 (identity stride)", mk(1, 7, [0, 3, 9, 14]), "m_one"),
("M = 2^32 - 1", mk(u32::MAX, 7, [0, 3, 9, 14]), "m_all_ones"),
("M = 2^16 + 1", mk(0x0001_0001, 7, [0, 3, 9, 14]), "m_pow2_plus1"),
("M = 2^31 - 1 (naf 2, popcount 31)", mk(0x7fff_ffff, 7, [0, 3, 9, 14]), "m_naf_le2"),
("M even (bijection failure)", mk(0x9E37_79B2, 7, [0, 3, 9, 14]), "m_even"),
("R = 0 (rotate by 0 is undefined in the emitted text)", mk(0x9E37_79B1, 0, [0, 3, 9, 14]), "r_out_of_range"),
("R = 32", mk(0x9E37_79B1, 32, [0, 3, 9, 14]), "r_out_of_range"),
("pos linear [0,1,2,3]", mk(0x9E37_79B1, 7, [0, 1, 2, 3]), "pos_linear"),
("pos contiguous [5,6,7,8]", mk(0x9E37_79B1, 7, [5, 6, 7, 8]), "pos_contiguous"),
("pos not ascending", mk(0x9E37_79B1, 7, [3, 2, 1, 0]), "pos_invalid"),
];
println!("\n## Planted parameters through the classifier (the known-fail case)\n");
println!("| Plant | Expected flag | Fired | Every flag raised |");
println!("|---|---|---|---|");
let mut ok = true;
for (name, e, expect) in &cases {
let f = classify(e);
let fv = flags_vec(&f);
let raised: Vec<&str> = CLASS_NAMES.iter().zip(fv.iter()).filter(|(_, &h)| h).map(|(n, _)| *n).collect();
let fired = raised.contains(expect);
ok &= fired;
println!("| {name} | {expect} | {} | {} |", if fired { "yes" } else { "NO" }, raised.join(", "));
}
// a sound draw must raise nothing
let f = classify(&base);
let raised: Vec<&str> = CLASS_NAMES.iter().zip(flags_vec(&f).iter()).filter(|(_, &h)| h).map(|(n, _)| *n).collect();
println!("| igneum-era-test/0 (a sound draw: M {:08x} R {} pos {:?}) | none | {} | {} |", base.stride_mul, base.stride_rot, base.pos, if raised.is_empty() { "yes" } else { "NO" }, raised.join(", "));
ok &= raised.is_empty();
println!("\nPlant verdict: {}", if ok { "every planted case fired and the sound draw did not" } else { "FAILED: a planted case did not fire" });
ok
}
/// Spec 01 section 1.13.1, the first era stream (not in igneum-pow): `seed_words_from_bytes("igneum-era/" || n_le64 || E_n)`
/// words 0 and 1 seed one SplitMix64; ten op weights each `below(2B+1) - B` with B = 2, renormalised by largest remainder
/// to 75; six fold rotations `1 + below(31)`; one draw consumed for `epoch_len`.
fn spec_stream(n_index: u64, e: &[u8; 32]) -> ([u64; 10], [u32; 6]) {
let mut b = Vec::with_capacity(11 + 8 + 32);
b.extend_from_slice(b"igneum-era/");
b.extend_from_slice(&n_index.to_le_bytes());
b.extend_from_slice(e);
let w = seed_words_from_bytes(&b);
let mut s = SplitMix64::new(w[0] as u64 | ((w[1] as u64) << 32));
const B: i64 = 2;
let mut p = [0i64; 10];
for (i, (_, wt)) in NONLOAD_WEIGHTS.iter().enumerate() {
p[i] = *wt as i64 + s.below((2 * B + 1) as u64) as i64 - B;
}
let sum: i64 = p.iter().sum();
// largest remainder to 75: floor(p_i * 75 / sum), then the leftover units to the largest remainders (ties by index)
let mut q = [0u64; 10];
let mut rem: Vec<(i64, usize)> = Vec::new();
let mut given = 0u64;
for i in 0..10 {
let num = p[i] * NONLOAD_SUM as i64;
q[i] = (num / sum) as u64;
given += q[i];
rem.push((num % sum, i));
}
rem.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
let mut left = NONLOAD_SUM - given;
for (_, i) in rem {
if left == 0 {
break;
}
q[i] += 1;
left -= 1;
}
let mut fold = [0u32; 6];
for f in fold.iter_mut() {
*f = 1 + s.below(31) as u32;
}
let _epoch_len_draw = s.next();
(q, fold)
}
fn run_spec(n: u64) {
println!("\n## Spec 1.13.1 first stream (op-weight perturbation, fold rotations), n = {} = 2^{:.0}; implemented here from the spec text, NOT in igneum-pow\n", n, (n as f64).log2());
let mul_ix: Vec<usize> = NONLOAD_WEIGHTS.iter().enumerate().filter(|(_, (op, _))| matches!(op, Op::Mul | Op::Mad | Op::MulHi)).map(|(i, _)| i).collect();
let mut share_hist = [0u64; 76];
let mut sum_bad = 0u64;
let mut fold_triple_equal = 0u64;
let mut fold_both_equal = 0u64;
let mut fold_repeat = 0u64;
let mut fold_all_six = 0u64;
let mut fold_hist = [0u64; 32];
let mut first_min: Option<String> = None;
let mut first_max: Option<String> = None;
let (mut smin, mut smax) = (75u64, 0u64);
for i in 0..n {
let eb = raw_era_bytes(i);
let (q, fold) = spec_stream(i, &eb);
if q.iter().sum::<u64>() != NONLOAD_SUM {
sum_bad += 1;
}
let share: u64 = mul_ix.iter().map(|&k| q[k]).sum();
share_hist[share as usize] += 1;
if share < smin {
smin = share;
first_min = Some(format!("{} weights {:?}", hex32(&eb), q));
}
if share > smax {
smax = share;
first_max = Some(format!("{} weights {:?}", hex32(&eb), q));
}
let t1 = fold[0] == fold[1] && fold[1] == fold[2];
let t2 = fold[3] == fold[4] && fold[4] == fold[5];
if t1 || t2 {
fold_triple_equal += 1;
}
if t1 && t2 {
fold_both_equal += 1;
}
if t1 && t2 && fold[0] == fold[3] {
fold_all_six += 1;
}
let rep = |t: &[u32]| t[0] == t[1] || t[1] == t[2] || t[0] == t[2];
if rep(&fold[0..3]) || rep(&fold[3..6]) {
fold_repeat += 1;
}
for f in fold {
fold_hist[f as usize] += 1;
}
}
let nf = n as f64;
println!("| Multiply share (mul+mad+mulhi of 75) | Count | Fraction | Chip pJ per op | Against the base 22/75 |");
println!("|---|---|---|---|---|");
for s in smin..=smax {
let c = share_hist[s as usize];
if c == 0 {
continue;
}
let e = (s as f64 * MUL_PJ + (75 - s) as f64 * ADD_PJ) / 75.0;
println!("| {s} | {c} | {:.3e} | {e:.3} | {:+.1}% |", c as f64 / nf, (e / base_pj_per_op() - 1.0) * 100.0);
}
println!("\nRenormalised weight sums not 75: {sum_bad}. Lowest multiply share seen {smin} (first seed {}); highest {smax} (first seed {}).", first_min.unwrap_or_default(), first_max.unwrap_or_default());
println!("The share moves the GPU's energy the same way (its IMAD is the chain's own op, algorithm.md 5.4): the chip-against-GPU gain of any weight corner is 1.0x; the absolute datapath swing is the column above; 0 memory effect.");
println!("\n| Fold rotation class | Count | Fraction | Expected (uniform) | Chip gain |");
println!("|---|---|---|---|---|");
let p3 = 1.0 / 961.0;
println!("| a triple all equal | {fold_triple_equal} | {:.3e} | {:.3e} | 1.0x (a wire mux) |", fold_triple_equal as f64 / nf, 2.0 * p3 - p3 * p3);
println!("| both triples all equal | {fold_both_equal} | {:.3e} | {:.3e} | 1.0x |", fold_both_equal as f64 / nf, p3 * p3);
println!("| all six equal | {fold_all_six} | {:.3e} | {:.3e} | 1.0x |", fold_all_six as f64 / nf, p3 * p3 / 31.0);
println!("| a repeated value inside a triple | {fold_repeat} | {:.3e} | {:.3e} | 1.0x |", fold_repeat as f64 / nf, 1.0 - (26970.0f64 / 29791.0).powi(2));
let exp_f = 6.0 * nf / 31.0;
let (fmin, fmax) = (1..=31).fold((u64::MAX, 0u64), |(lo, hi), r| (lo.min(fold_hist[r]), hi.max(fold_hist[r])));
println!("\nFold rotations over 1..31 (6 per seed): min bucket {fmin} max bucket {fmax} (expected {exp_f:.0}, sd {:.0}); rotation 0 seen {} times.", exp_f.sqrt(), fold_hist[0]);
}
fn run_days(n: u64) {
println!("\n## The 64-bit seeding of the day-key stream (memhard.rs `MixParams::with_shape`: `SplitMix64::new(key[0] | key[1] << 32)`), days 0..{n}\n");
let mut seeds64 = HashSet::with_capacity(n as usize);
let mut keys256 = HashSet::with_capacity(n as usize);
let mut tuples = HashSet::with_capacity(n as usize);
let mut dup64 = 0u64;
for d in 0..n {
let k = seed_words_from_bytes(&day_bytes(d));
let s = k[0] as u64 | ((k[1] as u64) << 32);
if !seeds64.insert(s) {
dup64 += 1;
}
keys256.insert(k);
let mp = MixParams::new(k);
tuples.insert((mp.rot, mp.mul, mp.rc));
}
println!("| Quantity | Value |");
println!("|---|---|");
println!("| Day key bytes on the chain | `\"igneum-day/\" \\|\\| day_le64`, `day = timestamp_ms / 86,400,000` (node fork `consensus/pow/src/igneum.rs`; `igneum_pow::bind::day_bytes`): calendar, no chain state |");
println!("| Days checked | {n} (the chain at 1 block/s has 65,745 days in 180 years, 2^16.0) |");
println!("| Distinct 256-bit day keys K | {} |", keys256.len());
println!("| Distinct 64-bit stream seeds `K[0] \\| K[1] << 32` | {} (duplicates {dup64}) |", seeds64.len());
println!("| Distinct (ROT, MUL, RC) tuples | {} |", tuples.len());
println!("| Bits of K that enter the cache fill | 256 (spec 1.8.3: `K[0..7]` in every block input) |");
println!("| Bits of K that enter the mixer-constant draw | 64 (spec 1.8.4: `K[0] \\| (K[1] << 32)`, the stated intent) |");
println!("| Nominal draw space of (ROT, MUL, RC) | 8 x log2(31) + 16 x 31 + 16 x 32 = {:.1} bits | ", 8.0 * 31f64.log2() + 16.0 * 31.0 + 16.0 * 32.0);
println!("| Reachable streams | 2^64 seeds, of which at most 2^16 are ever used (one per calendar day) |");
println!("| Birthday bound on a 64-bit collision among 2^16 days | 2^(32 - 65) = 2^-33 |");
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let cmd = args.first().map(String::as_str).unwrap_or("all");
let n = arg(&args, "--n").and_then(|s| s.parse::<u64>().ok());
let test = arg(&args, "--seeds").as_deref() == Some("test");
let do_plant = args.iter().any(|a| a == "--plant");
let t0 = std::time::Instant::now();
match cmd {
"census" => {
if do_plant && !plant() {
std::process::exit(2);
}
let c = run_census(n.unwrap_or(1 << 20), test);
print_census(&c, if test { "igneum-era-test/<i> seeds" } else { "raw 32-byte seeds" });
}
"spec" => run_spec(n.unwrap_or(1 << 20)),
"days" => run_days(n.unwrap_or(1 << 17)),
"all" => {
if !plant() {
std::process::exit(2);
}
let c = run_census(n.unwrap_or(1 << 20), false);
print_census(&c, "raw 32-byte seeds");
run_spec(n.unwrap_or(1 << 20));
run_days(1 << 17);
}
_ => {
eprintln!("usage: attack-f7 census|spec|days|all [--n N] [--seeds raw|test] [--plant]");
std::process::exit(2);
}
}
println!("\nelapsed {:.1} s", t0.elapsed().as_secs_f64());
}