igneum/docs/plans/counter-asic-2-node.md
2026-10-05 22:04:29 +00:00

20 KiB

Counter ASIC 2.0: the node side (program class v3 as a height switch)

5 October 2026, night, worker "ca2-node". Branches: ca2-v3 (main repository: the igneum-pow seam, the workers, the fast-time gate) and ca2-v3-node (the fork, from the 0.3.10 tip 21d4c73c plus pack-loop 05ef0fa3). Plan: docs/plans/counter-asic-2-rollout.md; status: docs/plans/counter-asic-2-status.md. The shape follows finality v3 (docs/plans/finality-v3-rollout-devnet.md section 6): one height switch read from the override file, every node carries the same object before the height.

Everything below is the SEAM. The class itself (igneum_pow::V3_CLASS) is a placeholder, w16 (LoadClass::fixed(4, 16)), that the integration branch replaces with the decided width, mix and scratch share; the ca2-era draw and the ca2-mixer item construction fill what Epoch::from_chain_seeds calls. Nothing here changes a v2 program, a pinned pack or any live node.

1. What changed, where

Piece What
igneum-pow generator.rs ProgramClass { V2, V3 }, V3_CLASS (placeholder), GENERATOR_VERSION_V3 = 3, generate_from_seed_bytes_program_class(label, seed, class, era); Program::era_bytes; a v3 program's id is program_id(3, seed, attempt) (spec 01 section 1.4.6)
igneum-pow verify.rs Epoch::from_chain_seeds(epoch, day, era, class, label), Epoch::chain_program (no cache fill), Epoch::chain_dataset(day, class) (the one entry the node's day cache goes through; today both classes build the same cache)
igneum-pow emit.rs program.h: IGNEUM_PROGRAM_CLASS "v3" and IGNEUM_ERA_SEED_HEX beside IGNEUM_GENERATOR 3; program.json: program_class, era_seed_bytes; nothing on a v2 pack (tests/packs.rs diffs the pinned packs igneum-genesis-mh and igneum-devnet-v4-epoch0: identical)
igneum-pow packcheck.rs verify_pack_texts_chain / verify_pack_dir_chain(dir, epoch, day, want_class, want_era): PackFault::WrongClass for the wrong class, the wrong era, or a generator other than 2 or 3; PackIdentity carries generator, class, era_hex
proto-cuda/nvrtc/packfile.h pf_load refuses a generator other than 2 or 3 (spec 1.4.5), reads the class (must match the generator) and the era; pf_pack_class_ok, pf_class_token (the one rule for the class= / era= tokens)
proto-cuda/nvrtc/worker.cpp, proto-opencl/host.c a pair's identity includes its class and era when the line names them; right seeds and the wrong class answer need <e> <d> and error <id> pack <dir>: program class mismatch ..., so the miner prepares the pair from a pack of the right class; a prepare on a wrong-class pack fails in plain words
proto-metal/main.swift refuses every class=v3 line (the Swift generator is version 2; the integration adds 3)
fork consensus/core/src/igneum.rs ProgramClass, program_class_for_epoch_at(e, N4, L), program_class_v3_first_epoch_at, the process-wide activation (install_program_class_v3_activation, program_class_for_epoch, program_class_at), POW_ERA_BLOCKS, POW_ERA_LEAD, pow_era_index, pow_era_seed_score; PowEpochInfo + program_class, next_program_class, program_class_v3_activation_daa, era_index, era_seed (serde defaults: v2, never, none)
fork consensus/core/src/config/params.rs program_class_v3_activation_daa in Params and OverrideParams (default never on devnet, simnet, mainnet; 0 on the testnet like every other switch), override_params, From<Params>, the digest (unconditionally, right after finality_v3_activation_daa), Params::install_program_class_v3_activation, Params::program_class_v3_first_epoch; tests override_params_carry_the_program_class_v3_activation, the digest test's 11th edit, fast_time_60x_file_is_the_devnet_at_60x (every field present)
fork kaspad/src/daemon.rs Program class v3 from the override file: active from epoch E (DAA score N4 rounded up to the epoch boundary at 3600*E, epochs of 3600 DAA); the activation installed next to the PoW schedule
fork consensus/pow/src/igneum.rs EpochSeeds { epoch, day, class, era } (+ EpochSeeds::v2), day caches keyed on (day, class), the program through Epoch::chain_program, the cache through Epoch::chain_dataset, standalone_epoch through Epoch::from_chain_seeds; test program_class_v3_seeds_hash_their_own_program_over_their_own_cache
fork consensus/src/pipeline/header_processor/{processor,pre_ghostdag_validation}.rs the class of the header's epoch (program_class_at(daa)), HeaderProcessor::era_seed (the stand-in, memoised per era), RuleError::EraSeedUnavailable
fork consensus/src/processes/pruning_proof/igneum_pow.rs seeds_for: the class of the epoch; era 0 = genesis; a later era is PruningImportError::MissingEraSeed (no era witness in the proof format yet)
fork consensus/src/consensus/mod.rs get_pow_epoch_info: the class of this and the next epoch, the activation, the era index and seed (the era walk memoised once per era per process)
fork rpc/core/src/model/message.rs, rpc/grpc/core/proto/rpc.proto (fields 12 to 16), rpc/grpc/core/src/convert/message.rs RpcPowEpochInfo + program_class (generator number), next_program_class, program_class_v3_activation_daa, era_index, era_seed; an old node's absent fields read as v2, never, none
fork igneum/miner/src/main.rs seeds from the template (seeds_from_info), the activation installed from the template, the legacy seed walk keys the class on it; next_pair takes the next epoch's class; the job and prepare lines end with class=v3 era=<hex> for a v3 epoch; seeds.txt carries program_class and era_seed_hex; write_pack_checked checks class and era (verify_pack_dir_chain); the "program and 256 MiB cache ready" lines and export-pack print the class and the program id
infra/fast-time/override-60x.json, tools/finality-attacks/redteam/override-60x-v3.json, infra/fast-time/README.md the field at never, the README row
infra/fast-time/class-v3.mjs the G4 gate (section 5)

2. The epoch-boundary rule

One epoch has one program (spec 01 section 1.12), so the switch keys on the EPOCH: epoch e is class v3 when L * e >= N4 with L the live epoch length (pow_epoch_blocks(), 3,600 on the devnet, 60 on the fast-time profile). The first v3 epoch is ceil(N4 / L); a height inside an epoch rounds UP to the next boundary and never splits an epoch between two programs. A block's class is a function of its DAA score alone (program_class_at(daa)), as its epoch seed is.

N4 L first v3 epoch first v3 DAA the epoch before
150 60 3 180 epoch 2 (DAA 120 to 179) is v2 to its last block
180 60 3 180
181 60 4 240
136,000 3,600 38 136,800 epoch 37 (133,200 to 136,799) is v2
136,800 3,600 38 136,800
0 any 0 0 (the testnet)
never any none

The unit tests program_class_switch_rounds_up_to_the_epoch_boundary (consensus-core) and override_params_carry_the_program_class_v3_activation (params) pin these rows and sweep every activation 0..399 at L = 60.

The digest: the field (and pow_genesis_dataset_log2) enters consensus_digest unconditionally, so the digest flips the moment a binary carrying the field (at never) runs, exactly as the finality v3 field did. This is intended: every node must carry the object before any node reaches the height, and a node without the field is refused at the handshake (rollout section 1, order step 1). Measured: the devnet digest with no override file moves from 9409dedac4bf9f0f... (0.3.10) to c562d70e1428c9789823cc40067623b4767f7c555ce7ff4ea11c1498f013ef6c (0.3.11; the pinned value of consensus_digest_keeps_the_0_3_5_value_until_the_fee_switch_is_set), which is the expected digest of a scratch node at the publish.

3. The era stand-in

E_n of spec 04 section 4.4, until the 1-hour VDF is in the node (docs/plans/era-layout.md section 2):

Era E_n
0 the genesis block hash
n >= 1 the hash of the last selected-chain block whose DAA score is below 15,552,000 n - 7,200

One function per path: HeaderProcessor::era_seed (the header's era from its DAA score, the walk down the selected parents from the header's selected parent, memoised per era in era_seed_memo), Consensus::get_pow_epoch_info (the same walk from the sink for the template, memoised once per era per process), ProofSeeds::seeds_for (era 0 only). The seed block is at least one era lead (7,200 DAA) below any header that uses it, past the merge depth (3,600), so one walk per era per process is sound; the walk itself is up to an era long on the first header of era n >= 1 (about 15.5 million selected parents), which is why it is memoised and why the VDF should land before era 1 (180 days after genesis). A v2 program never reads the era; the placeholder v3 class does not either (the ca2-era draw will); the era is carried and recorded in the pack so a worker of the wrong era is refused from the first v3 build.

4. The job line, the prepare line, the pack

Surface Class v2 (today) Class v3
job line job <id> <prehash> <target> <start> <count> <epoch> <day> the same with class=v3 era=<64 hex> at the end
prepare line prepare <epoch> <day> [<dir>] the same with class=v3 era=<64 hex> at the end
program.h IGNEUM_GENERATOR 2 IGNEUM_GENERATOR 3, IGNEUM_PROGRAM_CLASS "v3", IGNEUM_ERA_SEED_HEX "<64 hex>"
program.json "generator": 2 "generator": 3, "program_class": "v3", "era_seed_bytes": "<hex>"
seeds.txt epoch_seed_hex, day_seed_hex, day_index plus program_class v3, era_seed_hex <hex>
template pow_epoch programClass 2 programClass 3, nextProgramClass, programClassV3ActivationDaa, eraIndex, eraSeed

A v3 program's identity is the pair (program id, era seed): the id covers the generator, the seed words and the attempt (spec 01 section 1.4.6, unchanged), so every era of one epoch seed shares one id, and the era seed, carried by the pack (IGNEUM_ERA_SEED_HEX) and the job line (era=), tells them apart. The workers and packcheck compare both.

A v2 line and a v2 pack are byte for byte what the workers read before this branch (the tokens are sent only for a v3 epoch), so a 0.3.10 worker on a 0.3.11 miner mines v2 epochs unchanged and refuses nothing until the switch; by the switch every worker is 0.3.11 (rollout order).

Refusals: pf_load refuses a generator that is not 2 or 3 (error 0 pack <dir>: program pack generator N is not a generator version this worker runs (2 or 3), the exit-44 path of 05ef0fa3: the miner rebuilds the pack before the restart). A job of class v3 against a resident v2 pack of the same seeds answers need <e> <d> and error <id> pack <dir>: program class mismatch: this pack is class v2, the job names class v3 (export the pack again); the miner's need handling prepares the pair again, write_pack_checked writes a v3 pack (checked with verify_pack_dir_chain before the worker hears of it), and the prepared v3 pair wins over the resident v2 pair because the pair identity now carries the class. The Metal worker answers error <id> program class v3 is not implemented by this worker until the Swift generator carries version 3.

5. The fast-time gate (rollout G4)

node infra/fast-time/class-v3.mjs [--secs 420] [--activation 150] [--epochs-after 2] under tools/lock/with-lock.sh run: three nodes on override-60x.json merged with genesis_bits 0x1f010000 (2^16 hashes per block, the CPU difficulty of sim/difficulty/testnet_v2.py) and program_class_v3_activation_daa 150 (inside epoch 2, so the rounding rule is exercised: the first v3 epoch is 3 at DAA 180); one real CPU miner per node (--engine igneum-pow, 1 thread, real lottery-hash solutions, every node verifying the other two); the run ends two epochs after the boundary.

Result, run 1 (5 October 2026, 21:33:04Z to 21:38:02Z, Apple M5 Max shared with other agents' builds)

Binaries: fork ca2-v3-node 79bd8e10 and igneum-pow at ca2-v3 66eeba3 (the mixer-x4 class, V3_CLASS = MX4, before the era and hot-table fields), target-ca2/release, built on the Mac under the build lock. Summary: docs/plans/counter-asic-2-gate/class-v3-20261005-2133Z-mx4.json. PASS: every check true.

Check Measured
Switch line on every node 3 of 3: Program class v3 from the override file: active from epoch 3 (DAA score 150 rounded up to the epoch boundary at 180, epochs of 60 DAA)
Digest, all three nodes 0186df7d0834d054... (the 60x profile with the CPU bits and the switch)
Template class per epoch epochs 0 to 2 class 2 (nextProgramClass 3 from epoch 2), epochs 3 to 5 class 3; the switch seen at DAA 180, 168.0 s wall
Blocks before / after the boundary (node 0's DAG) 181 / 124 (selected chain 176 / 123), 305 in all
Program id per epoch, all three miners agreeing e0 v2 8f8806638d59850f, e1 v2 fd9562df32a68313, e2 v2 1ae6d90ab299154c, e3 v3 5d0dedd9fd9e29a1, e4 v3 e81808dcdb02ce05, e5 v3 06aff9c1d33e7a13; no v2 id reappears under v3
Rejected blocks miners 0 / 0 / 0 (97, 103, 104 accepted); nodes 0 / 0 / 0 PoW rejected lines
Forks sinks 082fd39ba65df2ff on all three nodes, block counts 304 / 304 / 304, one tip each
Program and cache ready, one CPU core a new (day, class) cache: v2 epoch 0 219 to 235 ms, the first v3 epoch 177 to 185 ms (two per miner: the 24-minute day rolled at DAA 190); a program swap inside a day 2 ms

The mixer x4 build-time number the rollout asks for is not visible here: the CPU miner derives dataset words on demand from the cache (no dataset build), so the x4 cost lands on the GPU workers' dataset build, measured by the ca2-mixer playbooks on the PCs.

Result, run 2 (5 October 2026, 21:46:36Z to 21:51:31Z): the composed class

Binaries rebuilt on ca2-v3 b105a55 (era layout merged on the mixer: V3_CLASS = MX4 with the era drawn inside generate_from_seed_bytes_program_class, hot None), fork 79bd8e10 unchanged. Summary: docs/plans/counter-asic-2-gate/class-v3-20261005-2146Z-era-mx4.json. PASS: every check true.

Check Measured
Switch lines, digest 3 of 3, the same line as run 1; digest 0186df7d0834d054...
Template class per epoch epochs 0 to 2 class 2, 3 to 5 class 3; the switch at DAA 180, 171.0 s wall
Blocks before / after the boundary 181 / 124 (selected chain 180 / 122), 305 in all; 102, 102, 100 accepted per miner
Program id per epoch, all three miners agreeing e0 v2 8f8806638d59850f, e1 v2 bb8dd9ddbf9eb63f, e2 v2 8ee7a9f33d418e48, e3 v3 2d278041ba482dba, e4 v3 2ae786d294a8a59d, e5 v3 bc36813df2f41b5f
Rejected blocks miners 0 / 0 / 0, nodes 0 / 0 / 0
Forks sinks 712c1b212091dcdc on all three nodes, block counts 303 / 303 / 303, one tip each
Program and cache ready, one CPU core v2 epoch 0 178 ms, the first v3 epoch 181 ms, an in-day swap 2 ms

CPU hash rate across the switch, run 2, miner cpu0 (one thread, the Mac shared with other agents' builds, so approximate): the cumulative rate read 0.024 MH/s through the v2 epochs (30 to 151 s), then fell to 0.021 MH/s cumulative by 271 s (100 s under v3), which puts the v3 interval rate near 0.017 MH/s, about 30 percent under v2 on the CPU interpreter (the era's strided windowed loads and the mixer path). The node has no per-block verify timing line; the era agent's measurement of the CPU verifier (igneum-pow, 0.604 ms per warp on readwidth against 1.332 ms on 88dafbc, the mixer's derive_items path at m = 1) is the number to fix before the publish, and the before/after goes here when the mixer agent's commit lands.

Epoch 0's v2 id is the same in both runs (8f8806638d59850f: a v2 program is untouched by the era code, on the chain as in the packs); the v3 ids differ from run 1 because the era draw is now inside the class.

The PC 2 suite jobs for the same fork (79bd8e10), the G6 evidence (the coordinator's status file carries the SUMMARY lines):

Job Tree What Result
build-20261005-215219 main b105a55 the Linux node, the six node suites, the app tests Linux build and app tests passed; kaspa-consensus failed on the known flake (ban_is_decided_by_the_carrying_block, UnexpectedDifficulty in mine_on_all, the 0.3.10 cut's section 11 case)
build-20261005-215712 main b105a55 kaspa-consensus alone 97 passed, 0 failed, 3 ignored, ban_is_decided ... ok, 21:59:45Z
build-20261005-220351 main 8ea6740 the other five crates (kaspa-consensus-core igneum-exec kaspa-pow igneum-miner kaspa-p2p-flows) and the app tests published 22:03:51Z, SUMMARY pending

6. Tests

Where What State
igneum-pow cargo test --release 42 unit + 11 pack tests, including program_classes, program_class_and_era_are_checked, the pinned-pack diffs pass (Mac, 5 Oct 2026)
proto-cuda/nvrtc/emu/packfile-test.sh 13 checks: the attempt rule, the generator rule, a v3 pack with its era, the token matcher pass (Mac)
host.c, worker.cpp (emulation), main.swift syntax / compile pass (Mac)
fork cargo test -p kaspa-pow --features igneum-pow 14 engine tests including program_class_v3_seeds_hash_their_own_program_over_their_own_cache pass (Mac, fork 79bd8e10)
fork cargo test -p kaspa-consensus-core 108 + 7: the switch rounding, the era clock, the params, digest and fast-time file tests pass (Mac, fork 79bd8e10)
PC 2 build-job.mjs suites kaspa-consensus-core igneum-exec kaspa-pow kaspa-consensus igneum-miner + igneum-app the coordinator publishes on its go

7. Unverified, and what is owed

  • The era walk for era >= 1 has never run (the devnet is 180 days from era 1); a pruning-proof sync past era 0 fails with MissingEraSeed until an era witness exists in the proof format.
  • The Metal worker has no class v3 generator (it regenerates from the seed in Swift): the integration branch adds generator 3 there, or the Mac mines v3 with the OpenCL worker from a pack.
  • The GPU workers' class refusal was checked by the C test of pf_pack_class_ok and the syntax of both hosts, not by a live worker on a v3 pack: the integration's bit-exact gate (G1) is where a real worker first builds a v3 pack.
  • next_pair keeps the current era seed for the next epoch; an epoch boundary that is also an era boundary (once per 180 days) would prepare the wrong era, and the job line then names the right one, so the worker refuses the prepared pair and the miner prepares again (one wasted compile, no wrong block). The VDF era seed replaces the stand-in before this matters.
  • Done 22:05Z: ca2-cache rebased as 1950661 fast-forwarded (the first attempt, 2de19e5 on 464d6e1, conflicted in 8 files and was aborted); igneum-pow 53 + 19 tests and the packfile test pass; the fork's kaspa-pow, miner and kaspad check clean against the merged crate (seam unchanged). V3_CLASS = { era: None, hot: None, ..LoadClass::MX4 }.
  • Owed: the mixer agent's verifier fix (the 2.2x on the v2 path at m = 1). ca2-mixer's tip 54bbfcc (22:10Z) carries the MX8 candidate, tests/mixer.rs, tests/scratch.rs and the measure docs, not the fix; a merge of it into ca2-v3 conflicts in docs/bench-log.md and proto-metal/packbench.swift only (generator.rs and verify.rs auto-merge). Merged when the fix commit arrives, then a rebuild and gate run 3 if asked.
  • Owed (0.3.12, coordinator's ask of 5 October 2026 22:20Z): per-day dataset reuse in the CUDA and OpenCL workers (a Day object shared by consecutive pairs, the cache freed after the build); the Metal worker already keys datasets by day. Until then the iGPU tier mines v3 with a dataset rebuild per epoch on those two workers.
  • Wire compatibility: RpcPowEpochInfo gained five fields in its Borsh form (wRPC) and five proto fields (gRPC); the gRPC side reads an old node's zeros as v2 / never / none; the Borsh form is versioned by GetBlockTemplateResponse (version 2 carries the whole struct), so a 0.3.11 wRPC client against a 0.3.10 node reads short: the miner uses gRPC, the console reads JSON (serde defaults).