igneum-pow 0.2.0: generator v2 draws exactly 16 load slots from instructions 1..63, a load's source from the registers written earlier and not read by a load since, the other 48 ops from the ten non-load weights; accept.rs is spec 01 section 1.4.6 (static: no stale load source, every register injected; dynamic: 64 units on the seed-keyed closed-form dataset, no constant bit, no lane-constant site, under 164 saturated, bias within 136 of 1024, distinct addresses above 245,760); a rejected candidate is replaced by the next attempt of the seed (seed || k_le32), 32 a consensus fault. Packs carry the generator version, attempt and program id. Version 1 kept as generate_v1 for the census. Packs: igneum-genesis, igneum-hourly, igneum-genesis-mh regenerated by igneum-pow export; new igneum-devnet-v4-epoch0 (devnet genesis hash, day bytes 20730). Checks: Rust 39 of 39 tests; Metal natively via the Swift port (export cross-check 3 of 3 warps, identical programs and vectors on five seeds incl. three with attempt 1, fuzz 2,000 of 2,000); CUDA emu 4 of 4 packs; OpenCL emu 2 packs x 2 configurations; Apple OpenCL 4 of 4 packs at 27.9 Mhash/s. Census 20,000: 5.225 percent rejected, accepted distinct mean 127.887. Spec 01 0.2 (1.4.2, 1.4.3, 1.4.6, 1.11, 1.15, 1.16, 1.17), igneum-pow README, the CUDA, OpenCL and Metal test notes, bench-log entry, ledger M5 and M6 Fixed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
13 KiB
igneum-pow
The Igneum lottery hash in Rust: the generator, the acceptance rule, the memory-hard dataset, the CPU verifier and the
kernel emitters. This is the crate the rusty-kaspa fork calls (docs/fork-map.md, rows a1 to a3) and, since
4 October 2026, the source of every program pack in proto-cuda/packs/. No dependency outside the standard
library; serde_json is a dev-dependency for reading the packs in the tests.
Dates: 3 October 2026 (crate, bit-exact with the Swift prototype), 4 October 2026 (generator version 2 and the
acceptance rule; every vector re-cut). Toolchain: rustc 1.99.0 via rustup (the Homebrew 1.69 on PATH is too old;
use ~/.cargo/bin/cargo). Crate version 0.2.0.
Modules
| Module | What it is | Swift namesake |
|---|---|---|
seed |
32-byte seed words from bytes (FNV-1a 64, four salts, finalised); seed_words_from_bytes is the boundary where the chain feeds the epoch seed; SplitMix64 |
seedWordsBytes, SplitMix64 |
generator |
version 2: 16 load slots drawn first from instructions 1..63, fresh-source loads, the other 48 ops from the ten non-load weights; attempts k = 0, 1, ... of a seed; the program id. The retired version 1 generator stays as generate_v1 for the census and the lever measurements |
generateProgramV2, candidateProgram, generateProgramV1 |
accept |
the acceptance rule of spec 01 section 1.4.6: two static tests and the 64-unit dynamic test on the seed-keyed closed-form dataset | acceptProgram |
memhard |
256 MiB cache (2^16 chains of 64 ChaCha12 blocks), mixer parameters, 8-round item derivation with the 32 lanes interleaved, MemhardCpu::fetch |
cpuFillCache, MixParams, deriveItems, MemhardCPU |
verify |
the 32-lane warp interpreter, DatasetMode::{ClosedForm, MemoryHard}, Epoch, hash_warp, verify_block |
cpuWarp, DatasetSource |
emit |
Metal, CUDA and OpenCL source, program.h, memhard.h, vectors.h, program.json, vectors.json, the header-bound kernels, export_pack |
generateMSL, generateCUDA, generateOpenCL, exportPack |
bind |
header binding (spec 01 section 1.6): init words from "igneum-block/" || H || nonce_hi_le32, bound hash API on Epoch, the 256-bit pow mapping, the interim day seed bytes |
blockInitWords |
Generator version 2 (4 October 2026)
Adopted from docs/analysis/weak-program-census-2026-10-03.md (ledger M5 and M6). Three parts, all in this crate
and mirrored in proto-metal/main.swift so the Metal worker derives the same program from the same seed:
| Part | Rule | Where |
|---|---|---|
| G1, exact load count | 16 load instructions per program, a uniform 16-subset of slots 1..63 drawn first by partial Fisher-Yates over the program stream; the other 48 ops from add 12, xor 10, mul 8, mad 8, shfl 8, rotl 7, sub 6, mulhi 6, rotr 6, or 4 (sum 75). 128 loads per hash, 4,096 items per unit |
generator::candidate_from_words |
| G2, fresh source | a load's source is drawn from the registers other than dst written by an earlier instruction and not read by a load since, so no load repeats an earlier load's address in the hash |
same |
| R, acceptance | (a) no load whose source is unwritten since the previous load from it, cyclically; (b) every register has an add, sub, xor, mad, shfl or load write; (c) 64 units at base nonces from SplitMix64(FNV-1a-64("igneum-accept/" || seed words LE)), init words = seed words, closed-form dataset dataset_elem(idx, S[0], S[1]) at 2^28 words: no constant register bit, no lane-constant load site in any unit, fewer than 164 saturated final values, every output bit within 136 of 1,024, distinct addresses above 245,760 over the 2,048 hashes |
accept::check |
| Attempts | a rejected candidate is replaced by seed_words_from_bytes(seed || k_le32) for k = 1, 2, ...; 32 consecutive rejections are a consensus fault (probability below 2^-136 at the measured 5 percent rate) |
generator::generate_from_seed_bytes |
| Program id | FNV-1a-64("igneum-program/" || 2_le32 || seed words LE || attempt_le32), written into program.json and program.h with the generator version and the attempt, so a version 1 pack or another attempt can never pass for the current program |
Program::program_id |
Measured on this crate (igneum-pow accept): igneum-genesis attempt 0 accepted, program id bcc1248b10cc90f2,
op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1, 128.000 distinct addresses per
hash; igneum-hourly attempt 0, id a4c4d00961c855df; seeds igneum-census-2026-10-03/22, /37 and /51 have
attempt 0 rejected ((b) r7 without an injecting write; (c) 119.74 distinct addresses; (b) r4) and attempt 1
accepted (ids 22ed0609d079f4cf, 947705cc4eb1df0a, 9869afcc028bf9f1); those three are the conformance vectors
for the attempt rule. The rule costs 1.3 to 3.4 ms per seed on one core. The 20,000-program census under this
generator is in docs/bench-log.md (4 October 2026 entry).
The API the fork calls
use igneum_pow::{Epoch, DatasetMode};
// Once per epoch and day: derives the accepted program and fills the 256 MiB cache (about 0.2 s on one core).
let epoch = Epoch::memory_hard("igneum-genesis", "2026-10-03");
let h: u64 = epoch.hash(nonce); // one nonce (computes its aligned 32-nonce warp)
let w: [u64; 32] = epoch.hash_warp(base_nonce); // one warp
let ok: bool = epoch.verify_block(nonce, target_u64);
// Miner programs for the epoch: the pack every worker compiles.
let pack = igneum_pow::emit::export_pack(&epoch, "2026-10-03", "igneum node");
pack.write_to(std::path::Path::new("out"))?; // kernel.cu, kernel.cl, program.metal, memhard.h, ...
The header-bound form (what the chain uses)
The pack form above initialises the lane registers from the program's own seed words, so one nonce has one
hash per epoch whatever block is mined. On the chain the init words commit to the block (spec 01 section 1.6,
src/bind.rs):
H = header hash with the nonce field zeroed, every other field as mined
(rusty-kaspa hash_override_nonce_time(header, 0, header.timestamp))
nonce = 64 bits; lane nonce n = low 32 bits; nonce_hi = high 32 bits
I = seed_words_from_bytes("igneum-block/" || H || nonce_hi_le32) (49 bytes in)
hash = interpret(program, I, n) (section 1.7 of the spec)
pow256 = hash in the top 64 bits, low 192 bits zero (little-endian bytes 24..32)
valid = pow256 <= target256, which is exactly hash <= target256 >> 192
H keeps the timestamp (Kaspa zeroes it in the pre-PoW hash and absorbs it in cSHAKE afterwards; the lane hash has
no afterwards, so a nonce would otherwise be reusable across timestamps). The interim day seed is
"igneum-day/" || day_le64 with day = timestamp_ms / 86,400,000. The epoch seed bytes are the 32 bytes of the
epoch block hash (devnet); the program is generate_from_seed_bytes(epoch_seed), attempts included.
use igneum_pow::{bind, Epoch};
let epoch = Epoch::from_seed_bytes(epoch_hash.as_bytes(), &bind::day_bytes(day), "label");
let lane: u64 = epoch.hash_bound(&prehash, nonce); // one 64-bit nonce
let warp: [u64; 32] = epoch.hash_warp_bound(&prehash, nonce); // its aligned 32-nonce group
let init = bind::block_init_words(&prehash, nonce); // what a GPU kernel takes as its argument
let same = epoch.hash_warp_init(&init, nonce as u32 & !31); // == warp
let pow: [u8; 32] = epoch.pow_bound(&prehash, nonce);
let ok = epoch.verify_block_bound(&prehash, nonce, bind::target64_from_le256(&target_le));
On the GPU the init words are a kernel argument: igneum_hash_bound in program_bound.metal takes
constant uint* initw [[buffer(3)]], kernel_bound.cu takes IgneumInitWords iw by value, kernel_bound.cl an
initw buffer. All three differ from igneum_hash only in the kernel name, the argument and the eight init lines.
Bound vectors (seed igneum-genesis, day 2026-10-03, memory-hard, 2^28 words, generator v2; igneum-pow hash-bound):
| H | nonce | hash_bound |
|---|---|---|
| 32 zero bytes | 0 | 746c567b090acf6a |
| 32 zero bytes | 1 | 45a619f860880c73 |
| 32 zero bytes | 31 | 9aa495e43dedbfe6 |
| 32 zero bytes | 4096 | 2e6ffd7624d3cba2 |
| 32 zero bytes | 4294967296 (1 << 32) | 38a5cea1fb01431a |
| bytes 00 01 02 .. 1f | 0 | 2a79c5e4797bf6aa |
| bytes 00 01 02 .. 1f | 4294967301 ((1 << 32) + 5) | a243e0c61aa1b82e |
| bytes 00 01 02 .. 1f | 18446744073709551615 (u64::MAX) | 9c7bbfbd064fe1a4 |
Init words for H = 32 zero bytes, nonce 0: 595a8f8a 37647e95 faadade1 cbbcf2a4 54f7cc13 f6851b5e 8c68ca04 7991ea9c
(unchanged by v2: the binding does not depend on the program). The eight are pinned in bind::tests::bound_vectors.
The version 1 bound vectors of 3 October 2026 are retired.
Epoch is Send + Sync; build one and share it. DatasetMode::ClosedForm is the interpreter regression dataset
of the packs igneum-genesis and igneum-hourly and is not memory-hard.
CLI
cargo build --release
./target/release/igneum-pow bench --seed igneum-genesis [--warps 20] [--closed-form] [--day 2026-10-03]
./target/release/igneum-pow export --seed igneum-genesis --out <dir> [--closed-form]
./target/release/igneum-pow export --epoch-hex <64 hex> --day-hex <hex> --out <dir> the chain's byte seeds
./target/release/igneum-pow hash --seed igneum-genesis --nonce 4103
./target/release/igneum-pow hash-bound --seed igneum-genesis --prehash <64 hex> --nonce <u64>
./target/release/igneum-pow accept --seed igneum-census-2026-10-03/22 every candidate with its verdict
./target/release/igneum-pow show --seed igneum-genesis the accepted program, one line per instruction
The packs were regenerated on 4 October 2026 with exactly these commands:
igneum-pow export --closed-form --seed igneum-genesis --out ../proto-cuda/packs/igneum-genesis
igneum-pow export --closed-form --seed igneum-hourly --out ../proto-cuda/packs/igneum-hourly
igneum-pow export --seed igneum-genesis --out ../proto-cuda/packs/igneum-genesis-mh
igneum-pow export --epoch-hex edc4fa844da9dc98d37e965176f6558a31560e40502ab3ae5491b21aaaabfb07 \
--day-hex 69676e65756d2d6461792ffa50000000000000 --out ../proto-cuda/packs/igneum-devnet-v4-epoch0
The last is the chain's own derivation for devnet v4 epoch 0: the devnet genesis hash as the epoch seed and
bind::day_bytes(20730) (2026-10-04) as the day bytes.
Tests
cargo test --release (39 tests, about 2 s after compile; the dev profile is optimised so the cache fill is quick):
| Check | Pack | Result |
|---|---|---|
program.json instruction by instruction from seed_bytes, generator 2, attempt, program id, op mix, 128 loads, acceptance |
all four | match |
| Mixer parameters (key, rot, mul, rc) | igneum-genesis-mh, igneum-devnet-v4-epoch0 | match |
Cache head, last line, FNV-1a 64 (48c4f5bf24166b2e for day 2026-10-03, 448274a57f508cbc for day bytes 20730) |
the two memory-hard packs | match |
Dataset head (16), [MASK], 64 sampled words |
all four | match |
| 96 hash vectors (3 warps x 32 lanes) | all four | 96/96 each |
| kernel.cu, kernel_bound.cu, program.metal, program_bound.metal, kernel.cl, kernel_bound.cl, program.h, program.json byte-identical; 16 masked loads per kernel | all four | identical |
| memhard.h, memhard.metal byte-identical | the two memory-hard packs | identical |
| vectors.json, vectors.h byte-identical; no stale file in any pack directory | all four | identical |
| bound vectors (8), bound warp == bound single, H and nonce_hi enter the hash | igneum-genesis-mh | pass |
the devnet pack equals Epoch::from_seed_bytes(genesis, day_bytes(20730)) |
igneum-devnet-v4-epoch0 | pass |
acceptance: instrumented interpreter == hash_warp, cyclic stale-load detection, injecting-write detection, rejection under 12.5 percent and distinct loads above 127 on 400 census seeds, version 1 programs mostly rejected |
unit tests | pass |
| generator: 16 loads, none at instruction 0, contract on 200 candidates; fresh sources; attempt words; program ids separate versions and attempts | unit tests | pass |
Measured, Apple M5 Max, one core, release build
| Step | 3 October 2026 (v1, 104 loads) | 4 October 2026 (v2, 128 loads, 4,096 items) |
|---|---|---|
| Cache fill, 256 MiB | 175 to 181 ms | 179 ms |
| CPU verify per warp, igneum-genesis, avg of 20 | 0.441 ms (3,328 items) | 0.631 ms |
| Cold single warps (bases 0, 4096, 1000000) | 0.41 to 0.87 ms | 0.67 to 0.81 ms |
| Acceptance rule per candidate | 1.3 to 3.4 ms | |
| Closed form, igneum-genesis | 0.002 ms | 0.002 ms |
The 10 ms gate holds with a margin of about 16x steady on this core. Every verified unit now derives exactly 4,096 items, the design bound of spec section 1.11.
Not done here
- No GPU. The vectors tie this crate to the Metal, CUDA and OpenCL results through the packs; nothing here runs a kernel.
- The epoch seed enters at
Epoch::from_seed_bytes(the epoch block hash on devnet); the VDF output replaces it there. - The dynamic acceptance test is specified on the closed-form dataset at 2^28 words; if the prototype mask ever changes, the rule's constant stays at 2^28.