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:
parent
5b023ca6c0
commit
91a169662f
5 changed files with 1013 additions and 0 deletions
165
docs/analysis/attack-pass/f7-era.md
Normal file
165
docs/analysis/attack-pass/f7-era.md
Normal 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
14
tools/attack/f7-era/Cargo.lock
generated
Normal 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"
|
||||
19
tools/attack/f7-era/Cargo.toml
Normal file
19
tools/attack/f7-era/Cargo.toml
Normal 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
|
||||
282
tools/attack/f7-era/reroll.mjs
Normal file
282
tools/attack/f7-era/reroll.mjs
Normal 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);
|
||||
533
tools/attack/f7-era/src/main.rs
Normal file
533
tools/attack/f7-era/src/main.rs
Normal 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());
|
||||
}
|
||||
Loading…
Reference in a new issue