igneum/docs/fork-divergence.md
igneum-labs 51d61f0dca Finality v2: fork reading guide, spec implementation notes, bench entry, observer checkpoints, live page locks
- docs/fork-divergence.md: "Finality v2" table (every file, risk, merge note), decisions
- docs/spec/03-finality.md: section 3.10 implementation notes, clause by clause
- docs/bench-log.md: test-network results (72 of 72 steady locks, median 0.80 s; equivocation
  strip; partition: 0 locks at 39.6% of total with the floor binding, heal in 30 s), follower
- tools/observer: live_checkpoints table, FinalityLock subscription, "checkpoint N locked" events
- site: /api/live adds checkpoints and locked/final flags; /live draws the lock ring and final line

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 21:54:24 +00:00

51 KiB

Igneum fork divergence from rusty-kaspa

Fork: vendor/igneum-node, a git clone of vendor/rusty-kaspa at v2.1.0 commit 01b532e8 (22 Sep 2026). Every Igneum change is a commit on top of that base, one per subject, so git log 01b532e8..HEAD in the fork is the full list. This file is the reading guide: what each change touched, why, how risky it is, and what to do when upstream moves. docs/fork-map.md is the plan this implements; row ids there (a1 to f2) are cited below.

Status: devnet v2, sustained-mining finality rule v2 on the real header-bound lottery hash (3 Oct 2026, the same night as v1): BLS12-381 vote keys, weight by blue blocks, 30-blue-score checkpoints, votes and certificates carried in blocks and gossiped on p2p, quorum and floor, locked checkpoints bounding fork choice, equivocation bans (see "Finality v2" below). The node binary is igneumd since the rename pass of 3 Oct 2026 (see "The rename" below). VDF seeds, the zkEVM and the proving layer are not in the fork yet.

The table

File (vendor/igneum-node/) What changed Why Risk Upstream-merge note
consensus/core/src/header.rs, consensus/core/src/hashing/header.rs Header gains vote_key_hash: Hash (32 bytes) as the last field; new_finalized takes it; hash_override_nonce_time writes it after pruning_point so the header hash and the PoW commit to it Finality rule v2: vote weight is blue blocks per BLS vote key. The hash of the key rides in every header now so the weight table can be built from headers alone later (fork-map d) Low by depth, high by spread: every header hash and every genesis hash moved Any upstream change to Header or to the hashing order conflicts here. Re-derive the four genesis hashes after merging (consensus/core/src/config/genesis.rs tests print them).
consensus/core/src/config/genesis.rs All four genesis hashes recomputed; devnet genesis has payload igneum-devnet, timestamp 2026-10-03T00:00Z, nonce 0; bits 0x1d100000 (2^28 expected hashes per block, hash edc4fa84...fb07) for the GPU devnet (RTX 5090 229 MH/s + M5 Max 45 MH/s + gfx1036 4 MH/s measured, about 1.04 blocks/s); earlier the same day 0x1e400000 (2^18) for three 6-thread CPU miners on the real hash (0.294 MH/s: 825 blocks in 641 s) and 0x1e020000 (2^23) for the kHeavyHash stub A devnet the miners at hand hold near 1 block per second until the DAA takes over at 600 blocks Low Mainnet, testnet and simnet genesis blocks are still Kaspa's content with new hashes. Replace them with Igneum genesis blocks before any public network. Every bits change moves the devnet genesis hash (print it with the ignored test).
consensus/core/src/errors/block.rs, consensus/src/pipeline/header_processor/pre_ghostdag_validation.rs RuleError::MissingVoteKeyHash; check_vote_key_hash_present rejects an all-zero vote_key_hash Nodes only check presence until the finality layer exists Low Keep. The presence check becomes a key-registry check later.
protocol/p2p/proto/p2p.proto, protocol/p2p/src/convert/{header,block,messages}.rs, protocol/flows/src/v10/request_headers.rs BlockHeader wire message gains Hash voteKeyHash = 15; converters copy it both ways p2p round trip of the field Low Field number 15 must stay unique if upstream adds header fields. Protocol version is still Kaspa's 11; bump it when the fork gets its own peers.
rpc/grpc/core/proto/rpc.proto, rpc/core/src/model/header.rs, rpc/core/src/model/optional/header.rs, rpc/core/src/model/verbosity.rs, rpc/core/src/convert/verbosity.rs, rpc/grpc/core/src/convert/{header,optional/header}.rs, rpc/service/src/converter/consensus.rs, consensus/client/src/header.rs RpcHeader and RpcOptionalHeader carry vote_key_hash; gRPC proto fields (string voteKeyHash = 16 on the header, optional string voteKeyHash = 15 on the optional header) and converters; wasm client header RPC round trip, template to miner and block back Low Mechanical. Borsh wire for wRPC changed shape: old wRPC clients cannot decode headers.
consensus/src/model/stores/headers.rs, consensus/src/test_helpers.rs, mining/src/testutils/consensus_mock.rs, mining/src/template_limits_tests.rs, consensus/src/pipeline/virtual_processor/processor.rs, consensus/src/pipeline/body_processor/body_validation_in_isolation.rs Header store serde includes the field; template builder passes the field through; tests construct it Storage and mining paths Low Database format changed: a node from before this commit must resync from scratch.
consensus/core/src/network.rs Network name prefix igneum- (was kaspa-) for every network id (igneum-mainnet, igneum-testnet-10, igneum-devnet, igneum-simnet); devnet ports gRPC 26610, wRPC borsh 27610, wRPC json 28610, P2P 26611 (Kaspa devnet: 166xx to 186xx); error text The prefixed name is the p2p handshake magic (FlowContext::handshake rejects a Version.network mismatch), so igneum-devnet never completes a handshake with kaspa-devnet. Distinct ports stop a Kaspa node on the same host from being dialled by mistake Low Mainnet, testnet and simnet ports are still Kaspa's. Change them before any public network.
consensus/core/src/config/params.rs MAINNET_PARAMS.dns_seeders and TESTNET_PARAMS.dns_seeders emptied (upstream: nine Kaspa mainnet seeders, three testnet seeders) An Igneum node started with --testnet or no network flag must never dial Kaspa seeders Low Fill with Igneum seeders before any public network. Devnet and simnet were already empty.
crypto/addresses/src/lib.rs, crypto/txscript/src/standard.rs (test vectors), bridge/src/default_client.rs, bridge/src/share_handler.rs, bridge/src/tests.rs Address prefixes igneum (mainnet), igneumtest, igneumsim, igneumdev (upstream kaspa, kaspatest, kaspasim, kaspadev); devnet first on 3 Oct 2026 v0, the other three in the rename pass later that day No Igneum address string parses as a Kaspa address on any network. The prefix is only part of the string encoding and its checksum; the script public key behind an address is unchanged, so nothing on chain moved Low Any upstream test that spells out a kaspa: address fails here; the vectors in addresses and txscript were regenerated. Wallet, cli and wasm crates (not in the default build) still carry kaspa: strings in their own tests.
consensus/core/src/config/bps.rs, consensus/core/src/config/params.rs OneBps = Bps<1>; DEVNET_PARAMS uses BlockrateParams::new::<1>(): 1,000 ms blocks, GHOSTDAG k 18 (calculate_ghostdag_k(2 x 5 x 1, 0.01)), 10 max parents, mergeset limit 180, merge depth 3,600 blocks, finality depth 43,200 blocks, pruning depth 108,000 blocks, coinbase maturity 100; crescendo_activation: always(); doc table and a test pinning every value 1 block per second at launch (fork-map f1, f2, e1). Finality and pruning depths are upper bounds only: live finality will be the certified checkpoint Low; this is Kaspa mainnet's pre-Crescendo path The ForkedParam and bps_history plumbing is still present for the other networks. Strip it in one pass when mainnet params are written.
consensus/core/src/igneum.rs (new), consensus/core/src/lib.rs, consensus/core/src/constants.rs Emission constants and functions: 1,000,000,000 coins in year one, per-second rate halving every two years (SUBSIDY_PER_SECOND_BY_PERIOD[i] = 3,168,808,781 >> i, 33 periods), block_subsidy(daa_score, bps), 30-day linear launch ramp from 10%, proving_pool_share 20% and producer_share 80%, proving_pool_script_public_key (OP_RETURN tagged igneum-proving-pool-v0), POW_EPOCH_BLOCKS = 3,600, POW_EPOCH_LEAD = 600, pow_epoch_blocks(), pow_epoch_lead(), pow_epoch_seed_score(epoch) (env overrides for test networks), PowEpochInfo The design's schedule, hard cap 4,000,000,000 (the geometric series sums to it; rounding leaves under 100 coins unminted), no emission treasury (fork-map b1, b2) Medium: consensus money Pure addition. Keep as the one source of the schedule.
consensus/src/processes/coinbase.rs Kaspa's pre-deflationary phase, 426-month table and Crescendo rescaling removed; CoinbaseManager::new(max_spk_len, max_payload_len, bps); calc_block_subsidy reads the period table; expected_coinbase_transaction pays 80% plus fees per rewarded block to its declared script and pools 20% of every subsidy (blues and reds) into one output to the proving pool script, placed after the blue outputs and before any red reward; tests rewritten 80/20 split in consensus; the 20% is burned on devnet v0 and becomes the prover payout when the proving layer records prover sets (fork-map b3) High: changes what every node accepts as a valid coinbase Upstream edits to coinbase.rs will conflict. The payload format is unchanged (full subsidy in the payload, split derived from it), so Kaspa's payload parsing merges cleanly.
consensus/src/consensus/services.rs, consensus/src/consensus/test_consensus.rs, consensus/src/pipeline/body_processor/body_validation_in_context.rs, consensus/src/processes/parents_builder.rs, consensus/src/processes/transaction_validator/tx_validation_in_isolation.rs Call sites of the new CoinbaseManager constructor; subsidy expectations in tests use igneum::block_subsidy Wiring Low Mechanical.
consensus/pow/src/igneum.rs (new), consensus/pow/src/lib.rs, consensus/pow/Cargo.toml, Cargo.lock PowEngine trait (check_header(header, &EpochSeeds) -> (passed, pow)), HeavyHashEngine stub (default), IgneumEngine behind feature igneum-pow calling the igneum-pow crate in its header-bound form (Epoch::from_seed_bytes, Epoch::pow_bound): H = header hash with the nonce zeroed (timestamp kept), init words seed_words_from_bytes("igneum-block/" || H || nonce_hi_le32), lane nonce = low 32 bits, pow value = lane hash in the top 64 bits with zero low bits; epoch seed bytes = the epoch block hash, day seed bytes = "igneum-day/" || day_le64; caches three (epoch seed, day seed) entries of program plus 256 MiB cache and exposes them to the miner (epoch_for, header_prehash, target64); igneum-pow as an optional path dependency ../../../../igneum-pow; doc note on the existing calc_block_level (pruning proofs still use the stub for block levels) The hash swap behind a trait (fork-map a1 to a3); the binding closes the "lane hash does not absorb the header" gap of v0 (spec 01 O-1.9) Medium Pure addition in the pow crate. State (kHeavyHash) is untouched, so upstream pow changes merge. The path dependency must become a workspace or git dependency when the fork gets its own repository.
consensus/core/src/api/mod.rs, consensus/src/consensus/mod.rs, components/consensusmanager/src/session.rs, rpc/core/src/model/message.rs, rpc/grpc/core/proto/rpc.proto, rpc/grpc/core/src/convert/message.rs, rpc/service/src/service.rs, kaspad/src/args.rs ConsensusApi::get_pow_epoch_info (and the session wrapper); GetBlockTemplateResponse.pow_epoch: Option<RpcPowEpochInfo> (borsh version 2, gRPC RpcPowEpochInfo powEpoch = 4); IGNEUM_DEVNET_GENESIS_BITS test override in apply_to_config The block template tells the miner the current and next epoch seeds, so GPU workers prepare the next program one lead ahead (hot swap, 3 Oct 2026) Low Additive. An old wRPC client reads version 2 as its own fields plus a trailing option; gRPC clients ignore the new field.
consensus/src/pipeline/header_processor/processor.rs, consensus/src/pipeline/header_processor/pre_ghostdag_validation.rs PoW check moved from validate_header_in_isolation to after GHOSTDAG (check_pow_and_calc_block_level(header, selected_parent)); epoch_seed walks the selected-parent chain to the last block below epoch x EPOCH - LEAD (genesis for epoch 0) with a memo (the lead since the hot-swap work of 3 Oct 2026; Consensus::get_pow_epoch_info walks the same way from the sink for the block template); pow_engine: Arc<dyn PowEngine> on the processor; one info line per accepted or rejected PoW naming the engine (PoW accepted <hash> by igneum-lottery-v1-bound ...) The epoch seed is chain state, so PoW cannot be checked in isolation any more (fork-map a4). Temporary seed rule until the 10-minute VDF over a certified checkpoint exists High: a header now reaches GHOSTDAG before its PoW is checked, so an attacker can make a node run GHOSTDAG on headers with bad nonces (bounded by the per-peer header rate; the stub engine ignores the seeds so devnet v0 is not exposed) Upstream rarely touches this ordering, but any refactor of process_header conflicts. Pruning-proof validation (processes/pruning_proof/validate.rs) still uses the stub for block levels; thread seeds through it before enabling the real engine on a pruning network.
consensus/Cargo.toml, kaspad/Cargo.toml Feature igneum-pow forwarded (kaspad -> kaspa-consensus -> kaspa-pow) cargo build -p kaspad --features igneum-pow selects the real engine Low Keep.
consensus/core/src/config/constants.rs Comment block only: the DAA constants kept at 1 BPS (sample every 4 blocks, 661 samples, 2,644-block window, min window 150 samples) and the two timestamp rules kept (132 s future tolerance in isolation, strictly above the sampled past median time of 27 samples in context) Difficulty step verified rather than changed (fork-map c1, c2); the known gap (per-epoch hash-speed step vs a 44-minute window) is recorded there None Comment only.
igneum/miner/ (new crate igneum-miner), Cargo.toml (workspace member), Cargo.lock Devnet miner: --engine stub (kHeavyHash, v0) or --engine igneum-pow (CPU warps through the node's own IgneumEngine cache, 64-bit nonces, random start per thread), --worker <exe> (GPU serve protocol: job/found/done lines, CPU re-check of every found nonce, one submit per template, --exit-on-seed-change exits 42 for ahead-of-time workers, --worker-args, --status-secs), export-pack (the pack for the node's current epoch and day plus seeds.txt), bad-nonce (submits an unmined nonce, expects a rejection); the epoch seed is derived as the node does it, walking from the sink over gRPC; watch and inspect as in v0 Kaspa ships no miner; the devnet needs one that follows the fork's own PoW crate, and the GPU workers need a driver None to consensus Internal tool. Depends on igneum-pow by path and on kaspa-pow with the igneum-pow feature. Cross-compiles for x86_64-pc-windows-gnu with mingw-w64 (no rocksdb in its closure).

Finality v2 (3 Oct 2026, night): what landed and where

The rule is docs/spec/03-finality.md (implementation notes in its section 3.10). The crypto and the wire types are in kaspa-consensus-core, the processing in kaspa-consensus, the RPC in the usual six layers, the p2p relay in kaspa-p2p-flows. Nothing here touches the lottery hash or the coinbase amounts.

File (vendor/igneum-node/) What changed Why Risk Upstream-merge note
Cargo.toml, consensus/core/Cargo.toml, crypto/hashes/src/hashers.rs Workspace dependency blst = "0.3.17" (BLS12-381, minimal-pubkey setting: G1 keys 48 bytes, G2 signatures 96 bytes), sha2 in consensus-core; new BLAKE2b domain VoteKeyHash => "IgneumVoteKeyHash" The vote key, its hash in the header (W1) and the sortition VRF output (S1) Low Pure additions. blst compiles with mingw for the Windows miner.
consensus/core/src/finality.rs (new), consensus/core/src/lib.rs FinalityParams (MAINNET and DEVNET constants, Q3 thresholds 2/3 and 17/30 as integers), VoteSecretKey::from_label (ikm = BLAKE2b("igneum-vote-key-ikm-v1" || label), standard BLS KeyGen, info "igneum"), vote_key_hash(pubkey), proof of possession, Vote (index, checkpoint, pubkey, signature, sortition proof; 280 bytes), Certificate (index, checkpoint, voter count, signer bitmap over the canonical voter list, aggregate signature, aggregator key hash and VRF proof), Evidence (two votes), KeyReveal (IGNK + 288 hex chars of pubkey || pop in the miner's extra data), the finality section codec at the end of the coinbase extra data (items || len_le32 || "IGNF"), domain tags IGNEUM_VOTE_V1_..., IGNEUM_POP_V1_..., IGNEUM_SORTITION_V1_..., vote message "igneum-vote-v1/" || chain_id || 0 || index_le64 || checkpoint; VRF = SHA-256 of the sortition signature, aggregator when output x voters < aggregators x 2^64; report types for the API Everything two nodes must agree on byte for byte Medium: consensus data formats Pure addition. The DSTs and the message layout are frozen by the first public network.
consensus/core/src/config/params.rs Params.finality: FinalityParams (mainnet and testnet MAINNET; devnet and simnet DEVNET: interval 30, depth 20, window 7,200 DAA s, dust 5, presence 20 indices, 8 aggregators, ban 7,200 DAA s, min DAA 0); devnet max_coinbase_payload_len 16,384 (MAX_COINBASE_PAYLOAD_LEN_WITH_FINALITY); OverrideParams.genesis_bits (genesis hash recomputed from the header) and OverrideParams.finality; a suffixed devnet (NetworkId::with_suffix(Devnet, n)) keeps the devnet rules under its own net The rule's parameters as network parameters; votes and certificates ride in the coinbase extra data, so the 204-byte Kaspa cap had to go on devnet; a private test network needs a low genesis difficulty and its own handshake magic Medium: a node with the old 204 cap rejects every block that carries a finality section (cut-over note below) Mainnet keeps 204 until its own value is set; a mainnet block with a certificate needs about 300 bytes plus 281 per vote.
database/src/registry.rs, consensus/src/processes/finality.rs (new), consensus/src/processes/mod.rs Prefix IgneumFinality = 70; FinalityManager: key registry (coinbase reveals with a valid proof of possession whose hash matches the header, and votes, which carry the key); weight table at a checkpoint (walk the selected chain of C through every chain block's mergeset_blues, count headers by vote_key_hash with DAA score in (daa(C) - window, daa(C)], stop when the chain block's DAA score is a merge depth below the window), canonical voter list (above dust, not stripped, sorted by key hash); checkpoint determination (lowest selected-chain block with blue score >= 30 i, once the sink's blue score reaches 30 i + d; never revisited); votes with carriers (every block that carries the vote), the F3 block reading of participation (indices in [i - P, i - 1] with a carrier in the past of C_i, keys younger than P count 1); sortition from the vote's proof; certificate aggregation as soon as the votes seen meet Q3 (anyone MAY aggregate; a local eligible voter is named, else a zero aggregator), certificate verification (bitmap positions, fast_aggregate_verify, aggregator proof); lock test 3 x signed x P >= 2 x active_num and 30 x signed >= 17 x total; locks, FinalityLock notification; equivocation (second vote by a key at an index for another hash: evidence, weight zero until detection DAA + ban, evidence carried in blocks); template section (certificates and evidence not in the template's past, then up to 48 votes not in its past); state persisted as one bincode blob once a second Spec 03 sections 3.1 to 3.6 at devnet scale High: new consensus-adjacent state; the weight walk is O(window) per checkpoint (fine at 7,200, not at 2.6 million: mainnet needs an incremental window) Pure addition. The manager holds its own lock and never takes it twice (the first run deadlocked the virtual processor through compute_weights).
consensus/src/consensus/services.rs, consensus/src/consensus/mod.rs, consensus/core/src/api/mod.rs, components/consensusmanager/src/session.rs ConsensusServices.finality; ConsensusApi gains finality_weights_report, finality_checkpoints_report, finality_submit_vote, finality_submit_certificate, finality_template_section, finality_take_gossip (default unimplemented!(), so mocks are untouched); async forwarders on the session Reach the manager from RPC and p2p Low Mechanical.
consensus/src/pipeline/body_processor/processor.rs, consensus/src/pipeline/virtual_processor/processor.rs After commit_body, finality.on_block_body(block) reads the coinbase (reveal, votes, certificates, evidence) of every block, blue or red; in resolve_virtual, finality.fork_choice_lock(depth_finality_point, tips) returns the highest locked checkpoint in the future of the depth-based finality point that some body tip passes through, and it replaces the finality point for tip filtering and the sink search (F1 to F3); after the virtual state is committed, finality.on_virtual_changed(sink) determines new checkpoints and re-evaluates the open ones The hooks of spec 03 Medium: the virtual processor thread now runs the finality evaluation (under 10 ms at devnet scale); a lock no tip passes through logs a warning and leaves fork choice to depth finality resolve_virtual is the conflict point with upstream; the two hooks are three lines.
rpc/core/src/model/finality.rs (new), rpc/core/src/model/{mod,message}.rs, rpc/core/src/api/{rpc,ops,notifications}.rs, rpc/core/src/convert/{notification,scope}.rs, rpc/grpc/core/proto/{rpc,messages}.proto, rpc/grpc/core/src/{ops.rs,convert/{kaspad,message,notification}.rs,ext/kaspad.rs}, rpc/grpc/server/src/request_handler/factory.rs, rpc/grpc/client/src/lib.rs, rpc/wrpc/{server/src/router,client/src/client}.rs, rpc/service/src/service.rs Methods getFinalityWeights (params, latest checkpoint, total and active weight, voters, per key: hash, pubkey hex, blocks, voter, participation, stripped-until DAA), getFinalityCheckpoints (last N: index, hash, blue score, DAA, state proposed/certified/locked, signed/active/total weight, fractions, votes seen, voters, aggregators, locked-at DAA, certificate aggregator), submitFinalityVote (hex of the vote bytes; accepted, reason, equivocation); notification FinalityLock (the lock event: index, hash, blue score, DAA, weights, fractions, voters) with NotifyFinalityLock; ops 154 to 156, 19 and 69; gRPC message numbers 1120 to 1128; getBlockTemplate appends the node's finality section after the miner's extra data Miners vote and read checkpoints over RPC; the observer and the site read locks Low Every layer follows the GetSinkBlueScore pattern; the wasm client is not touched (not in the default build).
notify/src/{events,scope}.rs, consensus/notify/src/notification.rs EventType::FinalityLock (EVENT_COUNT 10), FinalityLockScope, consensus FinalityLockNotification The lock event through the notification system Low Mechanical.
protocol/p2p/proto/{messages,p2p}.proto, protocol/p2p/src/core/{payload_type,hub}.rs, protocol/flows/src/{lib,flow_context,service}.rs, protocol/flows/src/v12/mod.rs (new), protocol/flows/src/v10/{mod,finality}.rs IgneumFinalityMessage { kind (1 vote, 2 certificate), payload bytes } as payload 70; PROTOCOL_VERSION 12 (a peer at 12 or later gets the v11 flows plus FinalityRelayFlow; 11 and 10 still peer and never receive finality messages); Hub::broadcast_where; FlowContext::broadcast_finality (peers with advertised protocol version >= 12 only); a 250 ms pump in P2pService::start drains finality_take_gossip (votes received over RPC, certificates built or relayed) to those peers; the relay flow verifies through consensus and leaves re-broadcast to the pump Votes and certificates as their own p2p message (C2, C3) without disconnecting pre-v2 peers (an unknown payload is a protocol error in the router) Low Payload number 70 must stay unique; the version gate is the merge-safe way to add a message.
kaspad/src/args.rs --devnet-suffix=N (network id igneum-devnet-N, own data directory and handshake magic) A private test network beside the shared devnet on one machine None Pure addition.
igneum/miner/src/main.rs Identity (BLS key from the identity label, vote_key_hash = hash of the key, key reveal in every template's extra data); Voter: once a second reads getFinalityCheckpoints, signs every new unlocked checkpoint (vote plus sortition proof) and submits it, prints VOTE and LOCK lines and a VOTER SUMMARY with lock latency as seen over RPC; --no-vote, --equivocate (also signs a wrong hash at every index; the node strips the key); the genesis hash is discovered from the node (pruning point walked to the parentless block) so a private test network with its own genesis mines correctly The miner is the signer (the node never holds a key) None to consensus The Windows launcher is unchanged: the positional identity label is the key label, so nvidia-<PC>-<i> identities keep working, with new vote_key_hash values (weight restarts under the new hashes).

Devnet compatibility and cut-over: the devnet genesis, network id and ports are unchanged, so a v2 node syncs the chain mined so far. Two things are one-way: (1) a v2 node accepts coinbase payloads up to 16,384 bytes and a v1 node accepts 204, so once a v2 miner votes, the blocks that carry a finality section are invalid to every v1 node; (2) a v2 node's p2p protocol version is 12 and it only sends finality messages to version 12 peers, so a v1 node peers but never sees votes. Cut over every node (node 1, the observer peer) and the Windows package together.

R3 fixes, M15: PoW cost before validation (branch r3-fixes, 3 Oct 2026)

Review round 3 R3.26 (ledger M15): the lottery engine ran before the DAA-score and past-median checks, so a header with any past timestamp or claimed DAA score forced a 256 MiB cache build for an arbitrary day seed and evicted the honest entry (KEEP was 3). Branch r3-fixes (on top of the rename commit d62708a8, not the hot-swap work; the overlap note is below) reorders the checks, caps the builds and bans the peer that forces them. Measured before and after: docs/bench-log.md, the M15 entry (50 bogus headers built 50 caches in 10.6 s before, 0 caches and all rejected in 14 ms after).

File (vendor/igneum-node/) What changed Why Risk Upstream-merge note
consensus/pow/src/igneum.rs, consensus/core/src/errors/block.rs The engine is process-wide (engine(), install_engine); check_header returns a PowCheck with an optional BuildReport; KEEP raised 3 to 4; the chain's current and next day stay resident (note_chain_day, is_live_day); at most one build per seed pair, MAX_INFLIGHT_BUILDS 2 at once, a queue of MAX_QUEUED_BUILDS 4 then PowEngineError::BuildQueueFull; a 1,024-entry ring of per-header build reports; new RuleError::PowCacheQueueFull Bound what a stream of headers can make a node build when the real engine is on (M15 rules 1 to 4) Medium: changes the engine API (check_header signature and return) The PowEngine trait signature moved; the stub still builds nothing. Pure additions to igneum-pow usage; the crate itself is unchanged.
consensus/src/pipeline/header_processor/processor.rs, .../pre_ghostdag_validation.rs, .../post_pow_validation.rs validate_header runs isolation, parents, GHOSTDAG, pre_pow_validation (pruning, DAA, difficulty), pre_pow_context_checks (blue score, blue work, past median), then the engine, then the rest of post_pow_validation (mergeset size, merge depth, indirect parents); the day seed uses the validated timestamp; note_chain_day fed from the headers-selected tip; one log line per cold build; the processor takes engine() The cheap checks and the checks that choose the seeds run before the engine (M15), so a bad header never reaches a build; identical consensus result for a valid header High: the header validation order moved The conflict point with upstream and with any branch that edits validate_header (the hot-swap epoch_seed lead and get_pow_epoch_info live in the same two files on master). Merge by keeping the reordered body and the hot-swap epoch_seed; both call check_pow_and_calc_block_level last.
components/addressmanager/src/lib.rs, components/connectionmanager/src/lib.rs ban_for(ip, duration) on both; MAX_BANNED_TIME made public A one-hour ban rather than Kaspa's full day Low Pure additions beside the existing ban.
protocol/flows/src/flowcontext/pow_guard.rs (new), .../flowcontext/mod.rs, .../flow_context.rs, .../v10/blockrelay/flow.rs, .../ibd/flow.rs, protocol/flows/Cargo.toml, Cargo.lock PowGuard counts an off-day cold build or a pre-PoW rejection per IP; FlowContext::charge_pow_strikes reads the engine's build report and the rule error; the relay and IBD header-join paths call it; more than IGNEUM_POW_STRIKES (default 2) in an hour disconnects and bans for an hour; flows depends on kaspa-pow Per-peer accounting (M15 rule 3) Low: additive; flow_context.rs and Cargo.toml overlap the finality-v2 additions New file plus small hooks. flow_context.rs conflicts textually with the finality-v2 FlowContext additions; both are field and method additions, no shared lines.

Tests: kaspa-pow --features igneum-pow cap tests (one_build_per_seed_pair_under_contention, live_days_survive_off_day_builds, build_queue_is_bounded), kaspa-consensus cheap_checks_run_before_the_pow_engine, kaspa-p2p-flows pow_guard, and the ignored measurement measure_m15_attack_before_and_after.

Merge note for the finality branch (which files overlap): consensus/core/src/errors/block.rs (both add a RuleError variant), consensus/src/pipeline/header_processor/processor.rs (finality adds no code here but the hot-swap branch does; the reorder is the conflict), protocol/flows/src/flow_context.rs and protocol/flows/Cargo.toml (both add to FlowContext and the manifest). No shared lines in any of them; each is additive on both sides.

The rename (3 Oct 2026): what a miner, a user or an operating system sees

Trigger: macOS asked the project lead whether "kaspad" may access the local network. Everything visible from outside the source tree now says Igneum. Internal crate names, module paths, protobuf packages and Rust identifiers keep their upstream names (next table) so git merge against rusty-kaspa stays mechanical.

Surface Before After Where
Node binary, process name, macOS and firewall prompts kaspad igneumd kaspad/Cargo.toml: package still kaspad, autobins = false, [[bin]] name = "igneumd"; cargo build --release -p kaspad --features igneum-pow writes target/release/igneumd
p2p user agent (the Version handshake message) /kaspad:2.1.0/ /igneumd:2.1.0/ core/src/kaspad_env.rs name(); used by protocol/p2p/src/convert/model/version.rs and protocol/flows/src/flow_context.rs. The handshake also checks network (igneum-devnet) and protocol version (11, unchanged), so an old kaspad-named Igneum node and igneumd still peer
CLI name, help, --version kaspad, "Kaspa full node daemon (rusty-kaspa) v2.1.0" igneumd, "Igneum full node daemon (igneumd) v2.1.0" kaspad/src/args.rs, kaspad/Cargo.toml description
Network flags --devnet "Use the Igneum development network", --testnet "Use the test network" --devnet (alias --igneum-devnet), --testnet and --simnet say Igneum kaspad/src/args.rs
Environment variables KASPAD_APPDIR, KASPAD_RPCLISTEN, ... (40) IGNEUMD_APPDIR, IGNEUMD_RPCLISTEN, ... (same suffixes) kaspad/src/args.rs
Default data directory ~/.rusty-kaspa (macOS, Linux), %LOCALAPPDATA%\rusty-kaspa (Windows) ~/.igneum, %LOCALAPPDATA%\igneum kaspad/src/daemon.rs get_app_dir. Upstream never used Application Support on macOS; the Go-era help text in the trailing comment of args.rs that mentions it is dead text
Log file names rusty-kaspa.log, rusty-kaspa_err.log igneumd.log, igneumd_err.log core/src/log/consts.rs
Startup banner kaspad v2.1.0-<hash> igneumd/2.1.0-<hash> kaspad/src/daemon.rs
Shutdown, FD-limit, DB-version and confirmation messages "Kaspad has stopped", "The kaspad node requires", "Kaspad DB version", "pass --yes to the Kaspad command line" igneumd in each kaspad/src/main.rs, kaspad/src/daemon.rs
Heap profile file (feature heap) kaspad-heap.json igneumd-heap.json kaspad/src/main.rs
UPnP port-mapping description shown by routers rusty-kaspa igneum components/addressmanager/src/lib.rs
Temp directory for database tests and tools <tmp>/rusty-kaspa <tmp>/igneum database/src/utils.rs
p2p and gRPC error text "received kaspad p2p message", "Kaspad gRPC message" igneumd protocol/p2p/src/core/router.rs, rpc/grpc/server/src/connection.rs
Address prefixes kaspa, kaspatest, kaspasim, igneumdev igneum, igneumtest, igneumsim, igneumdev crypto/addresses/src/lib.rs; the stratum bridge accepts and defaults to the Igneum prefixes
DNS seeders nine Kaspa mainnet, three Kaspa testnet hostnames none consensus/core/src/config/params.rs
Default build set cargo build at the root built kaspa-cli, kaspa-wallet, kaspa-wrpc-proxy and the wasm bundles too default-members leaves those out; they stay members, so -p kaspa-cli and --workspace still build them Cargo.toml
igneum-miner no user-visible string named kaspad (checked) unchanged igneum/miner/

Kept on purpose, not visible outside the tree, so upstream merges stay clean:

Stays Kaspa-named Why
Crate names (kaspa-consensus, kaspa-p2p-lib, kaspad the package, kaspad_lib, ...) and every use kaspa_* path Renaming 60 crates touches every file in the tree; each upstream commit would then conflict on its first line
Protobuf packages and messages (protowire, KaspadMessage, KaspadRequest, KaspadResponse, kaspad_message::Payload) The gRPC and p2p wire names are matched by generated code on both sides; changing them is a wire-format fork for no user-visible gain (clients see the port and the methods, not the package name)
The module name kaspad_env in kaspa-core Internal; its name() now returns igneumd
SOMPI_PER_KASPA and the 8-decimal unit constants Open decision (8 or 18 decimals) in the table above; rename with the unit decision
RPC server_version (getInfo) Still the bare 2.1.0, as clients and the observer parse it; the node name is in the p2p user agent and the banner
p2p protocol version 11 Not a name. Bump when the fork takes its own peers
Mainnet, testnet and simnet ports and genesis content Not names. Ports and Igneum genesis blocks for the public networks are a separate pass (see the rows above)
Wallet, cli, wasm crates and the fork's README Not in the default build and not shipped; their kaspa: test strings and docs are untouched

Devnet compatibility: the devnet genesis, consensus rules, ports, network id igneum-devnet and address prefix igneumdev are unchanged, so an igneumd built from this commit syncs the chain mined by the earlier kaspad-named build and the Windows miner keeps submitting to it (it derives igneumdev addresses; a change of the devnet prefix would have broken its templates at cut-over). Verified 3 Oct 2026 20:33 to 20:36 BST: a fourth node from target-rename/release/igneumd (built with CARGO_TARGET_DIR=target-rename cargo build --release -p kaspad -p igneum-miner --features igneum-pow, 2 min 31 s) on gRPC 26630, P2P 26631, appdir /tmp/igneum-rename-test, peered to node 1 at 127.0.0.1:26611, logged igneumd/2.1.0-745d41ef as its first line, completed IBD in under a second (140 headers, 140 blocks, sink 7f4cba28...3aa11, the same sink and DAA score node 1 reported) and node 1 kept running. A fifth node peered to the fourth showed in getConnectedPeerInfo as /igneumd:2.1.0/igneumd:2.1.0/ while node 1 showed as /kaspad:2.1.0/kaspad:2.1.0/ (the doubled agent is upstream behaviour: Version::default and add_user_agent both write it). Note for test nodes: --connect sets the inbound limit to 0 and skips the P2P listener; use --addpeer with --listen when another node must dial in. Cut-over for node 1: stop the old kaspad process, start igneumd --devnet with the same --appdir, --rpclisten and --listen; the database format did not change.

Execution layer (devnet v3, branch execution-layer, 3 Oct 2026)

Implements docs/design/execution-layer.md D1 to D10 and section 8.2 on a 3-node simnet with CPU stub miners. Worktree vendor/igneum-node-exec on branch execution-layer, forked from d62708a8 (the rename commit). Not merged into master yet; the merge plan with the finality branch is in the design document's implementation notes. Status words follow the design document: Implemented means it runs on the test network, prototype means a placeholder the design leaves to measurement.

File (vendor/igneum-node-exec/) What changed Why Risk Upstream-merge note
consensus/core/src/evm.rs (new), consensus/core/src/lib.rs, consensus/core/Cargo.toml (sha3) EvmTransaction = Vec<u8> (raw EIP-2718 bytes), evm_tx_hash (keccak256), evm_chain_id (4461 / 4462 / 4463, simnet shares the devnet id), miner_evm_address (low 20 bytes of vote_key_hash), BLOCK_EXECUTION_GAS_LIMIT 30 M, MAX_EVM_BODY_BYTES 1 MiB, the EvmTemplateSource trait Design D1, D8, 8.1; the devnet rule for the miner address until the body carries a 20-byte field Low Pure addition.
consensus/core/src/block.rs Block and MutableBlock gain evm_transactions (Arc<Vec<EvmTransaction>> / Vec<EvmTransaction>); Block::new keeps its signature (empty EVM list), with_evm_transactions, from_arcs_with_evm; MutableBlock::set_evm_transactions recomputes the merkle root and re-finalizes the header Design D1: EVM transactions in the body Medium by spread: every Block {..} literal in the tree had to name the field (six sites) Upstream additions of Block literals will fail to compile until the field is added; the compiler finds them.
consensus/core/src/merkle.rs calc_block_hash_merkle_root(utxo_txs, evm_txs): leaves are Kaspa's transaction hashes followed by keccak256 of each raw EVM transaction Design D7: hash_merkle_root is reused as the body commitment and now covers the EVM transactions. With no EVM transactions the root equals Kaspa's, so no genesis hash moved Low Pure addition; calc_hash_merkle_root is untouched.
consensus/src/model/stores/block_transactions.rs Stored body is BlockBody(utxo_txs, evm_txs); get_evm, insert_batch and insert take both One store, one write per body Low Database format changed: a node from before this branch must resync. insert has one more argument; upstream call sites conflict trivially.
consensus/src/pipeline/body_processor/processor.rs, body_validation_in_isolation.rs, consensus/core/src/errors/block.rs, consensus/Cargo.toml Body validation checks the new merkle root, rejects any UTXO transaction besides the coinbase (RuleError::UtxoTransactionsRetired), and runs the state-free EVM body rules through igneum_evm_types::validate_body (RuleError::BadEvmBody: decoding, signature, chain id, types 0 to 2 only, intrinsic gas within the limit, no duplicate hash, per sender nonces sorted and contiguous, sum of gas limits within B_e, body bytes within the limit); commit_body writes the EVM part Design D1 (UTXO transaction type retired from bodies), D4, D5 state-free class High: changes what every node accepts as a body Keep. The coinbase stays a UTXO transaction until the UTXO layer is stripped (see "what is missing").
consensus/src/consensus/mod.rs, consensus/core/src/api/mod.rs, components/consensusmanager/src/session.rs get_block and get_block_even_if_header_only fill the field; new get_block_evm_transactions(hash) on ConsensusApi and async_get_block_evm_transactions on the session Readers for the executor and the p2p body server Low Mechanical.
protocol/p2p/proto/p2p.proto, protocol/p2p/src/convert/block.rs, protocol/flows/src/ibd/flow.rs, protocol/flows/src/v10/request_block_bodies.rs BlockMessage.evmTransactions = 3 and BlockBodyMessage.evmTransactions = 2 (repeated bytes); BlockBody on the wire is (Vec<Transaction>, Vec<EvmTransaction>); IBD body sync and the body server carry both p2p round trip of the body Low Field numbers 3 and 2 must stay unique if upstream adds fields to these messages. Protocol version still 11.
rpc/grpc/core/proto/rpc.proto, rpc/core/src/model/block.rs, rpc/core/src/model/optional/block.rs, rpc/core/src/convert/block.rs, rpc/grpc/core/src/convert/block.rs, rpc/service/src/converter/consensus.rs, rpc/core/src/model/tests.rs RpcBlock, RpcRawBlock and RpcOptionalBlock carry evm_transactions (gRPC repeated string evmTransactions = 4 as hex; JSON hex list; Borsh serializer version 2 with a fallback for version 1) Template to miner and block back carry the EVM part Low wRPC Borsh shape changed again (version field bumped, old shape still decodes).
rpc/service/src/service.rs, rpc/service/Cargo.toml RpcCoreService holds an EvmTemplateSource; get_block_template attaches the execution layer's selection to the mining manager's template with set_evm_transactions Design D1: templates carry EVM transactions. The mining manager and its cache stay UTXO-only; the EVM part is selected per request Low A few lines in get_block_template_call; conflicts with the finality branch's RPC edits are line-local.
consensus/core/src/config/params.rs SIMNET_PARAMS.blockrate = BlockrateParams::new::<1>().increase_max_block_parents(64), pre_crescendo_target_time_per_block from OneBps Simnet = the devnet DAG shape (1 BPS, k 18, mergeset 180, merge depth 3,600) without proof of work, so a CPU test network of the execution layer runs the devnet rules Low Simnet genesis content and hash unchanged (bits and payload untouched).
kaspad/src/args.rs, kaspad/src/daemon.rs, kaspad/Cargo.toml --evm-rpclisten=IP[:PORT] (default port 26790 on devnet and simnet), --evm-disable; the daemon builds igneum_exec::ExecService, hands its template source to the RPC service and registers it as an async service Wiring Low Mechanical.
igneum/evm-types/ (new crate igneum-evm-types) Decoding through alloy (TxEnvelope::decode_2718), sender recovery, Cancun intrinsic gas, the per-transaction and per-body state-free rules; depends on alloy only so consensus and the executor share one decoder Design 1.5 state-free class in one place None to consensus beyond its use above New crate. Pins alloy-consensus 2.5, alloy-primitives 1.7, alloy-eips 2.5.
igneum/exec/ (new crate igneum-exec) The execution layer: service.rs chain follower (polls get_virtual_chain_from_block, builds segment(C) from get_block_acceptance_data which is written in consensus_ordered_mergeset order, unwinds reorgs from a 64-deep snapshot ring), executor.rs (revm 43 over the segment, rewards by rule, two-dimensional gas, fee flows, skip rule), pgas.rs (the prototype pgas table and the per-frame attribution inspector, CREATE rule for the registry), state.rs (in-memory revm CacheDB, MPT state root through alloy-trie as an output), pool.rs (EVM mempool), rpc.rs (eth_* and igneum_* JSON-RPC on axum), registry.rs and contracts/DeveloperRegistry.sol (system contract at 0x...0210, runtime bytecode embedded), bin/diff.rs (igneum-exec-diff, the plain-revm differential harness) Design sections 1 to 4 and 8.2 None to consensus (the node runs without it under --evm-disable) New crate. Pins revm 43, alloy-trie 0.9, axum 0.8. The in-memory state and the full-recompute state root are devnet scope (see "what is missing").
igneum/miner/src/main.rs --vote-key-hash <hex32> (so a miner's EVM address, the low 20 bytes, is a key it holds), --hold-ms <n> (paces the stub miner on a network without proof of work), --network simnet (simnet address prefix for label addresses) Test-network tooling None Internal tool.

Decisions recorded as open

Decision v0 choice Why it is open
8 or 18 decimals Kaspa's 8 (SOMPI_PER_KASPA), so one coin is 100,000,000 units and the cap is 4e17 units, inside u64 The zkEVM side expects 18 decimals (wei). 18 decimals put the cap at 4e27, which does not fit u64, so the UTXO amount type, mass rules and every RPC amount would change. Decide with the execution engineer before the EVM bridge; a fixed 1e10 scaling at the bridge is the alternative.
Epoch seed Devnet seed rule with a lead (hot swap, 3 Oct 2026): the seed of epoch k is the hash of the last selected-chain block whose DAA score is below k x EPOCH - LEAD (EPOCH 3,600, LEAD 600 DAA score, about 10 minutes at 1 block/s; kaspa_consensus_core::igneum::{POW_EPOCH_BLOCKS, POW_EPOCH_LEAD, pow_epoch_seed_score}); genesis for epoch 0. Every block template carries pow_epoch (RpcPowEpochInfo: epoch length and lead, the template's epoch index and seed, the boundary DAA score, the next epoch's seed once the sink has reached boundary - LEAD, the day index and the next one), so a miner sends prepare to its GPU worker one lead ahead and the worker swaps programs at the boundary with no pause and no process exit. Before 3 Oct 2026 evening: the last selected-chain block below the epoch's start score (no lead). IGNEUM_POW_EPOCH_BLOCKS and IGNEUM_POW_EPOCH_LEAD override both for private test networks (the lead is clamped below the epoch length); IGNEUM_DEVNET_GENESIS_BITS overrides the devnet genesis difficulty for such a network (the genesis hash moves, so it never peers with the real devnet). This is the devnet stand-in for the 20-minute VDF lead of spec 04 (section 4.3: C(e) is the checkpoint at least 1,200 DAA s before the epoch, the VDF takes 600 s, so the program is knowable 10 minutes early). The design uses a 10-minute class-group VDF over a certified checkpoint (bench-log, proto-vdf); the devnet rule keeps the lead and drops the delay, so it is grindable in principle (a miner choosing which block sits at the seed score) and needs the VDF and checkpoints to close. The hash binding (igneum-pow/src/bind.rs) is unchanged.
Day seed for the 256 MiB cache "igneum-day/" || day_le64 with day = header.timestamp / 86,400,000 Timestamps are miner-chosen inside the two timestamp rules, so a day boundary can be straddled by a few blocks; harmless for a cache seed. On branch r3-fixes the day seed is computed only after the future and past-median checks pass (M15 below), so an arbitrary past day no longer reaches the engine. Spec 01 O-1.10 proposes the first epoch seed of the day instead, which waits for the VDF schedule.
Lane hash to 256-bit target Lane hash (64 bits) in the top 64 bits, low 192 bits zero; pow <= target is exactly lane <= target >> 192 Closed for the binding (3 Oct 2026, igneum-pow/src/bind.rs: the init words commit to the nonce-zeroed header hash and the high nonce word). Still open: the block level for pruning proofs reads calc_level_from_pow on a value whose low 192 bits are zero (leading_zeros(lane) shifted), and pruning-proof validation itself still uses the stub.
GPU workers and the hourly program Hot swap (3 Oct 2026): the serve protocol has prepare <epoch_seed_hex> <day_seed_hex> [<pack_dir>]; the worker compiles the next program (and builds the next day's dataset) in the background, keeps at most two programs and two datasets resident, answers prepared <epoch> <day> <ms> ..., switches instantly on the first job with the prepared pair and drops the old pair after it. Metal compiles from the seed; OpenCL builds <pack_dir>/kernel_bound.cl at runtime; CUDA runs nvcc to two cubins in the background and loads them through the driver API (cudaGetDriverEntryPoint). The miner (igneum-miner --worker) writes the pack for the prepared seeds under --prepare-packs and sends prepare as soon as the template reports the next seed; --exit-on-seed-change (exit 42, launcher rebuild) is the fallback for a worker whose ready line says prepare 0. A worker crash is restarted by the miner after a random 5 to 60 s. Measured on the Metal worker across a short-epoch test network: see docs/bench-log.md, hot-swap entry. CUDA and OpenCL prepare paths are written and compile-checked (OpenCL ran on the M5 Max through Apple's OpenCL); the CUDA path has not run on NVIDIA hardware yet.
Proving pool payee OP_RETURN burn tagged igneum-proving-pool-v0 Becomes a payout to the prover set of the proven block once the proving layer records prover sets (20 to 60 s behind the tip).
Finality and pruning depths Kaspa's 12 h and 30 h at 1 BPS Upper bounds. Live finality is the 30-blue-score locked checkpoint (v2 landed 3 Oct 2026); pruning depth must stay above the longest checkpoint gap and F3 (pruning point never past the latest lock) is not yet enforced.
Checkpoint determination depth d 20 blue score on devnet (spec placeholder 60) C1 says d scales with the reorg-depth distribution the devnet records; 20 was chosen so a lock lands within about a minute at 1 block/s.
Who aggregates Any node, as soon as the votes it has seen meet Q3; a local eligible voter is named in the certificate, else a zero aggregator Spec S1 says the 8 VRF-selected aggregators publish and anyone MAY; without a grace timer (Q4, O-3.4) the first node to see quorum publishes, which costs nothing in safety and favours liveness on a 4-node network.
Vote and certificate carriage Coinbase extra data, trailer-framed (... IGNF), devnet cap 16,384 bytes, 48 votes per block Spec says "block body"; a dedicated transaction type would avoid the coinbase cap and is the mainnet form.
Key reveal IGNK plus hex in the miner's extra data through getBlockTemplate The gRPC template request carries extra data as a UTF-8 string, so the 144 binary bytes became 288 hex characters.
PoW before or after GHOSTDAG After Needed for the chain-derived seed; costs GHOSTDAG work on invalid headers. The expensive half of that cost, a 256 MiB cache build per bad timestamp or DAA score, is closed on branch r3-fixes (M15 below): the cheap checks run before the engine, the engine caps and bounds its builds, and a peer that forces off-day builds or sends pre-PoW-rejected headers is banned. The GHOSTDAG work on a bad nonce remains, bounded by the per-peer header rate and the same strike guard. A header-only seed (for example the VDF output carried in the header and verified against the checkpoint) would move the whole check back.

Per-second subsidy numbers (8 decimals, 1 BPS)

Period Years Per second (units) Per second (coins) Per block at 1 BPS
0 0 to 2 3,168,808,781 31.68808781 same
0, day 0 of the ramp (10%) 316,880,878 3.16880878 same
0, day 15 of the ramp (55%) 1,742,844,829 17.42844829 same
1 2 to 4 1,584,404,390 15.84404390 same
2 4 to 6 792,202,195 7.92202195 same
31 62 to 64 1 0.00000001 same
32 and after 64 on 0 0 0

Split of 3,168,808,781: producer 2,535,047,025 (80%, plus the rounding remainder), proving pool 633,761,756 (20%). Total over the schedule: under the 4,000,000,000-coin cap by less than 100 coins (test total_emission_stays_under_the_cap).