Rendered from rfcs/rfc-0012-leader-election-beacon.md at commit 2882390fcb0c

Source SHA-256 8fa1450b8f797424f9e3f774e5efb5c3deddece62841eabfcc63300d575791dd

RFC0012
TitleLeader Election v2 — Address-Committed Slot Secrets, Epoch Beacon, Stake Age
AuthorSpek Core Team
StatusAccepted (gate G1 closed 2026-10-20; ρ fixed by E12 2026-10-22; genesis-frozen)
TypeConsensus
Created2026-10-18
Revised2026-10-20 (rev 4; replaces the 2026-10-18 public-draw design, "option A")
Companion-tocountdown.md Phase B §B4 (session log), RFC-0007 (wallet derivation: Level V context), RFC-0008 (PSBT: hashed-key reveal), RFC-0011 (guardian interop: guardian families move to Level V, G1-13)

Abstract#

The slot-h election draw today is distance(BLAKE3("spek/election-seed/v1" ‖ seed(h-1) ‖ miner_pubkey)). Both inputs can be chosen, for free, by a party that already knows them. That gives path grinding on private branches, stake shifting into a pre-ground key, and free retroactive branches (§1).

The first draft of this RFC (2026-10-18, "option A") replaced the score input with a lagged epoch beacon. Review on 2026-10-19 showed that option A still draws every slot from public data. The whole winner schedule was computable 12 to 24 hours ahead, and the beacon could be predicted before its stake cutoff. That allowed stake fitting, withhold grinding over the whole window, and seed-phase ticket shopping (§1.5).

This revision keeps the epoch beacon and adds the missing ingredient: a per-height secret r_h. Only the key owner can compute it, it is unique per height, and it is committed inside the stake output before the beacon it is drawn against exists.

The same wave makes three related changes, because all of them are genesis-frozen:

A rolling rollback cap with finality evidence was part of option A. It is deferred (§3.22): as drafted it let both sides of a long partition finalize, and the election fix does not need it.

This document is normative for implementation sessions E1–E18 (§11). Every session reads §0, the sections its plan names, and its own session entry.

0. Reading guide and conventions#

1. Motivation#

1.1 Current rule (as of master cc40956)#

1.2 R1 — path grinding#

On a private branch the attacker chooses, each slot, which of its own eligible keys signs. That choice sets the next seed, so the attacker searches a tree of paths, and its per-slot deficit drops from about Exp/A to about Exp/(e·A). The threshold is q = 1/(1+e) = 0.2689. At or above it, an attacker who picks the fork point after seeing the revealed scores reverses any block above the restoration pin, given time. Below it, see Appendix B table B2.

1.3 R2 — stake-shift key grinding (Critical, post-seed)#

The producer of block h−1 knows seed(h−1) before it publishes. It precomputes a pool of G keypairs (about 10 ms each). It then picks the key whose draw at h is best, and moves its stake unit to that key inside its own block h−1. The output counts at h immediately, so the key wins with probability G/(G+N). With G = 10^8 and N = 10^3 it holds about 99% of blocks. On a private branch this rewrites history of any depth above rollback_floor(). A probe on 2026-10-18 confirmed it (Appendix C).

1.4 R3 — free retroactive branches#

A block for a past slot can be produced at any time at zero cost. This is inherent to unbonded proof of stake and can only be bounded:

The slot-secret horizon does not bound signing after the fact. Eligibility is judged at the height being signed, and the key owner can always recompute r_h for any height its horizon covered. Selling the stake does not help either: on a fork from before the sale the stake output is still unspent. Only the pins bound this.

1.5 Why the public-draw design (option A) was not enough (review 2026-10-19)#

Option A drew distance(BLAKE3("spek/score/v1" ‖ B_e ‖ h ‖ pk)), with the beacon B_e read from election_seed at (e−1)L − 1. Every input is public and the seed is a fold of winner public keys, so the whole chain of future seeds can be computed. Simulations (2026-10-19, recreated in E1 in rfcs/rfc-0012/sim/; E1 numbers in brackets, Appendix E):

Finding Mechanism Measured Severity
Public winner schedule Draws are a public function of public data Whole epoch known 12–24 h ahead [S1: exact prediction p^r; p = 0.999 gives 0.24 at a one-epoch margin, and 0.82 within two misses] Critical
Stake fitting B_e predictable before its cutoff with probability p^r N = 1000: 5% stake choosing from 10^5 keys wins 24.6% of an epoch; 10% from 10^6 wins 50.6% [S2: 23.7%, 48.4%] Critical
Withhold grinding A winner can evaluate skipping a block over the whole window q = 0.2, 2 withholds: 34-deep reversible window every epoch; q = 0.33, 1 withhold: 80 [S3 part A: 33, 79 (median)] Critical
Seed-phase ticket shopping Tickets mined after the epoch schedule is known About $200 buys a chosen 20-slot run [S4: with 10^5 candidates per ticket, a chosen 20-slot run lands with 0.98–0.999] Critical
Finality evidence E1–E4 Tests foreign blocks and peers, not participation Both sides of 50/50, 70/30 and 20/80 partitions finalize High

The draw needs a value that is:

A unique-signature VRF gives (a) and (b), but no deployable post-quantum one exists. A deterministic SQIsign is not verifiably unique (§9). A committed hash chain gives all three with BLAKE3 alone.

2. Design goals and invariants#

3. Specification#

3.1 Parameters and constants#

Consensus parameters (spek-config/src/params.rs, ConsensusParams, one profile per network: CONSENSUS for mainnet and testnet, REGTEST_CONSENSUS; init_economics resolves it together with the economics and active_consensus() reads it):

Name Mainnet Regtest default Regtest knob Meaning
election_epoch_blocks (L) 1440 (12 h) 10 REGTEST_ELECTION_EPOCH_BLOCKS beacon epoch length
slot_segment_blocks (S) 8640 (3 days) 10 REGTEST_SLOT_SEGMENT_BLOCKS slots per slot-secret hash chain
ticket_records_per_block_max 16 16 (none) seed-phase ticket records per block

Compile-time constants (spek-core, next to the header constants):

Name Value Meaning
SLOT_HORIZON_DEPTH (d) 10 Merkle depth; 1024 segments per horizon (8.4 years mainnet, 10,240 slots regtest)
BLOCK_HEADER_SIZE 743 was 349
BLOCK_HEADER_SIGNING_SIZE 543 was 201
GENESIS_WORK calculate_hash_distance(&GENESIS_ELECTION_SEED) = 2^255 height-0 work, unchanged in value
SLOT_SEED_BYTES 32 random slot seed stored in the miner key file (§3.3)
STAKE_INDEX_MIN_VALUE spek_config::STAKE_REQUIREMENT_FLOOR (0.1 coin; the same constant floors calculate_min_coin_requirement) smallest output the stake index holds (§3.6)

d is a compile-time constant on every network, so the header has one size everywhere. Regtest shortens horizons through S, and drills reach a horizon's end by choosing J0 (§3.5).

Validation at profile build or load (hard error with a descriptive message, never a clamp):

Deleted (no consumers, no shim): timeslot_precision_ms, fork_min_confirmations and fork_comparison_depth, with their doc comments, including the false "FINALIZE … no reorg possible" ladder text.

3.2 Domain tags (complete list)#

Constant Bytes Used in
SLOT_SECRET_CONTEXT "spek 2026-10-20 slot-secret v1" (BLAKE3 derive_key context) σ from the slot seed
SLOT_SEGMENT_TAG "spek/slot-chain/v1" s_g
SLOT_STEP_TAG "spek/slot-step/v1" step
SLOT_LEAF_TAG "spek/slot-leaf/v1" key-bound Merkle leaves
SLOT_NODE_TAG "spek/slot-node/v1" Merkle nodes
SLOT_COMMIT_TAG "spek/slot-commit/v1" stake commitment Cs
HASHED_COMMIT_TAG_L1 "SpekHashedCommit" (tagged hash) v1 hashed program
HASHED_COMMIT_TAG_L5 "SpekHashedCommitL5" (tagged hash) v2 hashed program
SCRIPT_COMMIT_TAG_L5 "SpekScriptCommitL5" (tagged hash) v2 script-path commitment
BEACON_DOMAIN_TAG "spek/beacon/v1" B_e
SCORE_DOMAIN_TAG "spek/score/v2" score
ELECTION_SEED_DOMAIN_TAG "spek/election-seed/v2" (replaces v1) seed
TICKET_DOMAIN_TAG "SPEK-SEED-TICKET-V2" (replaces V1) ticket preimage
TICKET_RECORD_MAGIC "SKTR" ticket record payload
LAUNCH_KEY_TAG "spek/launch-key/v1" launch key K_L from the network and the open anchor (§3.14)
LAUNCH_SEAL_TAG "spek/launch-seal/v1" seal digest over the sealed submissions, T, message and allocation (§3.14)
LAUNCH_NONCE_TAG "spek/launch-nonce/v2" launch nonce from K_L, the seal digest and the seal anchor (§3.14)
GENESIS_LAUNCH_TAG "spek/genesis-launch/v1" genesis coinbase input committing the launch nonce (§3.14)

Every tag is a pub const in the module that owns its use. A unit test asserts that all tags are pairwise distinct.

3.3 Slot secrets#

σ        = blake3::derive_key(SLOT_SECRET_CONTEXT, slot_seed)   // 32 random bytes from the miner key file
g(h)     = h / S                                   // global segment index, u64
o(h)     = h mod S                                 // offset in the segment
s_g      = BLAKE3(SLOT_SEGMENT_TAG ‖ σ ‖ g as u64 LE)
step(x)  = BLAKE3(SLOT_STEP_TAG ‖ x)               // x is 32 bytes
T_g      = step^S(s_g)                             // segment top
r_h      = step^(S − 1 − o(h))(s_g(h))             // slot reveal

commitment of a horizon starting at segment J0 (u32):
  leaf[j] = BLAKE3(SLOT_LEAF_TAG ‖ pk_e ‖ T_(J0 + j))   for j = 0 .. 2^d − 1   // 1024 leaves, no padding; pk_e = 83-byte Level I key
  node(a, b) = BLAKE3(SLOT_NODE_TAG ‖ a ‖ b)
  C = root of the complete binary tree over leaf[0..1024)

path for leaf j: path[0..d), path[k] = sibling at level k (level 0 = leaves)
slot_root_from_reveal(h, pk_e, r_h, J0, path, S) -> Result<[u8; 32], SlotRevealError>:
  j = g(h) − J0 (u64, checked); require g(h) ≥ J0 and j < 2^d       else HorizonOutside
  x = r_h; repeat (o(h) + 1) times: x = step(x)                     // ≤ S hashes; x == T_g(h)
  x = BLAKE3(SLOT_LEAF_TAG ‖ pk_e ‖ x)                               // leaf binding (I10)
  for k in 0..d: x = if bit k of j == 0 { node(x, path[k]) } else { node(path[k], x) }
  return x                                                           // the derived C

The header does not carry C. The derived root is used to rebuild the stake address (§3.5). A wrong reveal or path yields a different root, so the eligibility lookup finds no stake output (or no ticket record) for that address. That makes the reveal check and the stake check one check, with no separate comparison step.

Properties and rules:

API (spek-core/src/slot_secret.rs, new module):

3.4 Hashed-key outputs (public keys hidden until spent)#

New standard output forms. Their wire bytes look like a Bitcoin P2WSH/P2TR output: a witness version followed by a 32-byte program.

v1 hashed (Level I):  script = 0x51 0x20 <A>              34 bytes   ScriptType::HashedCommit
                      A = TH("SpekHashedCommit", pubkey83 ‖ commitment32)
v2 hashed (Level V):  script = 0x52 0x20 <A5>             34 bytes   ScriptType::HashedCommitL5 (§3.18)
                      A5 = TH("SpekHashedCommitL5", pubkey169 ‖ commitment32)

commitment = [0u8; 32]                                   key-path-only output
           = existing ScriptCommit commitment             script-path output (v1: compute_commitment;
                                                           v2: same construction with SCRIPT_COMMIT_TAG_L5)
           = Cs = BLAKE3(SLOT_COMMIT_TAG ‖ C ‖ J0 u32 LE)  stake output (§3.5)

Spending (spek-script/src/scriptcommit.rs, extending validate_scriptcommit_spend for the hashed types):

Other surfaces:

3.5 Stake output#

A stake output is a v1 hashed output whose commitment is the slot commitment:

Cs           = BLAKE3(SLOT_COMMIT_TAG ‖ C ‖ J0 u32 LE)
A            = TH("SpekHashedCommit", pk_e ‖ Cs)          // pk_e = header miner_pubkey (Level I)
stake_script = 0x51 0x20 <A>                              // 34 bytes

3.6 Eligibility (stake age with margin, one attempt per key)#

epoch(h)          = h / L
beacon_height(e)  = 0 if e < 2, else (e − 1)·L − 1
stake_cutoff(h)   = beacon_height(epoch(h)).saturating_sub(L)        // (e−2)·L − 1 for e ≥ 3, else 0
                    // epoch_of, beacon_height, stake_cutoff_height and segment_of are methods of
                    // spek_core::election::ElectionSchedule (L and S as NonZeroU64, built from
                    // the active ConsensusParams), so every one is total
counted(entry, h) = entry.script == stake_script(header)                 // C derived, §3.3
                    && (entry.height < stake_cutoff(h) || entry.height == 0)
                    && (!entry.is_coinbase || h − entry.height ≥ coinbase_maturity)
stake_output(h)   = some UTXO unspent in the PARENT ledger view with counted(entry, h)
                    and entry.value ≥ calculate_min_coin_requirement(h)
eligible(h)       = 0 ≤ g(h) − J0 < 2^d
                    && (seed phase: a counted ticket record for (A, round(h)) exists, §3.14
                        post-seed: stake_output(h) exists)

3.7 Epoch beacon#

B_e = BLAKE3(BEACON_DOMAIN_TAG ‖ e as u64 LE ‖ genesis.merkle_root)                     e ∈ {0, 1}
B_e = BLAKE3(BEACON_DOMAIN_TAG ‖ e as u64 LE ‖ election_seed@beacon_height(e))          e ≥ 2

Withholding at the beacon height (measured, accepted). At the slot boundary every competing header is public. The miner at beacon_height(e) therefore knows the seed for "publish" and for "withhold" (the runner-up). It also knows all of its own future r_h, so for each choice it can compute its exact per-slot win chances for epoch e, exp(−(1−q)·N·d). A miner that wins several of the last slots before the beacon height has more choices.

Measured in E1 (S3, withhold_grind.py; Appendix E). The attacker controls a beacon only when it wins the tail of the source epoch; with a run of t tail wins it has up to 2^t choices (t geometric in q):

In epochs 0–2 the same effect only shifts wins between genesis-record holders. A delay function on the beacon was reconsidered (§9) and rejected. The gain per controlled beacon is below the 5% bound, so it is accepted (G1-6).

3.8 Score#

score(header) = GENESIS_WORK                                             height 0
              = 0                                                        canonical null header
              = calculate_hash_distance(BLAKE3(SCORE_DOMAIN_TAG ‖ B_epoch(h) ‖ h as u64 LE ‖ pk_e ‖ r_h))   otherwise

3.9 Election seed#

election_seed(h) = BLAKE3(ELECTION_SEED_DOMAIN_TAG ‖ election_seed(h−1) ‖ miner_pubkey ‖ r_h)
null header:  miner_pubkey = NULL_MINER_PUBKEY, r_h = [0u8; 32]
height 0:     GENESIS_ELECTION_SEED

3.10 Header format#

Before (master, 349 B): version 4 ‖ prev_block 32 ‖ merkle_root 32 ‖ da_commitment 32 ‖ election_seed 32 ‖ block_height 4 ‖ miner_pubkey 65 ‖ miner_signature 148; signed prefix 201 B.

After: struct BlockHeader (spek-core/src/blockheader.rs). Fields in wire order:

Offset Field Type Bytes Notes
0 version i32 LE 4 unchanged
4 prev_block [u8; 32] 32 identity chain, unchanged
36 merkle_root [u8; 32] 32 unchanged
68 election_seed [u8; 32] 32 v2 (§3.9)
100 block_height u32 LE 4 unchanged
104 miner_pubkey [u8; 83] 83 pk_e, SQIsign Level I
187 horizon_start u32 LE 4 J0
191 slot_reveal [u8; 32] 32 r_h
223 slot_path [[u8; 32]; 10] 320 path[0] (leaf level) first
543 miner_signature [u8; 200] 200 signs bytes 0..543
Total 743

Why each new field is needed:

3.11 Verification order and per-slot cost#

Live and fork header receipt, cheapest first. The signature check stays ahead of candidacy, as in the code today (candidacy_receipt_gate runs on a signature-valid header):

  1. Length and version pins (fail fast, handle_header_message).
  2. Claimed-score pre-filter (new). Resolve the beacon from the tip context and compute the claimed score from miner_pubkey and slot_reveal (one BLAKE3).
    • A header can rank iff its parent's tracked chain (ChainCandidates) holds fewer than 5 entries, or the claimed score orders before the current 5th entry under Rule 8.
    • A header that cannot rank is dropped before any expensive check and counted in HEADER_RANK_PREFILTER. It is not relayed; it would not be relayed today either, since relay is top-5 only.
    • The pre-filter only drops. Entries are inserted only after steps 3 and 4 pass, so an unverified claim can never displace a verified entry.
  3. Validation-pool job (VerifiedHeader::verify, Propagation lane):
    • SQIsign verify (1.4–5 ms), then slot_root_from_reveal (≤ 8651 hashes, 0.9–2.6 ms; an out-of-horizon J0 fails before any hash).
    • VerifiedHeader stays a proof object. It carries the signature proof and the derived C, exposed as VerifiedHeader::slot_root().
    • A bad signature or HorizonOutside is an objective invalidity (a violation, as a bad signature is today).
  4. Candidacy (candidacy_receipt_verdict, on the event loop with the chain_state read lock):
    • the seed link where the parent is known;
    • eligibility (§3.6) against the stake address rebuilt from the derived C, with one bounded index walk.
    • A header with no stake output there is dropped; if it builds on the node's tip its deliverer takes one Header violation (§3.10), otherwise the drop is silent.
  5. Scoring happens at buffering with the tip context.

Load:

IBD header batches run the horizon check (inside validate_basic) at Ibd rank next to the signature, but no reveal walk: the walk can only fail that same horizon check, and stage 9 derives the root itself at block apply, when the bodies holding the stake or record are present. Walking at the header stage would only double about 1.05M walks per year of history (8–23 core-minutes) beside 1.05M signature verifies (25–88 core-minutes) without rejecting anything new (E7, user decision 2026-10-21).

The IBD header stage no longer checks seed-phase eligibility. Today it runs RandomX on the in-header nonce; with records, eligibility needs the block bodies, exactly as post-seed stake eligibility already does. A fake seed-phase header chain is therefore as cheap to build as a fake post-seed one is today (signature and reveal only), and such a chain outweighs the honest one at the same length (a ground reveal scores near the cap, an empty honest slot scores zero). Block-apply validation alone came too late: it ran after the rollback to the common ancestor, so a fake chain forced every synced node into a rollback and, past the resident undo window, stranded it at the ancestor.

The pre-rollback eligibility screen (ibd_eligibility_screen.rs, Nov 2026) closes this. For a candidate height h with stake_cutoff(h) ≤ ancestor + 1, every output or record that can count at h was created in the shared prefix, so eligibility against the ledger at the ancestor is a necessary condition of stage 9 on the candidate chain, and the same verdict on every node. Before any rollback the node walks each such header's reveal (off the loop, Priority::Ibd) and runs check_coin_requirement against that ledger: from resident undo for a shallow reorg, from the completed deep-undo or rebuild staging, before the install, for a deep one. A refused candidate rolls nothing back, its block is recorded in the failed-reorg memory and its source peers are cooled. On mainnet every height of a provable reorg is screened (the cutoff lags by at least 2L + 1 = 2,881 blocks); heights past the screened range draw their stake from the candidate's own blocks, which screened miners had to fund with real value. Pins and the IBD candidate budgets apply unchanged.

This is a real loss during the first 45 days, when most nodes join. Today ibd_headers.rs (requires_ticket, ticket_round) makes every fake seed-phase header cost a RandomX ticket. Drill D25 (§11, E16) proves the remaining defences: a fresh node offered a fake seed-phase header chain must still finish IBD on the real chain, within the existing candidate budgets.

3.12 Beacon resolution per path (I6)#

trait BeaconSource { fn beacon_for_epoch(&self, e: u64) -> Result<Beacon, BeaconError>; } in spek-core/src/election.rs:

Source Implementation Used by
Main chain ChainState::beacon_for_epoch(e) reads the header at beacon_height(e), in the same lock scope as best_block commit seams, mining, live candidacy, propagation fork walks, RPC
Tip context TipElectionContext gains beacon_current (epoch of tip+1) and beacon_next (epoch of tip+2 if different), filled by ChainState::tip_election_context received-header scoring, candidacy gate, mine_header, MinerCandidate
IBD candidate IBDStagedStorage::get_header(candidate_id, height) at or above the divergence, else main chain; cached per candidate per epoch CandidateMetadata::new (caller passes work), copy_header_prefix_from, stop_ibd_security, ibd_candidate_work_delta, ibd_headers.rs
Sequential sweep SeedChainWalk with BeaconTracker recording election_seed at each beacon_height boot sweep, persistence consistency sweep, do_chain_restoration / restore_chainwork, add_genesis_rows_to_batch

Rules:

3.13 Chainwork and score consumers#

Per-block work becomes score(header) under the resolved beacon. Production sites (counted 2026-10-18; E7 re-counts with grep -rn "calculate_score()" crates --include=*.rs):

Site (file, function) Beacon source
spek-core/src/chain.rs ChainState::new, genesis_baseline_work GENESIS_WORK
chain.rs add_winning_block, tip-pinned apply, get_accumulated_work, add_block_for_ibd, apply_propagation_fork_reorg main chain
spek-storage/src/ibd_staged.rs CandidateMetadata::new, copy_header_prefix_from IBD candidate (caller passes work)
spek-node/src/node/storage.rs do_chain_restoration, add_genesis_rows_to_batch sequential sweep / genesis
spek-node/src/node/ibd_completion.rs stop_ibd_security, ibd_candidate_work_delta main chain or candidate
spek-node/src/node/handlers/block_validation.rs apply_block_verify_result (log) main chain
spek-node/src/node/propagation/header_relay.rs try_push_header tip context
spek-node/src/node/rpc_node_status.rs refresh_node_status main chain
spek-network/src/propagation/received_headers.rs VerifiedHeader::verify (scoring moves out) tip context at buffering
spek-network/src/sync/fork_common.rs from_header (two structs) &dyn BeaconSource (main chain)
spek-network/src/sync/propagation_fork.rs check_chain_complete (rewrite the "header-local" comment) main chain
spek-consensus/src/candidate.rs MinerCandidate::new(pubkey, height, &Beacon, parent_seed, slot_reveal) tip context
test tooling: spek-test-client/src/adversary.rs, relink.rs, range_work.rs, main.rs, spek-network/src/test_harness.rs per tool

Persisted chainwork changes meaning. Old data directories are invalid (E18 regenerates estate and fixtures).

3.14 Seed phase: ticket registry#

The ticket moves out of the header into an on-chain record, so a ticket is committed before the beacon it competes against exists. That stops "ticket shopping": ranking free candidate keys by their own scores in an epoch that is already known, then buying RandomX tickets only for the best.

Preimage (spek-randomx/src/preimage.rs):

Record: a zero-value OP_RETURN output in the block's coinbase. The codec (TicketRecord: encode, parse, encode_payload, parse_payload) and the genesis launch set (GenesisRecordSet) live in spek-core/src/ticket_record.rs, because the genesis builder in spek-core/src/genesis.rs must write records and spek-core cannot depend on spek-consensus (user decision, 2026-10-20):

payload = TICKET_RECORD_MAGIC ("SKTR", 4) ‖ round u32 LE ‖ A (32) ‖ nonce u64 LE     = 48 bytes
script  = 0x6a 0x30 <payload>                                                        = 50 bytes

Inclusion rules (stage 11 structure plus a seed-phase stage):

Counting:

State.

Relay and pool.

Timing.

Genesis.

Size. About 1000 tickets in each of 15 rounds is about 750 KB for the whole seed phase.

Transition. Ticket holders must fund a stake output below the cutoff of the first post-seed epoch. The node warns when that cutoff is 7 days away.

3.15 Mining node#

3.16 Wallet and RPC surface#

3.17 P2PK removal#

Once stake and coinbase move to hashed outputs, nothing in the node produces P2PK. Remove it completely, with no compatibility path:

After this, is_admissible_output admits exactly:

3.18 SQIsign Level V (opt-in)#

Headers, mining keys and stake outputs stay Level I. What a break of Level I would do, stated exactly:

Level I stays for headers on these terms (verify budget, header size). The remaining exposure is liveness plus history down to the pin, not funds beyond one stake unit per key.

Long-lived custody gets Level V from genesis:

3.19 Naming cleanup#

3.20 Wire generation#

Bump WIRE_GENERATION once, in E6 (the header change). Every protocol string, including the new ticket-records topic, derives from it.

3.21 Instruments#

Rate-limited, emitted on state change where that applies, with no issue labels:

Name Level Fields Where
BEACON_SET info epoch, beacon_height, beacon prefix block at a beacon_height commits on the main chain
BEACON_UNRESOLVED debug; WARN (max one per candidate per minute) for source=ibd_candidate source, epoch, reason BeaconError paths
SLOT_REVEAL_REJECT debug height, key prefix, reason (horizon_outside, no_stake_at_derived_root) receipt, IBD, stage 9
HEADER_RANK_PREFILTER debug, one line per slot slot, dropped, verified receipt pre-filter
STAKE_AGE_REJECT debug, at most 16 per height height, key prefix, reason (no_output, aging, immature, below_requirement), largest indexed output value, requirement, cutoff, site candidacy and stage 9 (post-seed NoStakeOutput only)
PAYOUT_ADDRESS_UNSET warn, at boot and once per day horizon j0, largest indexed output at the stake address node, when the coinbase pays a covered stake address because no payout address is set
COINBASE_PAYOUT debug height, target (stake or payout), covered mining, per own coinbase
TICKET_RECORD_ADMIT / TICKET_RECORD_REJECT debug round, address prefix, reason pool and block validation
TICKET_RECORD_INCLUDED info round, height miner sees its record on chain
HORIZON_CREATED info j0, eligible_from_height node
HORIZON_EXPIRING warn, once per horizon j0, eligible_until_height, next_j0, and whether the renewal transfer can fund the next horizon or the address has no surplus node, 30 days ahead, only while the next horizon is not funded
HORIZONS_FILE_RECOVERED warn, at boot or at the reconcile j0, created_at_height, stake_address, path node, a horizon the file did not list was rebuilt from the main chain and recorded
HORIZONS_FILE_MISSING warn, at boot path, main_chain_tip node, the horizons file is missing or empty on a chain past genesis and the boot scan found nothing (the reconcile follows once synced)
HORIZONS_RECOVERY_PENDING info, at boot path, settled_tip node, no horizons file and the main chain is at genesis while the clock is past it: the reconcile runs once synced
HORIZONS_RECONCILED info, once per process recorded node, the reconcile found no horizon missing from the file
HORIZONS_RECONCILE_FAILED error, once per process reason node, the reconcile scan could not run or its result could not be recorded
TICKET_SEARCH_HORIZON_ENDED warn, once per process next_height node, the horizon the ticket search binds to has ended, so the search plans no round until a restart
STAKE_SWEEP_SUBMITTED / STAKE_SWEEP_REFUSED info / warn j0, txid, inputs, amount, fee node, a sweepstake call

3.22 Deferred (not in this wave)#

4. Consensus Impact#

Type: Consensus (hard, genesis-frozen).

5. Backward Compatibility#

None needed: pre-launch. Rehearsal-1 chains, estates, fixtures and data directories are invalid and are regenerated in E18. There are no feature flags, no dual paths and no leftover P2PK, da_commitment or beacon-free scoring code.

6. Activation Conditions#

Active from genesis. No signalling deployment.

7. Security Considerations#

7.1 Attack-to-mitigation matrix#

Attack Today (master) Option A (2026-10-18) This revision
R1 path grinding Critical, 26.9% threshold removed within an epoch removed for forks shallower than L; deep private branches choose beacons: q* 0.45–0.47 (S9, §3.22), depth bounded by the pins
Stake fitting inside a private branch n/a (R2 is stronger) n/a (public beacons) inside the restoration pin about 3%, thresholds unchanged; beyond it (fresh-node IBD) about 0.42 at 10^6 candidates, bounded by compiled checkpoints; density window deferred (G1-22)
R2 stake shift Critical, about 99% capture replaced by stake fitting closed (I3)
Stake fitting n/a 24.6–50.6% of blocks closed
Public winner schedule / targeted DoS about 30 s ahead 12–24 h ahead unknown until each header is published
Withhold grinding n/a whole-window, 34-deep at q = 0.2 one choice per withheld tail win, which the withholder can evaluate (it holds its own future r_h); S3: net 1.9–3.0% more of its own wins per controlled beacon, 0.3–0.6% over all epochs, no compounding; accepted (G1-6)
Seed-phase ticket shopping n/a about $200 for a chosen 20-slot run closed by the registry
Hindsight fork-point choice factor on top of R1 factor 1.2–1.5 same as option A (hidden draws change when windows are known, not which exist)
Bought or dormant keys signing after the fact equivalent to key share adds to q_eff unchanged: bounded only by the restoration pins (the horizon does not bound it, §1.4); M2 deferred (G1-4)
Header signature forgery wins slots wins slots cannot win live slots (r_h secret, I11); can force equivocation negations and rewrite history down to the pin (§3.18)
Harvest-now attacks on public keys every output's key public same keys hidden until spent, except stake outputs (pk_e is public in headers), which the payout rule keeps at one stake unit (§3.5)
Commitment copy (slot sniping) n/a n/a closed: the score and the commitment leaves both bind pk_e (I10); without it, share q → q/(1−q)
Stake-address dust slowing receipt O(outputs at the address) per header (today, P2PK) same bounded: one output, a value-ordered index, and each extra step costs a stake unit (§3.6)
Grinding epochs 0–1 by a genesis-record holder or the organiser n/a known beacon merkle-root beacon; tickets under a Bitcoin open anchor, a witnessed seal, a launch nonce from a Bitcoin seal anchor mined after the seal, and a deterministic build (§3.7, §3.14)
Junk-reveal header flood n/a n/a at most one signature check, one walk and one bounded lookup each; per-peer cap; on the tip the deliverer is struck once per copy (Header violation, a ban after the threshold), later copies of the block hash are dropped before any signature check (§3.10)
Level V verify flood n/a n/a vsize ρ bounds block verify time to today's worst
Partition restoration pin caps at 33–67 h both sides finalize no new cap; shorter partitions heal by weight; longer ones still split at the existing pin (§3.22)

7.2 Implementation hazards (checked in E15)#

8. Performance Impact#

Item Cost
Header size 743 B (+394 B); about 781 MB per year (+414 MB)
Receipt per header one BLAKE3 pre-filter; for top-5 candidates, one SQIsign verify plus ≤ 8651 BLAKE3
Per slot per node about 31–66 header checks (1k–1M miners); walks about 40–170 ms CPU
IBD per year of history about 1.05M walks (8–23 core-minutes) beside 1.05M signature verifies (25–88 core-minutes)
Miner 8.8M hashes per horizon; under 96 steps per slot; 32 KB tops
Outputs hashed 34 B (was 67 B P2PK / 100 B plain); a hashed key-path spend carries 115 B more witness
UTXO set 66 B smaller per output than plain ScriptCommit; stake_value_index adds one (20 + 8 + 36 B key) BTree entry per output of at least 0.1 coin, the same entry size as script_index
Stake lookup bounded by the outputs at the address holding at least the requirement (was all outputs at the script)
Ticket records about 750 KB for the whole seed phase
Level V v2 witness bytes weigh ρ; block verify time capped at today's worst

9. Alternatives Considered#

10. Decisions (gate G1)#

ID Decision Value Status
G1-1 L 1440 default
G1-2 S and d 8640 (3 days), d = 10 (8.4 years) decided 2026-10-20
G1-3 Stake cutoff margin one epoch decided 2026-10-20
G1-4 M2 forfeiture no; deferred (§3.22). The honest-signer guard ships as a separate node fix decided 2026-10-20
G1-5 Cold-staking fields no; the §3.5 payout rule caps a stolen box at one stake unit per key decided 2026-10-20; rationale corrected rev 4
G1-6 Withholding residual accept: S3 measures a net 1.9–3.0% per controlled beacon (below 5%) and no compounding confirmed 2026-10-20 (S3)
G1-7 Seed-ticket registry mandatory, coinbase OP_RETURN records, cap 16 decided 2026-10-20
G1-8 WIRE_GENERATION bump yes, in E6 default
G1-9 Rollback cap and finality deferred decided 2026-10-20
G1-10 Regtest defaults L = 10, S = 10, round = 60, seed phase 180 (§3.1 round bound) default (rev 4)
G1-11 Level V in scripts yes, hashed v2 only; ρ = RHO_L5_NUM / RHO_L5_DEN = 151/50 = 3.02 (Round 3: w5 = 6.12; E12 on Round 2 set 171/50) decided 2026-10-20; ρ fixed 2026-10-22
G1-12 Hashed-key outputs and P2PK removal yes decided 2026-10-20
G1-13 Guardian and covenant families on Level V yes; E14 runs decided 2026-10-20
G1-14 Proof pruning deferred default
G1-15 Score and commitment leaves bind pk_e (I10) yes, both default (rev 4)
G1-16 Slot seed source random 32 B in the miner key file (I11) default (rev 4)
G1-17 Coinbase payout rule pay the stake address until covered, then --payout-address default (rev 4)
G1-18 Stake form one output at least the requirement, with stake_value_index (floor membership) default (rev 4)
G1-19 Launch beacon genesis merkle root over an open launch search (tickets under K_L from a Bitcoin open anchor), a witnessed seal, a launch nonce from the Bitcoin seal anchor and a deterministic build; commit-reveal replaced 2026-11-06 default (rev 4); option B decided 2026-11-06 (user)
G1-20 C in header not carried; derived (743 B header; 673 B on Round 2) decided 2026-10-20
G1-21 Next-horizon lead time (node policy) 122 segments (about one year) by default, configurable (--horizon-renewal-lead-segments, floor 2 + ⌈3·L / S⌉ + 1, ceiling 2^d − 2), with the renewal transfer funding non-winners from surplus stake; was 36 days (decided 2026-10-20) decided 2026-10-25 (user)
G1-22 Deep private branches (S9) accept beacon choice (q* 0.45–0.47) and in-branch stake fitting (inside the pin about 3%; beyond it about 0.42 at 10^6 candidates); density window not adopted (E14A void 2026-10-23, §3.22) decided 2026-10-20, density 2026-10-23

11. Implementation order and sessions#

11.1 Rules for every session#

  1. Read §0, this section, your session entry, and the sections it names. Read only the design_architecture_analysis files named (navigate from 00_TABLE_OF_CONTENTS.md).
  2. Budget: 300–330k tokens. If a session will overrun, stop at a compiling, committed boundary and write the hand-off: what is done, what is left, and which tests are red and why.
  3. Run only the targeted tests listed (§0 env vars). Never run the workspace suite.
  4. Do not bend production code to make a test pass: no cfg(test) branches in production functions, no env bypasses in validation, mining or networking. Regtest knobs use the REGTEST_* pattern.
  5. No emoji. No issue labels (R1, M2, C1, ...) in code, comments or logs. No TODO, placeholder, stub or legacy code. Banned terms: local, our, my.
  6. Tests go in sibling <module>_tests.rs files with #[path].
    • Test fixtures that need slot fields use a shared helper, spek_core::slot_secret::testing::slot_fields_for(keypair, height, j0) (feature testing, added in E4). It caches tops per key in a OnceLock map, so mainnet-S tests pay the 8.8M-hash build once per key per test binary.
    • Prefer the regtest S (10) wherever the test does not need mainnet values.
  7. End every session with:
    • a commit on rfc0012 that ends with the attribution line;
    • the session row in countdown §B4;
    • a memory entry;
    • the §15 change log if the design moved.
  8. Branch state.
    • From the end of E6 until the end of E8 the node binary is not runnable end to end. That is expected, and each hand-off states it.
    • cargo build --workspace and the session's targeted tests must pass at every session end.
    • Test targets that a later session owns may fail to compile only if the hand-off lists them.
  9. Drill sessions follow the drill feedback rules: serial Sonnet agents, no idle gaps, stop when a binary is shown broken, never edit a running drill script, never auto-restart clusters, size clusters against host RAM and threads.

11.2 Order and dependencies#

# Session Depends on Produces
E1 Simulations and gate G1 — sims S1–S9, G1 answers, Status Accepted
E2 Adversary tooling and pre-fix baselines E1 D1-A, D2-A baselines on master behaviour
E3 Params and naming cleanup E1 new params and validation; renames; dead params gone
E4 Pure primitives and vectors E3 slot secrets, hashed-commit hashing, stake script, beacon, score v2, seed v2 helpers, record codec, Appendix D
E5 Hashed-key outputs in the script layer E4 HashedCommit type, spend rules, addresses, PSBT reveal, wallet default
E6 Header format and core consensus E4, E5 673 B header, seed v2 and score v2 in core, stage-9 single-output eligibility with stake_value_index, record validation and keyspace, wire bump
E7 Network, storage and node scoring consumers E6 BeaconSource paths, receipt order, IBD and restoration, proto/RPC header shape
E8 Mining and seed-phase node side E7 miner key file with slot seed, SlotSecretStore, mine_header, horizons, coinbase payout rule, ticket pool/topic/search, ceremony build, new RPCs
E9 P2PK removal (production code) E8 no P2PK producer or validator left; tests migrate once in E10–E11
E10 Test migration (core, consensus, script, storage) E9 green targeted tests in those crates; fixture helper
E11 Test migration (network, node, test-client) and new end-to-end tests E10 green targeted tests; beacon and eligibility e2e tests
E12 Level V vendoring and benchmark E11 Level V stacks, seeded keygen, benchmark, ρ
E13 Level V outputs and vsize E12 HashedCommitL5, v2 leaves, vsize rule, wallet cold storage
E14 Guardian and covenant families on Level V (G1-13 = yes) E13 v2 covenant templates, RFC-0011 bump, covenant drills
E14A VOID (not doing): density fork-choice rule — decision recorded (G1-22, §3.22); nothing built
E15 Whole-wave self-review and fix wave E14 review findings fixed
E16 Drill campaign E15 D1-B..D28, D30 results (D29 void with E14A)
E17 Docs E16 design docs, CLAUDE.md, WHITEPAPER, RFC-0007/0008, countdown
E18 Land E17 reviewable commits on master, regenerated estate and genesis, rehearsal #2 prep

11.3 Session entries#

E1 — Simulations and gate G1 (about 300k)#

Read: §1.5, §3.3, §3.6, §3.7 (withholding), §3.8 (I10), §3.14, §3.22; rfcs/rfc-0012/sim/ (existing hindsight.py, hindsight_closed.py, cap_threshold.py, hindsight_exact.py, and the prior-review seeds commitment_copy.py and withhold_eval.py with their .txt outputs).

Write pure-Python scripts (run with python3 -I, no third-party packages) with a committed .txt output each, in rfcs/rfc-0012/sim/:

Then fill §10 (G1-4 and G1-13 answered by the user; G1-6 confirmed with the S3 numbers, and if S3 exceeds 5% the user decides again), record the answers in §15, and set Status to Accepted.

Exit: all scripts run and outputs are committed; G1 has no open row except ρ, which E12 owes.

E2 — Adversary tooling and pre-fix baselines (about 300k)#

Read: spek-test-client/src/adversary.rs (AdversaryMode, best_scoring_key_indices, squat_children_of), relink.rs, range_work.rs, snapshot_gen.rs, docs/MULTI_NODE_TESTING.md (desktop cluster recipe), docs/final_tests.md (format).

Add regtest-only AdversaryMode variants (node code unchanged):

Post-fix-only modes must compile against the new APIs later. In E2, implement the mode enum entries and the master-compatible ones (StakeShift, PrivateBranch, RetroSign). E11 completes the post-fix-only modes.

Baselines on a binary built from the E1 commit (master behaviour), in a 5–7 node desktop cluster:

Exit: baselines recorded in docs/final_tests.md with run numbers, commands and log excerpts.

E3 — Params and naming cleanup (about 300k)#

Read: §3.1, §3.19; spek-config/src/params.rs, spek-config/src/checkpoints.rs, spek-network/src/sync/constants.rs, scripts/sync-timeline/.

  1. Add election_epoch_blocks, slot_segment_blocks and ticket_records_per_block_max to the consensus struct and every profile, with knobs and validation (§3.1). Change the regtest round and seed-phase defaults.
  2. Delete the three dead params and their docs.
  3. Rename fork_finality_depth → propagation_fork_window and FINALITY_DEPTH → PROPAGATION_FORK_WINDOW everywhere (193 references), including the assert message and doc text.
  4. Do the catchup rename in comments, docs, scripts and CLAUDE.md Rule 10, and delete the dead FinalityCatchup log patterns.
  5. Delete MIN_PIN_BURIAL_INTERVALS.
  6. Tests (params_tests.rs): validation rejects S % L != 0, a round or seed phase not divisible by L, W/2 < 2L + 3 (including W = L and W = 4L), and bad knob strings; the profiles build. The regtest defaults (L = 10, W = 60, seed phase 180) pass.

Run: cargo test -p spek-config; cargo build --workspace; cargo clippy --workspace -- -D warnings.

Exit: behaviour unchanged (renames only, plus params not yet consumed); grep -rniE "finality[ _-]?catch-?up|fork_finality_depth|FINALITY_DEPTH\b" --exclude-dir=rfcs --exclude-dir=target . returns nothing outside dated test records (docs/final_tests.md history stays as written).

E4 — Pure primitives and vectors (about 300k)#

Read: §3.2–§3.9, §3.14 (record codec and preimage only), §12; spek-core/src/blockheader.rs (seed and score functions), spek-core/src/election.rs, spek-script/src/scriptcommit.rs (compute_commitment), spek-randomx/src/preimage.rs.

  1. spek-core/src/slot_secret.rs: everything in §3.3, including SlotSecretStore and SlotRevealError. Add the test helper slot_secret::testing::slot_fields_for(keypair, height, j0) behind the existing testing feature (the one that exposes BlockHeader::new_test). It uses a fixed test slot seed per key (BLAKE3("spek/test-slot-seed" ‖ pk), testing feature only, never in production builds) and caches a SlotSecretStore per key in a OnceLock map.
  2. spek-core: epoch_of, beacon_height, stake_cutoff_height, derive_beacon, derive_launch_beacon (genesis merkle root), Beacon, BeaconSource, BeaconError, BeaconTracker, beacon_score (score v2, with pk_e), GENESIS_WORK.
  3. Add derive_election_seed_v2 as a new function. E6 renames it to derive_election_seed and deletes the old one in the same commit; this is the one sanctioned short overlap (E4 → E6). The v1 ticket preimage is a second one (§3.14 Preimage), deleted in E6 with the header-nonce path.
  4. spek-script: hashed_commit_program(pubkey, commitment), slot_commitment(c, j0) (Cs), stake_script(pk_e, c, j0). These are hashing only, with no type detection yet.
  5. spek-randomx: TicketPreimageV2 (stake program) and TICKET_DOMAIN_TAG_V2 beside v1. spek-core/src/ticket_record.rs: the TicketRecord codec (encode, parse from an output script) and the ceremony hashes.
  6. Unit tests (sibling files):
    • the derived root equals slot_commit_root at o = 0, 1, S−2, S−1;
    • j = 0 and 1023; out-of-horizon on both sides (HorizonOutside, before any hash); a tampered path at each level and a wrong J0 each derive a different root;
    • r_(h+1) hashes to r_h;
    • σ differs per slot seed; tags pairwise distinct;
    • the same (B, h, r_h) under two different pk_e gives different scores, and the same tops under two different pk_e give different roots, so a reveal and path valid for one key derive a non-matching root for the other (I10);
    • calculate_min_coin_requirement is non-increasing over the post-seed schedule (the §3.5 payout target relies on it);
    • epoch and cutoff boundaries (h = 0, L−1, L, 2L−1, 2L, 3L−1, 3L) including genesis entries;
    • B_0 ≠ B_1; both change when the genesis merkle root changes and not when only the genesis signature changes; derive_beacon refuses epochs 0 and 1; null and genesis scores; Rule 1 edge cases through beacon_score;
    • record codec round trip and every malformed length;
    • preimage layout.
  7. spek-core stake lookup (§3.6), not yet consumed:
    • stake_value_index in UTXOSet, maintained in add, remove and every script_index rebuild path;
    • StakeOutputSource::find_stake_output for UTXOSet and the LedgerView overlay, with the overlay merge;
    • entry_counts_as_stake. Tests in utxo_tests.rs and ledger_overlay_tests.rs:
    • largest first;
    • stop below the requirement;
    • skip an aging output and return the next counted one;
    • equal-value tie-break;
    • parity between the overlay and the base under created and spent outputs;
    • a dust flood of 10^4 outputs walks zero entries;
    • the index after a rebuild is identical.
  8. Compute and paste the Appendix D vectors.

Run: cargo test -p spek-core slot_secret election blockheader utxo ledger_overlay, cargo test -p spek-script hashed, cargo test -p spek-randomx preimage, clippy on those crates.

Exit: green; Appendix D filled; nothing outside tests consumes the new functions yet.

E5 — Hashed-key outputs in the script layer (about 320k)#

Read: §3.4, §3.16 (wallet part); spek-script/src/script_type.rs, scriptcommit.rs, address.rs, validation.rs, tx_builder.rs, lib.rs; spek-wallet/src/psbt.rs and the wallet binary; rfcs/rfc-0008-psbt-interchange.md; spek-test-client/src/script_zoo.rs.

  1. ScriptType::HashedCommit: detection (exactly 0x51 0x20 + 32 bytes), is_admissible_output, as_str ("witness_v1_hashedcommit"), and disassembly.
  2. Spend validation (§3.4): key path and script path, disambiguation by last-item length, and the NUMS refusal on the key path.
  3. Bech32m: v1 program length 32 or 97, both directions; canonical-encoding checks unchanged.
  4. tx_builder and wallet: hashed receive, change and coinbase addresses (key-path-only commitment = zeros); signing produces [sig, reveal].
  5. RFC-0008: an optional per-input reveal record. Update the PSBT codec and signer and bump the format version if RFC-0008 versions it.
  6. Tests:
    • key-path spend OK; a wrong reveal; a reveal with the wrong commitment;
    • script-path spend OK; a script-path control block whose computed commitment does not hash to the program;
    • ambiguous witness lengths;
    • NUMS key on the hashed key path refused;
    • address round trips for both v1 forms;
    • PSBT round trip with a reveal;
    • admissibility of each form.

Run: cargo test -p spek-script, cargo test -p spek-wallet psbt address, clippy.

Exit: green. P2PK still exists (E9 removes it); stake still uses P2PK (E6 switches it).

E6 — Header format and core consensus (about 330k)#

Read: §3.5, §3.6, §3.8–§3.10, §3.14 (validation and state), §3.20; spek-core/src/blockheader.rs, block.rs, chain.rs (score and seed sites), election.rs, null_block.rs, genesis.rs (header construction only), utxo.rs (find_stake_output from E4), ledger_overlay.rs; spek-node/src/node/miner_key.rs, spek-node/src/bin/miner_keygen.rs, spek-test-client/src/snapshot_gen.rs (read_miner_key_bytes); spek-consensus/src/validation/common.rs, validation/block.rs (stages 9 and 11), seed_ticket.rs, candidate.rs; spek-storage keyspace patterns; spek-network/src/wire_version.rs.

  1. The new BlockHeader layout, SlotFields, constructor, signing bytes, serialize/deserialize, serde, null and genesis forms, validate_basic; the 673/525 constants; remove da_commitment everywhere in production code.
  2. Rename derive_election_seed_v2 to derive_election_seed and delete the old function. Add BlockHeader::score(&Beacon) and delete calculate_score. Update the spek-core and spek-consensus production sites, with chain.rs commit seams resolving via beacon_for_epoch.
  3. Stage 9: eligibility per §3.6.
    • Derive C with slot_root_from_reveal, rebuild the stake script, then call find_stake_output with the cutoff and requirement.
    • Delete get_mature_balance, mature_stake_balance and MatureBalanceSource; no caller of the summing form may remain.
    • VerifiedHeader gains slot_root() (§3.11).
  4. Seed phase:
    • record validation in the coinbase (§3.14 inclusion rules);
    • the ticket_records keyspace, written in the commit batch and deleted on disconnect;
    • seed-phase eligibility through the keyspace;
    • delete the header-nonce ticket path (SeedTicketInput variants that read da_commitment), the v1 TicketPreimage and TICKET_DOMAIN_TAG; rename TicketPreimageV2 / TICKET_DOMAIN_TAG_V2 to the plain names and drop the TicketInput trait if only one preimage type is left.
  5. Bump WIRE_GENERATION.
  6. Only spek-core and spek-consensus production code must be complete in this session. Downstream crates must still build, so update their call sites:
    • Score sites without their §3.12 source yet call ChainState::beacon_for_epoch on the main chain. That is correct for every live path, and E7 replaces it where §3.12 names another source.
    • miner_key.rs moves to the 450-byte key file with the slot seed (§3.3, §3.15): generate, persist and load, rejecting any other length. Add spek_core::slot_secret::MinerKeyFile (parse, encode, accessors); port miner_key.rs, miner_keygen.rs and snapshot_gen.rs to it. Tests go in miner_key_tests.rs: round trip, every wrong length, and the public key read back equal to the generated one.
    • mine_header builds a SlotSecretStore from the slot seed at boot, with one horizon J0 = g(tip + 3·L) held in memory, and fills real SlotFields from it. E8 adds the horizons file, renewal and the coinbase payout. Until E8 the coinbase still pays the old script, so post-seed mining is not eligible on this branch; the hand-off says so.

Run: cargo build --workspace, clippy, cargo test -p spek-core --lib blockheader slot_secret election, and cargo test -p spek-node miner_key.

Exit: the workspace builds; spek-core lib header tests are green; the hand-off lists the test targets left for E10/E11.

E7 — Network, storage and node scoring consumers (about 330k)#

Read: §3.11–§3.13; 01_BLOCK_LIFECYCLE.md (header receipt, candidacy, commit), the IBD sections of 05_NETWORK_SYNC*.md; spek-network/src/propagation/received_headers.rs, sync/fork_common.rs, sync/propagation_fork.rs, test_harness.rs; spek-storage/src/ibd_staged.rs; spek-node/src/node/handlers/header.rs (receipt gate, floodsub verify job, candidacy_receipt_verdict, buffer_verified_header), propagation/winner.rs, propagation/finalize.rs, ibd_completion.rs, ibd_headers.rs, storage.rs, rpc_node_status.rs; spek-rpc/src/types.rs.

  1. The BeaconSource implementations per §3.12, including the IBD candidate cache and BeaconTracker in every sweep.
  2. Receipt order per §3.11:
    • the pre-filter (claimed score with pk_e);
    • the pool job verifying the signature and deriving C;
    • then candidacy against the derived stake address, dropping silently on no stake;
    • scoring at buffering with the tip context;
    • HEADER_RANK_PREFILTER and SLOT_REVEAL_REJECT.
    • Rewrite the stale "O(1) … via pubkey_index" comment above candidacy_receipt_gate to the bounded index walk.
  3. IBD header batches: no reveal walk at the header stage (amended in E7, §3.11). Candidate work is supplied by the caller as the candidate's beacon source (ibd_staged stays storage-only; keep its torn-write and WriteBatch invariants intact).
  4. RPC header shape (§3.16); refresh_node_status and logs.
  5. Fix comments that say "header-local" or "score is the election_seed distance".

Run: cargo build --workspace, clippy, and --no-run for spek-network, spek-storage and spek-node.

Exit: builds; hand-off lists red test targets.

E8 — Mining and seed-phase node side (about 330k)#

Read: §3.5, §3.14 (relay, pool, timing, genesis), §3.15, §3.16; spek-node/src/node/mining.rs (mine_header, coinbase build), miner_key.rs, ticket_search.rs, ticket_store code; the floodsub topic setup and codec.rs protocol strings; the mempool admission lanes in validation_pool.rs; spek-core/src/genesis.rs; spek-rpc/src/jsonrpc/server.rs (registration pattern of getseedphasestatus).

  1. SlotSecretStore, horizons file (with commit_root), horizon creation rule, HORIZON_CREATED and HORIZON_EXPIRING.
  2. mine_header per §3.15. The coinbase payout rule per §3.5:
    • target(h), the covered test, --payout-address / payout_address with boot validation;
    • PAYOUT_ADDRESS_UNSET and COINBASE_PAYOUT;
    • tests:
      • current uncovered → current stake address;
      • current covered and next uncovered → next stake address;
      • both covered → payout address;
      • both covered with no payout address → current stake address plus PAYOUT_ADDRESS_UNSET;
      • the seed-phase target;
      • a plain ScriptCommit, OP_RETURN or own-stake payout address refused at boot.
  3. Ticket search keyed by (round, A); record submission and re-announce; TicketRecordPool; the ticket-records topic; admission on the Mempool lane with the rate limit; producer inclusion; TICKET_RECORD_* instruments.
  4. The genesis builder (§3.14 ceremony procedure):
    • it reads the ceremony file and the commitment list;
    • it refuses unmatched records and a set-hash mismatch;
    • it sorts records by (round, A) and builds deterministically, so two builds from the same inputs are byte-identical apart from the signature, and their merkle roots are equal;
    • B_0 and B_1 come from the merkle root. Rebuild the regtest genesis fixture used by tests. Tests go in genesis_tests.rs.
  5. RPCs getstakeaddress and createstakehorizon (regtest only), plus the eligibility fields.
  6. Desktop smoke on a 3-node regtest cluster (L = 10, W = 60, seed phase 180): the seed phase mines with records past epoch 3 (records from non-genesis keys count); the post-seed phase mines with stake outputs; the payout switches to --payout-address once covered; createstakehorizon creates the next horizon, the payout rule funds it, and a restart reloads it. A horizon's END is out of a desktop run's reach: d = 10 is fixed on every network (the header's path length), so a horizon spans 10,240 slots at S = 10 and no J0 ≥ 0 ends sooner. Expiry, renewal and the overlap order are unit-tested on fabricated tips instead (amended in E8).

Run: cargo build --workspace, clippy, targeted node unit tests for the new modules.

Exit: the smoke passes and is recorded in docs/final_tests.md. The node is runnable end to end again.

E9 — P2PK removal, production code (about 250k)#

Read: §3.17; every production P2PK site (grep -rn "p2pk_script\|ScriptType::P2PK\|validate_p2pk\|has_p2pk_shape" crates --include=*.rs | grep -v _tests); spek-wallet/src/psbt.rs and the wallet binary; the RPC methods; the test-client wallet, snapshot_gen.rs, relink.rs and script_zoo; the genesis builder's private copy.

  1. Remove P2PK from production code and tools (§3.17), with no compatibility path. Every caller moves to the hashed form.
  2. is_admissible_output admits exactly plain ScriptCommit v1, HashedCommit and zero-value OP_RETURN (HashedCommitL5 arrives in E13).
  3. Test files are not migrated here. E10 and E11 migrate each fixture once, straight to the hashed form, and the hand-off lists the test targets that no longer compile.

Run: cargo build --workspace, clippy.

Exit: the production grep above is empty; the workspace builds.

E10 — Test migration: core, consensus, script, storage (about 320k)#

Scope: every test in spek-core, spek-consensus, spek-script and spek-storage that constructs headers or seeds, calls the removed functions, or uses da_commitment (re-count with grep -rln "BlockHeader::new(\|derive_election_seed(\|calculate_score()\|da_commitment" crates/spek-core crates/spek-consensus crates/spek-script crates/spek-storage).

  1. Migrate fixtures to slot_fields_for (§11.1 item 6) and update BlockHeader::new_test; move create_test_block patterns to the helper.
  2. Turn Appendix C into stake_funded_one_block_before_is_rejected (expect NotEligible { reason: NoStakeOutput }; InsufficientCoins became that variant in E6).
  3. Add:
    • stake_created_before_cutoff_is_accepted, cutoff_boundary_exact, spent_stake_is_rejected_next_block, horizon_expiry_ends_eligibility;
    • two_horizons_need_two_stake_units;
    • summed_small_outputs_are_not_stake;
    • largest_output_too_new_falls_back_to_next_counted;
    • wrong_reveal_finds_no_stake;
    • copied_commitment_gets_independent_draw (I10);
    • stake_inside_reorg_range_uses_branch_view: stake spent on the main branch inside the reorg range counts for a fork block through the reorg overlay. (Stake created inside the range can never count on the propagation reorg path: its rollback limit, 5, is below the L + 1 age a new output needs. The original stake_created_inside_reorg_range_uses_branch_view is kept as E16 drill D30, on the deep reorg path where it is reachable.)
  4. Add ticket record block validation tests: a record before and after the cutoff; a late record covering the remaining epochs; genesis epochs 0–2; a duplicate; an invalid nonce; a wrong round; a record for a round past the seed phase; a reorg removing a record; more than 16 per block; a record-shaped output after the seed phase.

Run: each touched test target by name.

Exit: those crates' targeted tests green.

E11 — Test migration: network, node, test-client; end-to-end tests (about 330k)#

Scope: the remaining test sites in spek-network, spek-node, spek-test-client and spek-consensus/tests integration targets. Complete the post-fix-only adversary modes (E2).

New end-to-end tests (harness structs):

Run: each touched target by name.

Exit: all touched targets green.

E12 — Level V vendoring and benchmark (about 270k)#

Read: §3.18 (vendoring, seeded keygen, verify cost); spek-crypto/build.rs, sqisign-c/VENDORED.md, sqisign-c/CHECKSUMS.sha256, spek-crypto-seeded; rfcs/rfc-0007-wallet-derivation-spec.md.

  1. Confirm no P2PK is left anywhere, tests included (grep -rn "p2pk_script\|ScriptType::P2PK\|validate_p2pk\|has_p2pk_shape" crates is empty; E9 removed production code and E10–E11 migrated tests).
  2. Vendor Level V (ref and broadwell) from dd133d7 with KAT and checksums; namespace sqisign_lvl5_*; the link-time duplicate-symbol check must cover both levels. Expose SqisignL5Keypair, SqisignL5PublicKey, SqisignL5Signature and sqisign_l5_verify in spek-crypto.
  3. Add the seeded Level V stack in spek-crypto-seeded behind the wallet feature, plus the RFC-0007 Level V context and an end-to-end vector.
  4. Benchmark Level I and Level V sign and verify on the minimum-spec profile (the published README spec), cold and warm, with 1000 iterations each. Record the numbers in docs/final_tests.md, set w5, and fix RHO_L5_NUM / RHO_L5_DEN in §3.18 and §10. (Done 2026-10-22; recorded in docs/final_tests_2.md, since docs/final_tests.md is closed to new records.)
  5. Tests: Level V KAT; seeded Level V vector; namespacing (both levels linked into one test binary).

Run: cargo test -p spek-crypto, -p spek-crypto-seeded, -p spek-script, -p spek-wallet, clippy.

Exit: green; ρ filled in.

E13 — Level V outputs and vsize (about 320k)#

Read: §3.4, §3.18; spek-script/src/script_type.rs, scriptcommit.rs, address.rs, vm.rs (sigop budget); spek-consensus/src/validation/block.rs (size accounting); the mempool template selector and fee-rate code; the wallet descriptors.

  1. HashedCommitL5: detection (0x52 0x20 + 32 bytes), admissibility, as_str ("witness_v2_hashedcommit"), and Bech32m v2 with a 32-byte program (versions 3–16 stay reserved).
  2. v2 spends: key path and script path (§3.4 with the L5 tags); the Level V-only CHECKSIG family in v2 leaves; SIGOP_VERIFY_COST_L5 and the v2 budget base.
  3. tx_vsize in block validation, the mempool, the template selector and fee rate.
  4. Wallet Level V opt-in (--level 5; Level I stays the default everywhere, user decision 2026-10-22, §3.18 Defaults); PSBT reveal of 161 B (RFC-0008 version 3).
  5. Tests:
    • cross-level rejection (a Level I key or signature under v2, and the reverse);
    • v2 key-path and script-path spends;
    • budget exhaustion at Level V;
    • a block at the vsize limit with v2 inputs is accepted, and one byte over is rejected;
    • the mempool fee rate uses vsize;
    • Level V address round trip.

Run: cargo test -p spek-script, targeted spek-consensus and spek-mempool tests, -p spek-wallet, clippy.

Exit: green.

E14 — Guardian and covenant families on Level V (G1-13 = yes; about 330k)#

Read: spek-core/src/covenant.rs, spek-covenant, spek-guardian, spek-config/src/allocation.rs, rfcs/rfc-0011-guardian-cancel-interop.md, the covenant drill scripts.

  1. A Level V NUMS internal key (non-canonical encoding, refused on the key path).
  2. v2 covenant templates for new descriptors.
  3. Guardian daemon Level V shares.
  4. RFC-0011 interop version bump with new vectors.
  5. Genesis allocation families on v2.
  6. Rerun the covenant drills (run_covenant.sh, run_guardian.sh, run_guardian_flood.sh) at both timelock profiles.

Exit: drills PASS and are recorded.

E14A — VOID (not doing). Density fork-choice rule#

Void (user decision, 2026-10-23). The density window is not adopted and this session does not run; E15 follows E14. The reason is recorded at §3.22 and G1-22: honest null slots dominate the window comparison, so the rule would not reach its q ≥ 0.48 gate. The original plan is kept below for the record only.

The last implementation session before testing. It closes the one residual this wave leaves below the 45% line: a private branch deeper than the restoration pin, built with in-branch stake fitting, breaks even at about q = 0.42 with 10^6 candidates per fit, and the threshold falls about 0.025 per tenfold more compute (§3.22, S9). Only a node syncing from scratch faces it, protected today by compiled checkpoints alone.

Read: §3.13, §3.22 (density window, participation cap, pin evidence rule), Appendix E (S9), rfcs/rfc-0012/sim/ (S9 script); the chain-comparison sites: spek-node/src/node/ibd_completion.rs (stop_ibd_security, ibd_candidate_work_delta), spek-storage/src/ibd_staged.rs (CandidateMetadata), spek-node/src/node/storage.rs (do_chain_restoration), and the CHC peer-chain comparison in spek-network/src/sync/.

  1. Decision gate (record the answer in §10 as a new G1 row):
    • Rerun S9 with the density rule at W in {L/4, L/2, L} and report the break-even q per W at 10^6, 10^9 and 10^12 candidates. Adopt only if W = L keeps q ≥ 0.48.
    • Count the fork-choice sites the rule touches, and check that each one already holds the headers of both chains from the common ancestor forward (IBD fetches them already; a site that does not must fetch them before comparing, inside the existing candidate budgets).
    • If not adopted: record why in §3.22, keep the checkpoint-only protection and the residual line in §7.1, and end the session there.
  2. If adopted, the rule:
    • window_weight(chain, a, W) = the sum of score(header) over heights a+1 ..= a+W on that chain, where a is the common ancestor; a null or missing slot adds 0. It is a pure function of headers already verified, using the beacons of that chain (§3.12), so every node computes the same value.
    • When two chains' common ancestor sits more than L below the lower tip, the chain with the larger window_weight wins. Equal window weight falls through to cumulative work, then to the existing tie-break (Rule 8). Forks within L keep today's cumulative-work comparison unchanged, so live propagation forks (inside PROPAGATION_FORK_WINDOW) never touch it.
    • The restoration pin still refuses any rollback below it, and the compiled checkpoints still apply; the rule decides only between chains both of those allow.
    • It is a fork-choice rule, not a validity rule: no block becomes invalid, so no wire generation bump.
    • Bounded cost: one comparison reads at most 2W headers, cached per (ancestor, tip) pair, and runs off the slot-critical validation lane (Rule 10).
    • Build window_weight as the shared helper the deferred participation cap and pin evidence rule (§3.22) will reuse.
  3. Tests in sibling _tests.rs files: a deep fork heavier in total but lighter in its first W slots loses; a deep fork heavier in the window wins; equal window weight falls through to cumulative work; a fork within L is compared exactly as before; a candidate whose window crosses the pin is still refused.
  4. Add drill D29 to E16 (below) and a §15 change-log row.

Run: cargo build --workspace, clippy, the new tests, and the targeted IBD-completion and restoration tests of the touched crates.

Exit: the decision is recorded; if adopted, the workspace builds, the new tests are green, and D29 is in the E16 table.

E15 — Whole-wave self-review and fix wave (about 300k)#

E16 — Drill campaign (coordinator about 300k; Sonnet agents run drills)#

Drill Setup PASS criterion
D1-B StakeShift as D1-A adversary share ≤ key share + 3σ over 300 slots; STAKE_AGE_REJECT present; 0 honest bans
D2-B PrivateBranch as D2-A key search gains nothing over greedy within an epoch
D3 RetroSign D3a: coalition 33% of the keys that mine over the fork range (1 dormant + 1 attacker funded speaker key against the four honest miners node0..node3; dormant keys and the late node4 never mine there, so funded-key shares overstate it), under the retro-grind threshold so the lighter branch must be refused; the first run's 2 + 1 keys were 3 of 7 = 43%, out-worked the honest chain by 1100 mslot over 40 blocks and were adopted network-wide (the no-pin rule): a chained branch from a fork point (GrindState::build_retro_branch, beam-planned over the dormant and attacker keys), delivered by speaker fork-serve (fork_serve.rs: the branch tip header every slot, then the handshake, headers, block, batch and compact protocols for the branch) to a synced node and to a fresh IBD node. Split: D3a on a live cluster, fork 40 blocks below the tip (no pin exists below height 4000, so rollback_floor() is 0); D3b on the tall fixture after the regeneration, fork below a pin at 4000, majority-key branch D3a: residual recorded (branch work against the honest chain's over the same heights); delivery shown by IBD_DIVERGENCE_ESCALATION and IBD_WORK_COMPARE on the synced node; a lighter branch is refused and the pin check reported vacuous; the fresh node ends on the honest chain; Rules 9 and 10 hold on the victim. D3b: where the branch out-works the honest chain, no node reorgs below the pin; a fresh node with no pin file has no pin, which the record states (run Oct 30: PASS; the unpinned node took the branch and, followed further in run 2, banned the honest nodes it had been syncing from)
D8 BeaconPredict an insider with funded keys mines honestly and, r slots before each beacon height (r = 1, 2, L + 1), predicts the beacon assuming it wins all r slots (a naive outsider's prediction misses with probability 1 − 2^-256 and tests nothing) a prediction matches iff the predictor's keys made every block of its run; no match at r = L + 1; at least one match at r = 1 (positive control)
D9 StakeFit 10% stake, pool 10^5 share ≤ key share + 3σ
D10 WithholdGrind 1/6 (one funded key that never moves, beside the five honest keys), evaluating attacker: at a beacon height it decides late in the slot, and when it outranks every header seen it compares its expected wins in the controlled epoch under its own beacon and the runner-up's; up to 2 withholds the beacon is evaluable: every node resolves the beacon the attacker predicted for the choice it made; at least 3 evaluated decisions; share within key share ± 3σ; mean predicted gain within the S3 bound + 3 SE (the gain bound itself rests on S3)
D11 PartitionHeal 60/40 split for 3L slots (shorter than the regtest restoration-pin interval) single chain after heal, 0 bans
D12 EclipseHeal one node eclipsed for 3L slots rejoins the network chain
D13 DeepReorgFromDisk reorg deeper than the RAM undo floor (shrunk test windows via RFC12_ENV_FILE, checked armed on every node at boot), over blocks that spend: the test wallet is funded first (the founder claims are spends, later stripped below the disk window) and keypair 2, fanned out into equal outputs first, makes a 3-input spend on both sides right after the cut and again a block later undo rehydrates from disk: the losing side's IBD_UTXO_UNDO restores spent outputs from undo loaded from disk, each walk restores exactly the inputs its reversed blocks held (read before the heal), over at least two spending blocks, the strip pass ran, and both former sides list the same unspent outputs for the wallet at one tip
D14 KillDuringCommit kill -9 during an epoch-boundary commit: blocks filled by fanout-load to widen the commit, each kill aimed from the victim's own measured commit window (BLOCK COMMITTED to CHAIN STATE VERIFICATION: the in-memory apply, post-commit hooks, atomic persist and mempool removal, not the rank-1 wait or validation) and classified before/processing/not-durable/durable/after from its log and the height its next boot loads; each kill is sent by a --pid=host sidecar that busy-waits to the aim time and signals the victim's host PID (about 1 ms late, recorded per kill; docker kill adds 45–55 ms of jitter against an ~18 ms window, and .State.FinishedAt includes the victim's memory teardown); the fill is checked alive and the window at least 5 ms wide before each kill, with at most 22 carry chains (a full block holds 23); the atomic-batch guarantee is pinned deterministically by test_beacon_height_commit_restarts_on_one_side_of_its_batch at least one kill not-durable or durable (inside the commit); restart rebuilds beacons (chain state equals node0's), tops and the ticket keyspace
D15 EpochBoundaryForks forks straddling beacon_height, merged through the propagation reorg and through the IBD path all nodes converge and resolve the same beacon from chain state; each node's last BEACON_SET names it
D16 FreshNodeIBD new node syncs 5 epochs identical chainwork
D17 RestartAcrossEpoch restart a miner across an epoch boundary no missed eligibility, no second header
D18 KeysOffline40 40% of keys stop chain continues, no stall
D19 HorizonExpiry regtest at L = S = 2 with REGTEST_HALVING_INTERVAL = 1000 (about two halvings per horizon, as on mainnet), so horizon 0 ends at height 2,048 and renewal is due at 1,804 under the default lead (~18 h; scripts/run_horizon_expiry.sh): two always-up members renew automatically, one with --no-stake-merge (coinbase only) and one with the defaults (renewal transfer); a third gets createstakehorizon before its renewal point with a gap after it; a non-member is away across the renewal point and the end, then funded at its late horizon; a fresh node IBDs across the end automatic renewal created at the lead; the coinbase-only key funds it by a winning coinbase, the default key by a renewal transfer, and getstakeaddress shows each next horizon covered by an output that counts before it is needed; no null block across the end; the gapped key stops at expiry and resumes at its next horizon; the away key mines nothing until funded and resumes after the cutoff; HORIZON_EXPIRING fires; 0 bans, one chain
D20 TicketShop seed phase, 1000 candidates ticket yield equals the honest yield
D21 JunkRevealFlood junk headers at the per-peer cap bounded verify time on the robust node's gauge; the per-peer cap holds; junk dropped (SLOT_REVEAL_REJECT); 0 missed winners
D22 LevelVBlock blocks at the vsize limit (≥ 99.5% of MAX_BLOCK_SIZE), built of COLD Level V spends (a level5-fill speaker mines its funded keys honestly and stuffs each won block with one pre-signed fill set no node's mempool has seen, so every signature is verified cold), beside an honest relayed Level V churn load (l5-load) whose template blocks fill with WARM v2 spends (the control), on nodes pinned to the minimum-spec CORE COUNT (4 dedicated cores); the heaviest v2 shape (k-of-n OP_CHECKSIGADD multisig) and its Level I twin are interleaved for the ρ ratio; one deliberately over-limit (MAX+1) block last (scripts/run_d22_level5_block.sh, BACKEND=optimized|ref) at least 20 adopted cold fill blocks each ≥ 99.5% vsize and ≥ 95% v2 inputs, and at least 15 honest template blocks ≥ 98% (else VACUOUS); every cold Level V block verify is ≥ 95% cache misses (really cold); for every filled slot on every node the winner verdict lands before the boundary — no MISSED_SLOT_WINNER, RANK1_WAIT_EXTENDED or RANK1_WAIT_TIMEOUT, one chain; the over-limit block is rejected on every node (Block virtual size … exceeds maximum) and the slot settles elsewhere; the verdict time projected onto the slowest minimum-spec box is REPORTED (decision A — core-count is pinned, core-speed is not; the authoritative check is a Phase-5 / WAN run on 4-dedicated-core cloud boxes); the cold Level V : Level I multisig verify-time ratio is reported per node. The node changes are logging-only (BLOCK_VERIFY_TIMING gains warm_jobs/l5_jobs/l1_jobs, mine_header gains MINED_TEMPLATE … vsize=, and an ADMISSION_JOB_LONG gauge); no consensus rule, wire format or limit changes
D23 TicketCensorship the censor mines two stopped launch members' keys (under their key files' slot seeds, two of three members) and omits every record; a TicketShop speaker announces a batch of n round-2 records at height 61; node3 and node4 are non-members that must register the censor's blocks carry no record; every record lands by the end of the full-coverage window (height 98), within ⌈n/16⌉ + 1 honest blocks of the announcement (+ 1 for the per-peer admission rate); node3 and node4 mine in round 2; 0 honest bans
D24 Regression subset Phase 0, 3 and 4 subsets from docs/final_tests.md same PASS criteria as their last green runs
D25 FakeSeedHeaderIBD a fresh node (joining at height 150) and a synced miner are each offered by fork-serve a fake seed-phase chain (64 self-made keys, best per height, no records, heavier than the real chain) next to the real chain; variants: fixed, rotating first block, withheld bodies; observed past the 30-minute failed-block memory delivered (else FAIL: not delivered); IBD finishes on the real chain within the existing candidate budgets; the fake candidate is refused at its first body and not re-selected in a loop; the miner keeps mining (Rule 9); header-verify cost recorded; 0 honest bans
D26 CommitmentCopy 30% attacker copying honest commitments with low-pubkey keys attacker share ≤ key share + 3σ; copied miners' share unchanged
D27 StakeDust 10^4 dust outputs plus 20 too-new full outputs at a top miner's stake address receipt lookup time on the robust node's gauge unchanged; 0 missed winners
D28 PayoutSwitch miners with and without --payout-address, one with a next horizon created via createstakehorizon uncovered current then next horizon paid first; payout address once both are covered; PAYOUT_ADDRESS_UNSET logged where unset; eligibility unaffected
D29 DensityWindow — VOID with E14A (not run) a fresh node syncs while a private branch deeper than L, built at q ≈ 0.42 with in-branch stake fitting, is offered next to the honest chain the fresh node ends IBD on the honest chain; window_weight logged for both candidates; 0 honest bans
D31 RealTargetWarm regtest at a real ticket target (2^242), seed phase 300, five nodes (scripts/run_ticket_target.sh): a node paused across a record burst (catchup), a 4-slot and a 12-slot partition across later bursts (propagation and IBD-path reorgs), a fresh node IBDing the record-bearing chain every warm path evaluates records (CATCHUP_WARM_READY ticket_evals, REORG_TICKET_WARM evaluated, IBD_REORG_WARM_DISPATCH record_jobs on the partitioned nodes, and on the fresh node's IBD the same drain dispatch plus IBD_VALIDATE_TIMING ticket_evals, since a batched forward sync applies through the reorg drain); no TICKET_EVAL verdict=valid (the commit check is cache probes only); no TICKET_WARM_STALLED; no starved winner, verify holds under the boundary wait; 0 bans, one chain
D30 stake_created_inside_reorg_range_uses_branch_view (moved from E10) regtest, 2-way partition held for more than 3L slots so the heal reorg is deeper than L + 1 (the deep reorg path, not the 5-block propagation path); on the winning side a key's stake output is created after the common ancestor and mines once it counts; on the losing side a different key is funded only on its own branch and mines there every losing-side node completes the reorg onto the winning branch, accepting the block whose stake was created inside the reorg range (eligibility judged on the branch's ledger, not the main chain's); no node counts stake that exists only on the branch it abandoned (the losing-side key's later headers are dropped as NoStakeOutput); one chain after heal, 0 honest bans

Drivers: each drill runs from one script on the 5-node ceremony compose deploy/compose/docker-compose.test-rfc0012-5nodes.yml through the shared scripts/lib/rfc12_drill.sh (frozen host binaries, ceremony genesis, tc partitions, adversary speakers as containers on the cluster network, and health counting only bans of honest nodes). D1-B run_d1b_stakeshift.sh, D2-B run_d2b_privatebranch.sh, D3a run_d3_retro_sign.sh, D8 run_d8_beacon_predict.sh, D9 run_d9_stakefit.sh, D10 run_d10_withhold_grind.sh, D11 run_d11_partition_heal.sh, D12 run_d12_eclipse_heal.sh, D13 run_d13_deep_reorg_disk.sh, D14 run_d14_kill_during_commit.sh, D15 run_d15_epoch_boundary_forks.sh, D16 run_d16_fresh_node_ibd.sh, D17 run_d17_restart_across_epoch.sh, D18 run_d18_keys_offline.sh, D19 run_horizon_expiry.sh (with run_late_horizon_fund.sh, the late-horizon funding path at seed phase 60), D20 run_d20_ticket_shop.sh, D21 run_d21_junk_reveal_flood.sh, D22 run_d22_level5_block.sh (BACKEND=optimized|ref, one backend per run; it layers deploy/compose/docker-compose.test-rfc0012-d22-minspec.yml for the 4-dedicated-core pins and funds the cold pool with fund-l5-pool + build-l5-fill, the over-the-wire load with l5-load, and reads the adopted block fill with block-vsize), D23 run_d23_ticket_censor.sh, D25 run_d25_fake_seed_ibd.sh (VARIANT=plain|rotate|withhold), D26 run_d26_commitment_copy.sh, D27 run_d27_stake_dust.sh, D28 run_d28_payout_switch.sh, D30 run_d30_stake_in_reorg_range.sh, D31 run_ticket_target.sh. Speaker keys mine under the test slot seed, so the drivers fund them with spek-test-client fund-speaker (stake or treasury address) and node keys with fund-miner; a speaker key read with read_key_file_speaker_key mines its key file's own slot seed instead (D23); spek-test-client beacon --epoch E reads an epoch's beacon off the chain for rank-miners and D9. D9 ranks ADV_STAKEFIT_POOL keys (default 4096 in the driver: each ranked key holds a slot store of about 100 KB, so 10^5 needs about 10 GB on the speaker). D26 runs at S = 60 so a copy holds for a whole segment. D27 reads its lookup cost from BLOCK_VERIFY_TIMING on the victim's blocks, the stage-9 lookup being part of each block verify.

Cloud: a regtest WAN run (short L and S) covering epoch boundaries and horizon expiry over real latency. Rehearsal 2 digests gain per-block producer, score, beacon epoch and eligibility fields.

E17 — Docs (about 300k)#

E18 — Land (about 250k)#

12. Test Vectors#

E4 fills Appendix D in this format:

slot_secret:
  slot_seed: <hex32>
  expected_sigma: <hex32>
  S: 8640
  segment: 7
  expected_s_g: <hex32>
  expected_T_g: <hex32>
  offset: 100
  expected_r_h: <hex32>
commitment:
  sigma: <hex32>
  pk_e: <hex83>
  J0: 7
  expected_C: <hex32>
  other_pk_e: <hex83>
  expected_C_other_pk_e: <hex32>   # must differ (leaf binding, I10)
  leaf_index: 3
  expected_path: [<hex32> x 10]
hashed_commit:
  pubkey: <hex83>
  commitment: <hex32>
  expected_program: <hex32>
stake_script:
  pk_e: <hex83>
  C: <hex32>
  J0: 7
  expected_Cs: <hex32>
  expected_script: <hex34>
beacon:
  L: 1440
  seed_at_beacon_height: <hex32>
  epoch: 2
  expected_B: <hex32>
score:
  beacon: <hex32>
  height: 2880
  pk_e: <hex83>
  r_h: <hex32>
  expected_distance: <decimal BigUint>
  same_inputs_other_pk_e: <hex83>
  expected_distance_other_pk_e: <decimal BigUint>   # must differ (I10)
launch_beacon:
  genesis_merkle_root: <hex32>
  expected_B0: <hex32>
  expected_B1: <hex32>
slot_root_from_reveal:
  height: <u64>
  pk_e: <hex83>
  r_h: <hex32>
  J0: 7
  path: [<hex32> x 10]
  expected_root: <hex32>          # equals commitment.expected_C for the same sigma
launch:
  network: "mainnet"
  open_anchor: <hex32>
  seal_anchor: <hex32>
  T: <u64>
  sealed_list: [<hex48> x 2]
  expected_K_L: <hex32>
  expected_seal_digest: <hex32>
  expected_launch_nonce: <hex32>
election_seed:
  parent_seed: <hex32>
  pk_e: <hex83>
  r_h: <hex32>
  expected_seed: <hex32>
ticket_record:
  round: 3
  A: <hex32>
  nonce: 123456789
  expected_script: <hex50>

13. Documentation and Rule Updates (summary)#

See E17. CLAUDE.md Rule 1's sentence "A block's score is the hash distance of its header's election_seed" becomes false at E6. E6 therefore adds a one-line interim note to that rule ("score uses the epoch beacon and the slot reveal; see RFC-0012"), and E17 rewrites the rule in full.

14. Open Questions#

15. Change Log#

Date Change
2026-10-18 Draft from the C1 deep dive: R1/R2/R3 root cause, closed forms, design A with options M2, M3, B; sessions E1–E14
2026-10-20 Rev 2: option A's public draws replaced by address-committed slot secrets; cutoff margin one epoch; ticket registry mandatory; da_commitment removed; header 705 B; rollback cap deferred; Level V option; renames
2026-10-20 Rev 3: σ derived from the miner secret key; hashed-key outputs (v1 and v2) added and P2PK removed; the stake output is a hashed v1 output (no new template, no witness-version collision); Level V hashed v2 only, with the vsize rule; ticket records as coinbase OP_RETURN outputs with keyspace state; fixed d = 10 on every network; proof pruning deferred with reasons; full byte-level spec; sessions E1–E17 at 300–330k each
2026-10-20 E1: simulations S1–S9 written and run (Appendix E). Option A findings reproduced (S1, S2, S3 part A). Withholding gain measured below 5% (G1-6 confirmed). S5 corrects the renewal figures for the 36-day lead (G1-21). S7 corrects the leaf-only copy row (below key share, griefing for victims). S9 adds in-branch stake fitting on private branches (G1-22, accepted; density window deferred). G1-4 no (M2 deferred), G1-13 yes (E14 runs). Status Accepted
2026-10-20 Rev 4 (pre-E1 review, cross-checked by a second reviewer): the score binds pk_e (I10; closes commitment-copy slot sniping); the slot seed is random and stored in the miner key file (I11); slot_commit removed, so C is derived from the reveal walk (673 B header) and the receipt order puts signature and walk before candidacy; stake is one output with a value-ordered stake_value_index (floor membership, no era rebuild); coinbase payout rule with --payout-address; B_0 and B_1 from the genesis merkle root, with ceremony commit-reveal and a deterministic build; the round bound W/2 ≥ 2L + 3 and regtest W = 60, seed phase 180; withholding is evaluable and accepted with S3 re-modelled; the horizon-bounds-retro-signing claim removed; the restoration-pin partition hazard stated, with pin evidence tracked separately; records for rounds past the seed phase invalid; ticket windows corrected to 5758/1438; leaf binding added beside score binding (both, I10); payout checks current then next horizon and requires a hidden-key payout address; MinerKeyFile::parse shared by spekd, spek-miner-keygen and snapshot_gen; new session E9 (P2PK removal before the test migrations, sessions now E1–E18); sims S7/S8; drills D25–D28
2026-10-20 E3: the per-network consensus parameters live in a network-aware ConsensusParams (CONSENSUS, REGTEST_CONSENSUS) resolved by init_economics beside the economics and read through active_consensus() (user decision; §3.1 wording made explicit). The rules of §3.1 are one const fn validate_network_params, checked at build time on both shipped pairs and at startup on an overridden regtest pair; it adds ticket_records_per_block_max ≥ 1. spek_network::sync::PROPAGATION_FORK_WINDOW now reads CONSENSUS.propagation_fork_window instead of repeating the value, and the boot rebuild of the 4-block window uses it instead of a literal 4. Consequence for drills: the old regtest shape (seed 20, W 5) and any seed-phase override that is not a multiple of 60 (1, 40, 200, 100000) are refused at startup
2026-10-20 E4: slot secrets, beacon, score v2, seed v2, hashed-commit hashing, stake script, stake value index and ticket-record codec implemented, nothing consuming them yet; Appendix D filled. Placement and API decisions: the TicketRecord codec and ceremony hashes live in spek-core/src/ticket_record.rs (the genesis builder needs them; user decision); TicketPreimageV2 is added beside v1 as a second E4 → E6 overlap (the live validator and miner have no stake program before E6; user decision); epoch and segment arithmetic is ElectionSchedule with NonZeroU64 lengths; slot_root_from_reveal takes the segment length; the requirement floor is one constant, spek_config::STAKE_REQUIREMENT_FLOOR, shared by calculate_min_coin_requirement and STAKE_INDEX_MIN_VALUE; extra SlotRevealError and BeaconError variants for the store, tracker and derivations; MinerKeyFile lands in E4 (E6 ports its readers)
2026-10-20 E5: ScriptType::HashedCommit (detection via one layout test, hashed_commit_program_of), key-path and script-path spends, last-item-length routing (pinned at compile time), the NUMS key-path refusal, Bech32m v1 programs of 32 or 97 bytes, tx_builder signing [signature, reveal], and the PSBT reveal record. Decisions: RFC-0008 becomes Version 2 with an in-band envelope "SPSBT" ‖ 0x02 and no trailing bytes (user decision; v1 had no marker); the reveal is checked against the program at parse, set_reveal, signing and finalize; a signer records the key-path-only reveal itself when its key's receive form is the program, and a non-zero commitment needs the updater's set_reveal. TransactionBuilder::add_input_with_commitment names a hidden commitment (stake output, script-path output) and refuses one on an unhashed input; the reveal is checked before any signing. The hashed key path takes exactly two items (HashedKeyPathWitnessShape otherwise) and routing precedes the annex rule. Wallet default (user decision): the hashed constructors, signers and a spek-wallet address subcommand land in E5; the regtest test wallet, genesis_gen and the test client's fee projections move in E9/E11, since a hashed key-path witness is 248 bytes against the 160 the projection assumes
2026-10-21 E7: TipElectionContext carries the beacons of epoch(tip+1) and the next epoch and is a BeaconSource; receipt follows §3.11 (claimed-score pre-filter with HEADER_RANK_PREFILTER, verify without a score, scoring at buffering from the candidacy snapshot, out-of-context headers quarantined and every held header re-scored at promotion; SLOT_REVEAL_REJECT at receipt and stage 9). IBD candidate beacons come from the candidate's own staged headers (CandidateBeacons, seed proven by one link step; new BeaconError::SeedUnlinked); ValidatedHeader takes the caller's beacon source and the staged prefix copy sweeps from the genesis header, so ibd_staged stays storage-only. §3.11 amended: no header-stage reveal walk in IBD (user decision: it can only fail the horizon check validate_basic already runs). BEACON_SET logged on beacon-height commits. RPC and proto header views gain the seed, slot fields and a derived slot_root; gRPC GetBlock chainwork is per block.
2026-10-21 Session E14A added between E14 and E15: the density fork-choice rule (§3.22) is considered as the last implementation step before the drill campaign (S9 rerun per window, decision recorded in §10, then the window-weight comparison for forks deeper than L if adopted), with drill D29 in E16. E2 closed: D1-A (100% of 298 settled slots after the first win) and D2-A (26-deep reorg at an ~89% key share; beam gain 332 millislots over greedy) recorded in docs/final_tests_2.md.

| 2026-10-21 | E8: stake horizons (<data_dir>/stake_horizons, boot refuses a commitment the key does not derive; the first horizon is J0 = 0 at a genesis tip, user decision, else g(tip + 3·L); renewal at max(J0 + 2^d − 2, g(tip + 3·L)), built on a blocking thread and merged by an event-loop select arm), the coinbase payout rule with --payout-address (the node has no config file, so §3.5's config key is dropped) and COINBASE_PAYOUT carrying the paid horizon, the ticket-records topic, pool and Mempool-lane admission (an invalid nonce is a violation only under a seed buried propagation_fork_window deep), own records re-announced every 60 s (under the regtest always-pass target, nonce 0 per open round, without which regtest stops after round 0), producer inclusion, getstakeaddress and regtest createstakehorizon (no proto change: the proto carries no mining or stake messages). Genesis: CeremonySet admission, the set hash and the deterministic ceremony build; the tools run the commit, set-hash and build steps (spek-miner-keygen --ceremony-record, spek-genesis-gen --ceremony-set-hash, then --expect-set-hash, user decision). The compiled genesis signatures for all three networks were regenerated (stale since E6's header change; merkle roots unchanged); records in the compiled networks' genesis wait for the launch ceremony's substitution (E18). E8 item 6 amended: a horizon end is unreachable in a desktop run (fixed d = 10), so expiry and renewal are unit-tested and the smoke covers creation, funding and reload. | | 2026-10-21 | E9: P2PK removed from production code with no compatibility path (§3.17): ScriptType::P2PK, p2pk_script, has_p2pk_shape, validate_p2pk, P2PK_SQISIGN_SIZE and the now-unused ScriptValidationError::{InvalidSignatureLength, InvalidPublicKey} are gone; is_admissible_output admits exactly plain ScriptCommit v1, HashedCommit and zero-value OP_RETURN, and a bare <push 65> <pubkey> OP_CHECKSIG script is NonStandard (refused at creation, unspendable). The PSBT signer and TransactionBuilder handle the two spendable forms only. RPC: the pubkey:<hex> UTXO-query locator is removed (every spendable form has an address; the coinbase pays hidden-key addresses since E8), so listunspent/listunspentcount take addresses only (resolve_address_script). spek-wallet new warns in stake-address terms. Test client: fund-miner and snapshot-gen pay the miner's stake address of an explicit horizon, built from the key file's slot seed (fund-miner --horizon-start, snapshot-gen --miner-horizon-start; user decision, same shape as rank-miners); the adversary coinbase pays the key-path-only hashed form and the stake move spends and recreates speaker-horizon stake outputs (speaker_stake_script); craft.rs signs HashedCommit key-path inputs with [signature, reveal] and refuses any other non-ScriptCommit script; wallet_smoke uses the hashed receive form with a size-scaled fee (HASHED_KEY_PATH_WITNESS_BYTES). The genesis builder already carried no P2PK copy. Exit grep: integration targets under crates/*/tests/ are test files (E10/E11 migrate them); the production grep with those excluded is empty | | 2026-10-21 | E10: tests in spek-core, spek-consensus, spek-script and spek-storage migrated to the 673-byte header and the RFC-0012 rules; one shared eligibility fixture, spek-consensus/tests/common/eligibility.rs (user decision), reached by the integration targets through tests/common and by the lib tests through a #[path] include, with real slot fields under any test horizon, stake programs derived as stage 9 derives them, ticket-record registration, stake funding and spend signing, committable genesis blocks and null-block filler. New regtest targets: stake_eligibility_integration (items 2 and 3) and ticket_record_block_integration (item 4: counting age before and after the cutoff, a late record, genesis records over epochs 0–2, in-block and on-chain duplicates, a nonce failing a real target, rounds not open and past the seed phase, more than 16 records, a record after the seed phase, a reorg removing a record). Two amendments to the E10 entry: the Appendix C test expects NotEligible { reason: NoStakeOutput } (E6 replaced InsufficientCoins), and the spend case stake_inside_reorg_range_uses_branch_view is added beside the original, because PROPAGATION_FORK_MAX_ROLLBACK (5) is below the L + 1 age new stake needs, so stake created inside a propagation reorg range can never count there; the original stake_created_inside_reorg_range_uses_branch_view moves to E16 as drill D30 on the deep reorg path (user decision). Deleted with the code they tested: the da_commitment tests. Targeted runs only | | 2026-10-22 | E11: tests in spek-network, spek-node, spek-mempool, spek-rpc, spek-covenant, spek-wallet and spek-test-client migrated to the 673-byte header, beacon scoring and the two spendable forms (test migration completed in an earlier session). The six post-fix adversary modes are completed and run live from the CLI (user decision: a mode that is only a tested-but-uncalled function is a stub): StakeFit, CommitmentCopy, StakeDust and TicketShop create on-chain state through adversary_postfix::TreasurySpender, which builds, signs and submits funding transactions from one pre-funded treasury coin over --rpc-url and chains the change tx-to-tx; JunkReveal and WithholdGrind are adversary_grind publisher variants. Ten end-to-end tests at the layer each invariant lives: beacon_fork_invariance, chainwork_paths_agree, tip_context_horizon, junk_reveal_header_dropped_within_verify_cap (spek-consensus beacon_fork_integration); prefilter_drops_unrankable_headers_before_verify (spek-network); commitment_copy_has_no_share_gain and stake_dust_does_not_slow_receipt through the node's real receipt gate candidacy_receipt_verdict + rank_prefilter (spek-node rfc0012_receipt_tests, user hard requirement); coinbase_payout_switches_when_covered (spek-node stake_horizons_tests); ticket_record_relay_and_inclusion (spek-node ticket_record_relay_tests); private_branch_key_choice_has_no_effect_within_epoch (spek-test-client adversary_grind_tests). TreasurySpender has an in-process test against a real SpekRpcServer on a loopback socket admitting to a live mempool (user hard requirement). Deferred: STAKE_AGE_REJECT (§3.21) stays OUT of E11 (no e2e test needs it; StakeFit's expectation is "STAKE_AGE_REJECT or no gain") and is added in E15 as a debug-gated, rate-limited classifier beside the existing SLOT_REVEAL_REJECT sites (handlers.rs candidacy gate, block.rs stage 9) — it needs no change to check_coin_requirement or find_stake_output, only a second bounded walk when find_stake_output returns None. Targeted runs only | | 2026-10-22 | E11 review fixes. Tests: the E11 end-to-end tests now run the production paths their names claim. ticket_record_relay_and_inclusion drives the receipt check and verdict action the handlers call (record_receipt_check, record_verdict_action, extracted from them), a real RandomX check, the pool, the producer's select_coinbase_records, the mined coinbase and validate_utxo under a real target, then the commit; the receipt tests build headers with VerifiedHeader::verify, judge them with candidacy_receipt_gate_on (the body of the node's gate) against a populated buffer, show the copier's two headers are an equivocation (one draw per key) and count the eligibility walk's steps (UTXOSet::stake_walk_steps, testing feature); chainwork_paths_agree moved to spek-node, scoring the IBD path through CandidateBeacons and the restoration through load_chain_state over deliberately wrong stored chainwork; beacon_fork_invariance and the private-branch test use real branches and chain beacons; a propagation reorg whose fork holds a beacon height is checked against a sweep; tip_context_horizon checks the beacon values and an epoch below the context; the junk-reveal test became junk_reveal_flood_is_bounded_by_the_per_peer_verify_share over the extracted lane gate header_verify_lane_admission; the payout test drives plan_rule_payout (extracted from autonomous_payout) on committed coinbases; ticket-record persistence has four storage tests. Code: PAYOUT_ADDRESS_UNSET reports largest_stake_output (one index read) instead of a balance sum under the mining read lock; the propagation reorg's off-loop warm also runs SeedTicketVerifier::prewarm_records, so the record checks under the chain-state write guard are cache probes (parallel record evaluation stays E15); an invalid-nonce verdict is punished only when the round seed is buried AND still the main chain's; the record verify in-flight set is exact (a shed job is removed at submit, a panicking check reports Unavailable) instead of cleared each slot; the boot rebuilds the ticket-record registry from the main-chain coinbases after a snapshot recovery (not when the keyspace is empty, which is a legitimate state), clearing rows the interrupted rewrite left, and refuses to boot when pruned bodies leave it incomplete inside the seed phase. Targeted runs only | | 2026-10-22 | E12: SQIsign Level V vendored from dd133d7 (ref and broadwell, KAT, checksums) and linked beside Level I on both backends; spek_sqisign_build::add_variant_stack selects each level's sources for the production and seeded builds. Linking two levels needed three namespacing patches beyond §3.18's list: gf/ref ONE/ZERO, and broadwell's p/p2 prime tables, which each level's assembly reads (unnamespaced, one level's assembly binds the other level's prime). spek-crypto exposes SqisignL5Keypair/SqisignL5PublicKey/SqisignL5Signature/sqisign_l5_sign/sqisign_l5_verify over one level-generic FFI core, with a level tag on every function table checked before each call. Two findings beyond the plan: Level V keygen and signing need about 3 MiB of stack (more than the 2 MiB default worker stack; an overflow aborts), so they run on a scoped 8 MiB thread; and the C signing budget of 300 ms would abort a Level V signature at its first retry, so it is 1.5 s at Level V. Seeded Level V keygen and the RFC-0007 Level V context with an end-to-end vector (RFC-0007 also gets its Level I vector corrected; it was stale since the rename). ρ fixed at 171/50 from the largest measured verify ratio across backends, w5 = 6.754 (§3.18, G1-11, Q1). Records go to docs/final_tests_2.md. | | 2026-10-22 | E13: Level V outputs and vsize. HashedCommitL5 (OP_2 <push 32> <A5>, "witness_v2_hashedcommit") is detected, admissible and addressable (Bech32m v2 with exactly a 32-byte program; v3–16 stay reserved). v2 spends: key path [sig292, reveal161] under tag SpekHashedCommitL5; script path with a 129-byte internal key (control block 130 + 32k, never 161) and leaf commitments under SpekScriptCommitL5; v2 leaves run the VM at SignatureLevel::Level5, so every CHECKSIG-family opcode takes only 129/292-byte keys and signatures, debits SIGOP_VERIFY_COST_L5 = 421 and starts from base 421. The signature cache key gains a level byte (BLAKE3(level ‖ pk ‖ msg ‖ sig)): without it a proven Level I triple could answer a Level V lookup of the same bytes under a different message length. vsize (spek_script::vsize, the §3.18 integer form, total and saturating; an unparseable v2 witness counts its wire length): block stage 11 sums BLOCK_HEADER_SIZE + coinbase + Σ tx_vsize as each transaction's inputs resolve and rejects BlockVsizeTooLarge before that transaction's covenant rule and signature jobs; the covenant fee allowance is sized on vsize. Mempool: the fee floor, fee rate, RBF, package and eviction aggregates and the template budget are vsize (only the memory cap counts wire bytes); VerifiedAdmission.vsize_surcharge, MempoolEntry.vsize, persisted mempool version 5 with a post-load re-derivation (MEMPOOL_VSIZE_CORRECTED); build_applicable_block_template returns BlockTemplate { transactions, total_fees, total_vsize } and the miner's last check tests raw size and vsize. Wallet: PSBT format version 3 (RFC-0008) with per-input level fixed by the spent script (148/97 or 292/161), set_reveal_l5, sign_input_l5, LevelMismatch; the transaction builder and the test-client crafter refuse v2 inputs (Level I signers); a --payout-address may be v2. Amendment (user decision): Level I is the default everywhere, cold storage included; Level V is opt-in via spek-wallet pubkey|address --level 5, refused with --miner-key-out (§3.18 Defaults, E13 item 4). The Level V NUMS key and its key-path refusal stay in E14. Appendix D gains the hashed_commit_l5 vector. Targeted runs only | | 2026-10-23 | E14: guardian and covenant families on Level V. The Level V NUMS internal key allocation_internal_pubkey() is a compiled constant (marker SPEK-ALLOCATION-NUMS-L5, BLAKE3("Spek//allocation//NUMS//no-key//l5"), bytes 63 and 127 forced to 0xff so neither field half is canonical; Appendix D nums5); a v2 key-path reveal naming it is refused (NumsKeyPathRefused). The Level I NUMS key, its refusals and every v1 covenant path are deleted. All eight covenant templates are 34-byte v2 outputs under NUMS5; genesis covenant data version 2 carries all eight commitments and the registry requires every allocation output to equal its family's vault template. All 28 ceremony keys, guardian shares and heartbeats and the guardian policy signer are Level V (spek-keygen --level 5; leaves via PUSHDATA1, council 662 B, founder 398 B; ceremony digest domain v2). Guardian cancel (RFC-0011 version 2): exactly veto_m signatures, fee = floor of the exact vsize at the staged creation height (steward 5,952 vsize → 496,000 bits, founder 3,046 → 253,833 at epoch 0), unsigned cancel 122 B. spek-covenant plans (RFC-0008 §9, plan version 2) price on the assembled vsize. vault_fee_allowance = 4 × floor(h) × max(vsize, 3,000) / 3,000 with one integer division (it rounded the floor first, which left the allowance below a creation-epoch cancel fee at the last fee epoch); the largest covenant transaction, a stewardship release of eleven outputs, measures 64,732 vsize. Decisions (user): covenant data v2 with all eight commitments; Level V NUMS as a constant, no Level I paths kept; every allocation and guardian key Level V; exact-veto_m cancel assembly. Genesis constants regenerated for all three networks. The vesting cluster builds its genesis through the §3.14 ceremony. Drills run_covenant.sh, run_guardian.sh, run_guardian_flood.sh at staged-release delays 15 and 20: 6/6 PASS (docs/final_tests_2.md). Targeted runs only | | 2026-10-23 | E11–E14 review fixes. Code: the signature warm-up (build_sig_jobs_for_warmup(_with_overlay), run before validate_utxo at every warm site including the slot-critical rank-1 path) applies the §3.18 block rule as a lower bound (header + every byte of the block + the Level V weight of each input as it resolves) and returns no jobs once it passes MAX_BLOCK_SIZE; before, a block under the raw limit but over the vsize limit had every Level V signature verified before stage 11 refused it (about 3× the worst legal block's verify time). The bound never exceeds the stage-11 vsize, so no valid block is left cold. Both relay doors price the pre-signature gate through one MempoolValidator::resolved_vsize. The raw SQIsign wrappers check their buffer sizes against the level's encoded sizes as well as the table's level; the unused SqisignL5PublicKey::is_well_formed is removed. spek_core::slot_secret exposes the commitment-tree builder (slot_tree_root_and_path) and the first half of the verifier walk (segment_top_from_reveal, now used by slot_root_from_reveal), shared by the CommitmentCopy mode and its receipt test. Tooling: TreasurySpender packs a mode's outputs into fan-out transactions (StakeDust's default 1,020 outputs used to chain 1,020 unconfirmed transactions and stop at the 26th) and pays each one's size-scaled floor; StakeDust's too-new outputs hold the requirement at the next height (they held the floor, below the walk range); test-wallet-smoke --level 5 spends Level V key-path outputs live through set_reveal_l5 and sign_input_l5 with the fee on vsize. Tests: the warm-then-validate sequence costs an over-vsize block with real Level V signatures zero real verifies (and a block at the limit warms and then hits the cache); RBF, CPFP aggregates, eviction order, the pre-signature gate, the persisted vsize and its reload correction each run with vsize ≠ wire size; the propagation-reorg test compares against a chain that committed the fork through the production commit seam. Targeted runs only | | 2026-10-23 | E14A void (user decision): the density fork-choice rule is not adopted, the session does not run, D29 is void, E15 follows E14 (§3.22 records why: honest null slots dominate the window). Automatic stake merge (§3.6, §3.16; node policy, on by default, --no-stake-merge): a mining node whose stake address holds no single output of the payout target but indexed outputs that reach it together (at most 64, largest first, plus the fee floor of the projected size) spends them to one output at the same address, signed and verified on the validation pool's Mempool lane and relayed through the node-origin channel; summing outputs in stage 9 was reconsidered and kept rejected. Relay policy: one relayed transaction that spends a v2 output and fails its signature is an immediate ban (invalid_level5_tx), not a strike; the same failure without a v2 input stays one strike. Targeted runs only | | 2026-10-23 | E16 pre-regen review fixes. D31 RealTargetWarm added (the record checks and their per-path warms had never run on a cluster: every drill ran the always-pass target) with scripts/run_ticket_target.sh; D19 specified at L = S = 2 with scripts/run_horizon_expiry.sh, to run before the tall fixture regen so the tall generator relies on the node's own renewal; both on the new 5-node ceremony compose deploy/compose/docker-compose.test-rfc0012-5nodes.yml; CATCHUP_WARM_READY reports ticket_evals. Mempool policy, no consensus change: a covenant spend (every input a confirmed covenant output, not a pure cancel) is admitted in the protected AdmissionClass::CovenantSpend, without the capacity feerate contest and never evicted, aging through the oldest-first template lane, at most COVENANT_SPEND_POOL_CAP_PER_FAMILY (8) per family and within the lane's 10,000-byte package bound. Reason: past the 3,000-byte flat-floor anchor a Level V covenant spend pays at most ~333 bits/vbyte at the 4x covenant allowance (~167 at the spend tool's 2x), below what any transaction under the anchor pays at the floor, so a full pool, honest or hostile, held every council and founder movement out. The 4x allowance and the tool's 2x cap are unchanged (raising the tool to 4x would break its halving-crossing margin). The squat drill now prices the staging at 1x and asserts its protected admission on both nodes. | | 2026-10-24 | E16 review fixes. Stake-horizon creation (first horizon, renewal, createstakehorizon) measures from selection_tip = max(main-chain tip, the clock's settled tip): a node booting or resyncing behind the network chose its next horizon from wherever its catch-up had reached when the slot ticked (D19's away key could renew at the overlap start the chain had already left); a genesis data dir keeps J0 = 0 only while horizon 0 is live by the clock. Node policy, no consensus change. The RetroSign builder now plans a chained branch from a past fork point (it built one sibling block per real parent); D3's row states the delivery and when its pin check is vacuous. D31's IBD check reads the drain dispatch a batched forward sync uses. | | 2026-10-25 | D19 first full run FAILED on its own design: at L = S = 2 with the regtest halving interval of 50, horizon 0 spanned 41 halvings, every coinbase past height ~451 was below the 0.1-coin floor (stake outputs never add up), so no renewal could be funded; every key's horizon 0 ended at 2,048 together and the chain committed only null blocks after it. Fixes: REGTEST_HALVING_INTERVAL (regtest only; D19 sets 1000, mainnet's ratio of about two halvings per horizon); §3.5 renewal transfer (the stake merge moves surplus from the current horizon's stake address to an uncovered next horizon, never spending the counted output or the largest one at the target); renewal lead configurable, --horizon-renewal-lead-segments, default 122 segments (about one year, user decision; was 12, about 36 days), G1-21 and S5 updated; §3.5 corrected: one coinbase funds a horizon only while the reward is at least 0.125 coin (9 mainnet halvings, about 36 years), not "the whole schedule except the final tail"; the requirement max(80% of reward, 0.1 coin) is unchanged (the floor is the key price). D19 now checks funding from getstakeaddress (the earlier coinbase-funded assertion matched a 1-bit payout log line). | | 2026-10-26 | E16 M runs, first drills. BEACON_SET (§3.21) is now logged on every path that commits a block at a beacon height: the propagation fork reorg runs the per-block post-commit hook for its new tip only, so a shallow reorg replacing the block at a beacon height logged nothing and an operator kept the old beacon in the log (the chain state was always right: beacon_for_epoch reads the main chain). Logging only, no consensus change. D13, D14 and D15 rows revised with their drivers: D13 receives its shrunk storage windows through the compose's RFC12_ENV_FILE and checks them armed; D14 widens the commit with filled blocks, aims at the measured window and classifies each kill, with the batch atomicity pinned by a storage test; D15 compares chain-state beacons on all nodes. Second revision, same day: D14's window is the commit proper (BLOCK COMMITTED to CHAIN STATE VERIFICATION), since the span from the slot transition is mostly rank-1 wait and validation, where a kill commits nothing; a kill inside it is not-durable or durable by the height the next boot loads. Third revision: the D14 commit window on full blocks is about 15 ms and docker kill takes about 33 ms to land, so kills are sent early by a delay measured under load and each records its actual death time; D13 now runs over spending blocks, since coinbase-only blocks carry no undo and neither the disk undo walk nor the strip pass had anything to act on, and IBD_UTXO_UNDO reports the spent outputs restored and how many came from disk (logging only). | | 2026-10-28 | E16 M-run review. D10 rebuilt: the previous withholder withheld blind at its first beacon heights, never checked it would have won them, and moved its stake to a fresh key with every block (the StakeShift path), which left it ineligible for epochs at a time; its share fell to 0.033 against 1/6 and an upper-bound-only check passed it. The withholder now keeps its stake still, decides late in the slot from the headers seen, and logs both predicted beacons and expected wins (WITHHOLD_EVAL); the drill checks the network resolves the predicted beacon, a two-sided share and the mean predicted gain against S3. The row's 20% is corrected to the drill's 1/6. D14 kills from a --pid=host sidecar and keeps at most 22 carry chains, since 26 overran the 23 a full block holds and the fill died mid-run. D13 makes 3-input spends in two blocks per side and checks the restored count exactly. D17's same-slot check counts distinct own header hashes and requires the re-announce, since a working guard pushes the identical header twice. The node fills its status snapshot before the RPC server binds, so a status read right after boot no longer returns the zeroed placeholder (no consensus change). | | 2026-10-28 | E16 step 6, adversary lane (code and drivers; drills not yet run). D3 split into D3a (live cluster, no pin below height 4000, so the pin check is vacuous there) and D3b (tall fixture after the regeneration); D3 shares 25% dormant and 12.5% attacker for the drill's 5 + 3 keys. D8 redefined: the meaningful predictor is an insider whose prediction matches iff it wins every slot of its run (exact rule, no match at L + 1, positive control at r = 1); "never matches" passed vacuously. Speaker fork-serve (spek-test-client/src/fork_serve.rs) delivers past-slot chains through the sync protocols; RetroSign is live on it with funded keys (its dead-code allowances and wire refusal deleted), FakeSeedChain added for D25. Speaker keys gain a key-file slot source so the D23 censor can mine launch-member keys. Drivers D3a, D8, D23, D25. No node, consensus or wire change | | 2026-10-28 | E16 lane runs (D14, D19-F, D23). Chain store: a kill inside a large batch's WAL append left a torn tail that AbsoluteConsistency treated as fatal, so the node never reopened; recovery is now PointInTime (the store reopens at the last whole batch; the dead recycle_log_file_num setting is gone). Restart settlement: a node killed while committing slot C-1 and booted into slot C committed the canonical null over the network's real C-1 and mined C on it, because the unobserved-slot guard exempted a node with 0 peers (a restarted node has none at its first tick) and its boot detector tested a slot tracker the stored tip now seeds; the first observed boundary is now fixed on the first tick before any settlement, the guard ignores the peer count, and the withheld slots are tracked as a range so the backstop still carries a lone node. Connection dedup: a link established 5 s or more before a peer's newest link is superseded, since the race rules kept a killed process's link (open until the 30 s idle timeout) and closed the restarted peer's fresh one. Ticket records refused for the deliverer's rate window, verify capacity or the tip are un-seen in FloodSub, which otherwise suppressed every later copy (D23: 4 of 48 landed). D19-F's lapse premise is impossible by design: one seed-phase win funds the key's horizon through its coinbase (one reward exceeds the 80% requirement), so only a horizon end lapses a key; node3 now joins after the seed phase and the drill checks the funded horizon's horizon_start. The TicketShop speaker paces one record per 300 ms. No consensus or wire change | | 2026-10-28 | E16 D3a and D17 re-runs. IBD: a same-chain candidate held on a Less verdict with no peer left now expires at once (kept_less_hold_expired takes has_peers), and a held candidate no longer counts as the synced winner that retires an empty sibling. Before, the speaker's losing branch held a fresh node's session open, catchup (which waits for the session) could not fetch the block of the slot the node booted into, and the honest sibling carrying it was retired after one second, so the node sat one block behind and could not mine. Drivers: D3a and D16 wait up to TIP_MATCH_MAX_SLOTS (6) for the fresh node to match node0 (wait_tip_match, one same-slot sample per slot), since after the restart-settlement change a fresh node trails by one block for up to two slots; D17's same-slot rounds fire only on a slot the victim mined by the stop offset (searching SAME_SLOT_SEARCH_SLOTS, 4) and require both the SIGTERM and SIGKILL round. A withheld slot's catchup runs in the next slot's phase 2 (block requests are refused in phase 1), so a restarted node mines late for about one slot; left as designed. No consensus or wire change | | 2026-10-30 | E16 D3b driver written and PASS on the tall fixture: a branch forking at 3950, +1,079,086 mslot heavier, is refused by every pinned node at the checkpoint barrier or hard checkpoint, and an unpinned fresh node takes it. D22 run 6 measured two hops and pool sizes 2, 3 and 4 (verify 6.6, 4.5 and 3.5 s, second-hop verdict 17.8, 15.6 and 14.7 s); the per-signature L5:L1 ratio read 7.0 to 7.8 against w5 = 6.754, under review with the ref run. D24 orchestrator added; header-publishing adversary modes now require a listed key, and run_seedticket moved to L = S = 2, W = 14. | | 2026-10-30 | Identity leaves signature bytes. SQIsign signatures are third-party malleable at both levels (the Round 3 specification, §8.4.2, states the scheme cannot be strongly unforgeable), so BlockHeader::hash now covers signing_bytes only and wire_hash keys every dedup that runs before a signature is checked; the txid covers everything sighash signs under the spek/txid/v1 tag, for every transaction including the coinbase (whose lock_time is pinned to the height), and the merkle leaf and compact short ID use the wtxid over full wire bytes. MAX_STANDARD_TX_VSIZE caps relay policy and the conflict gate now runs at any mempool occupancy, before signature work. I2 and every score input are unaffected: none of them hashes a header hash. Because the genesis hash no longer covers the random signature, the genesis coinbase input commits a launch nonce (spek/genesis-launch/v1), bound by the set hash (GENESIS_SET_TAG now spek/genesis-set/v2); without it two launches of the same keys and records (rehearsal #2 runs on the mainnet key set) would share a genesis hash and so a sighash domain. Appendix D's set-hash vector is regenerated. | | 2026-10-30 | SQIsign moved to the round 3 parameters (upstream nist-v3, specification 2026-09-01). The Wesolowski endomorphism-ring attack lowers the classical cost from about p^(1/2) to p^(1/3), so the round 2 primes no longer met their targets: Level I is now p324_3 and Level V p664_17. Level I keys 83 B, signatures 200 B, secret keys 270 B; Level V 169, 406, 549 B. The header is 743 B with a 543 B signed prefix; reveals are 115 and 201 B; SIGOP_VERIFY_COST_L5 is 575; the plain ScriptCommit output is 118 B. Sizes elsewhere in this change log are as written at the time. ρ is re-measured on the new parameters. The vendored tree carries the hardening again on the new code, including the bound on the hint searches, which round 3 still lacks (VENDORED.md) | | 2026-10-31 | The launch nonce is committed before the reveal. It feeds the genesis merkle root and so B_0 and B_1, and §3.14 drew it at the set-hash step, after the records were revealed: the organiser could redraw it until the launch beacons favoured its own records, although the text said it was chosen before the reveal. Step 1 now publishes BLAKE3("spek/launch-nonce-commit/v1" ‖ nonce) in the commitment list, CeremonySet::admit refuses a nonce whose commitment the list lacks (UncommittedLaunchNonce) and the set carries the admitted nonce into the set hash and the build. Tools: spek-genesis-gen --commit-launch-nonce (step 1) and --ceremony-set-hash --launch-nonce. | | 2026-11-04 | Stake review fixes (node policy, no consensus change). sweepstake RPC (all networks, trusted bind): the withdrawal path out of a stake address, by horizon_start from the horizons file (live or ended), through the merge's own sign and submit code, keeping the counted output by default. The refusal memo is per plan: a repeating refusal holds until the outputs change, a temporary one (full mempool) backs off 1, 2, 4, ... slots up to one epoch. A destination with a pooled output paying it at least the target counts as covered for the renewal transfer. HORIZONS_FILE_MISSING at boot past genesis. A coinbase whose reward is under the index floor pays the payout address when one is set. STAKE_TRANSITION_UNFUNDED names coinbase maturity. HORIZON_EXPIRING fires only while the next horizon is not funded and names the no-surplus case. Addresses carry no network (§3.5). | | 2026-11-05 | E17 docs. CLAUDE.md Rule 1 restated as the §2 invariants (I1–I4, I7, I10, I11, derived C); Rule 10 already said catchup, and no 349 B or 673 B figure remained. Design docs: Event 1 data flow and MinerCandidate now show seed v2 and beacon_score; the candidacy gate text states the on-tip strike instead of a silent drop; 05 gains the IBD candidate-beacon and pre-rollback eligibility-screen sections; the v1 seed formula is gone from 04, 05 and 05_2; the requirement security model prices stake outputs and RandomX records instead of keys; miner key file 385 B (was 450, the Round 2 key size) in the white paper and docs; Level V NUMS byte positions and field size for Round 3 (bytes 83/167, 84-byte halves, top byte 0x10) in 04, RFC-0011 and three code comments; RFC-0011 guardian secret 549 B. White paper §11.3 states the on-tip strike. RFC-0007 pins f417ebd and freezes at the launch release; RFC-0007, -0008 and -0011 freeze at the launch release (public testnet skipped). countdown §B3 and §B4 refreshed to the as-built state. The remaining "finality" vocabulary for the propagation fork window and the catchup walk (§3.19 renamed only the constant and "finality catchup") is gone from code, scripts and design docs: CATCHUP_WALK_DEPTH, MAX_CATCHUP_CONSECUTIVE_FAILURES, RequestCatchupHeaders/RequestingCatchupHeaders, ForkWindowBlock, OutsideForkWindow, install_fork_window and the matching log and ban-reason text (fork_window_violation, genesis_in_fork_window); the 05_2 "Finality Levels" table, which called 4 blocks technical finality, now points to Appendix B. No code behaviour change | | 2026-11-06 | Open launch (option B). The commit-reveal ceremony left the organiser an informed abort per record, because only it knew the launch nonce while the reveals arrived (the §3.14 claim that the nonce gave it no choice beyond the abort bits was wrong), and it limited genesis records to invited members whose tickets nothing checked ("valid by construction"). Genesis records are now round-0 tickets under a launch key K_L = BLAKE3("spek/launch-key/v1" ‖ network ‖ open anchor) from a Bitcoin block hash, searched openly, submitted publicly, sealed by a witnessed digest (spek/launch-seal/v1) over the list, T, message and allocation, and selected by lowest ticket hash, one per A, up to 1,973. The launch nonce is derived (spek/launch-nonce/v2 over K_L, the seal digest and a Bitcoin seal anchor mined after the seal), not drawn. Removed: CEREMONY_COMMIT_TAG, LAUNCH_NONCE_COMMIT_TAG, GENESIS_SET_TAG and the set hash (the published merkle root and genesis hash are the build checksum). Anchors and the seal digest are compiled spek_config::genesis constants checked by --verify-substitution (D1). Decisions D1–D10 (user, as recommended). Appendix D's ceremony vector becomes the launch vector. Tools: spek-miner-keygen --launch-search (keeps the lowest valid ticket, nonce 0 under always-pass) and spek-genesis-gen --launch-key, --check-launch-ticket, --seal-submissions, --launch-select, --audit-launch; --verify-substitution adds verify_compiled_launch. The compiled anchors are one LaunchAnchors constant per network. scripts/lib/ceremony_genesis.sh keeps its command line, runs the launch under always-pass and records its anchors in launch.txt; the vesting and E8 composes now call it instead of inlining the ceremony. | | 2026-10-23 | E15 whole-wave self-review and fix wave (diff since fed29a1 against §7.2 and the threat model). Clean: I10 (one score function, beacon_score, binds pk_e; no other preimage builder) and I11 (slot seeds only from getrandom, never from the SQIsign key, Zeroizing, owner-only key file; σ, tops and checkpoints never logged or served); the verifier walk (horizon check before any hash, ≤ S steps, path exactly d); index parity (live and overlay walks share one predicate and order); no floating point; record and witness parsing panic-free. Fixed: ticket-record RandomX checks were sequential inside validate_utxo, so unrelayed records (IBD, catchup and reorg bodies, a producer's own records) were evaluated one by one under a chain-state guard, on the event loop on the IBD and catchup apply paths. Every validate_utxo site now warms them first, off-lock and parallel on the pool as §3.14 states: SeedTicketVerifier::record_warm_jobs + prewarm_record (replacing the sequential prewarm_records), run by ValidationPool::prewarm_ticket_records at the site's tier (SlotCritical rank-1 verify, Catchup reorg and catchup windows via window_record_warm_jobs, Ibd drain, where a record-carrying coinbase-only block is no longer applied cold inline); BLOCK_VERIFY_TIMING/IBD_VALIDATE_TIMING gain ticket_evals. Added STAKE_AGE_REJECT (§3.21): note_stake_age_reject at the candidacy gate and stage 9, debug-gated and budgeted per height, over StakeOutputSource::classify_stake_miss (the lookup's entries plus one; the overlay's merged walk is now one LedgerView::stake_walk shared with find_stake_output). The boot restoration test now asserts the restored stake value index equals the committed one (§7.2). slot_commit_root and slot_path (test-only callers) are compiled for tests and the testing feature only. Stale v1-seed, P2PK and banned-term comments corrected; one issue label removed. Tooling: spek-test-client hashed-payout switches a test-wallet key to the key-path-only HashedCommit form --payout-address accepts (the WAN load driver lives on mining income and its P2PK graft went with E9); run_stakeshift.sh enables the STAKE_AGE_REJECT target and tallies reasons per node. The stake merge and the Level V ban rule (added after the E11–E14 review) were reviewed: the merge's walk and fee are bounded; a Level V SignatureInvalid is view-independent (time-lock and staged-release failures are separate, non-punishable errors), so the immediate ban cannot hit an honest relayer. Targeted runs only |

16. References#

Copyright#

This RFC is dual-licensed under MIT and Apache 2.0.


Appendix A — Race model and closed forms#

Normalise a block's deficit D = (2^255 − score) / 2^255. The best of n independent draws has D ≈ Exp(1)/n. With N eligible online keys and attacker share q, scaling by N:

Per-slot step X = honest_deficit − attacker_deficit. The attacker's branch is heavier over a window when the window's sum of X is > 0. For b < a the walk drifts down, and its supremum W satisfies P(W = 0) = 1 − ρ, otherwise W ~ Exp(θ), with ρ = b/a and θ = a − b (M/M/1 identity; this ρ is unrelated to §3.18's ρ).

Let S_k be the sum of the first k steps after the transaction's parent, and T_k = P(Bin(2k−1, p) ≥ k) with p = b/(a+b):

forward race (fork point = tx parent):   P_fwd(k)  = (1 + ρ) · T_k
hindsight (fork point chosen after):     P_hind(k) = (1 + 2ρ − ρ²) · T_k + ρ² θ · E_Q[(−S_k)⁺]
E_Q[(−S_k)⁺] = (1/a) · Σ_{i=0}^{k−1} (k − i) · C(k+i−1, i) · p^k · (1−p)^i

Checks (2026-10-18): q = 0.33, m = 1, k = 10 gives closed form 0.125 against Monte Carlo 0.128 (4000 trials). q = 0.20, m = e, k = 30 gives 0.172 against 0.1725.

Slot secrets do not change these odds: the reversible windows are the same set, learned later.

Appendix B — Tables (2026-10-18)#

B1. Fixed draws, hindsight attacker: probability a tx at k confirmations is ever reversible#

q k=1 2 5 6 10 20 30 60 120 240 720
0.10 1.22e-1 3.43e-2 1.10e-3 3.64e-4 4.84e-6 1.27e-10 3.83e-15 1.33e-28 2.26e-55 ~0 ~0
0.20 2.97e-1 1.56e-1 2.99e-2 1.78e-2 2.43e-3 2.07e-5 1.98e-7 2.18e-13 3.66e-25 1.44e-48 ~0
0.25 4.07e-1 2.59e-1 8.31e-2 5.86e-2 1.54e-2 6.52e-4 3.08e-5 4.00e-9 9.16e-17 6.64e-32 ~0
0.30 5.34e-1 3.94e-1 1.86e-1 1.48e-1 6.28e-2 8.54e-3 1.27e-3 5.02e-6 1.04e-10 6.14e-20 1.6e-56
0.33 6.16e-1 4.89e-1 2.79e-1 2.36e-1 1.25e-1 2.91e-2 7.33e-3 1.38e-4 6.40e-8 1.83e-14 2.6e-40
0.40 8.15e-1 7.41e-1 5.91e-1 5.52e-1 4.31e-1 2.47e-1 1.48e-1 3.50e-2 2.34e-3 1.31e-5 2.4e-14
0.45 9.40e-1 9.13e-1 8.52e-1 8.34e-1 7.74e-1 6.54e-1 5.62e-1 3.70e-1 1.73e-1 4.22e-2 2.3e-4

Reversible slots per year = P × 1,051,200. Confirmations for under one reversible slot per century:

q 10% 20% 25% 30% 33% 40% 45%
k 16 (8 min) 37 (18 min) 58 (29 min) 95 (48 min) 136 (68 min) 412 (3.4 h) 1686 (14 h)

B2. Today's chained seed with path grinding (asymptotic), hindsight attacker#

q k=1 5 10 20 30 60 120 240 720
0.10 3.66e-1 5.92e-2 8.38e-3 2.12e-4 5.97e-6 1.67e-10 1.77e-19 2.8e-37 ~0
0.20 8.28e-1 6.15e-1 4.59e-1 2.75e-1 1.72e-1 4.58e-2 3.86e-3 3.40e-5 3.9e-13
0.25 9.83e-1 9.56e-1 9.30e-1 8.87e-1 8.48e-1 7.52e-1 6.04e-1 4.06e-1 9.78e-2
≥ 0.269 1 1 1 1 1 1 1 1 1

B3. Monte Carlo cap check (4000 trials, hindsight): today's rule, q = 0.33#

cap K k=1 2 5 10 60 120
10 0.983 0.972 0.921 0.740 – –
120 1.000 1.000 1.000 1.000 0.999 0.987

Kept as the record of why a cap alone does not fix R1 and R2.

Appendix C — Probe used on 2026-10-18 (source of the E10 regression test)#

Placed temporarily at crates/spek-consensus/tests/scratch_stake_shift_probe.rs, run with cargo test -p spek-consensus --release --test scratch_stake_shift_probe -- --nocapture, then removed. Output: PROBE validate_full(stake funded at height-1) = Ok(()).

mod common;

use spek_config::params::ECONOMICS;
use spek_consensus::validation::block::BlockValidator;
use spek_consensus::validation::{calculate_block_reward, calculate_min_coin_requirement};
use spek_core::block::Block;
use spek_core::blockheader::BlockHeader;
use spek_core::chain::ChainState;
use spek_core::transaction::{Transaction, TxInput, TxOutput};
use spek_core::utxo::{UTXOEntry, UTXO};
use spek_crypto::SqisignKeypair;

fn genesis(kp: &SqisignKeypair) -> Block {
    let pk = *kp.public_key_bytes();
    let cb = Transaction::new(
        1,
        vec![TxInput { prev_tx: [0u8; 32], prev_out_index: 0xFFFF_FFFF, signature_script: vec![], sequence: 0xFFFF_FFFF }],
        vec![TxOutput { value: 50_00000000, pubkey_script: spek_script::p2pk_script(&pk) }],
        0,
    );
    let root = *spek_util::MerkleTree::new(&[cb.hash()]).root().expect("root");
    let h = BlockHeader::new(1, [0u8; 32], root, spek_core::DA_COMMITMENT_RESERVED,
        spek_core::GENESIS_ELECTION_SEED, 0, pk, kp);
    Block::new(h, vec![cb]).expect("genesis")
}

#[test]
fn probe_stake_shift_key_grinding() {
    let height: u32 = ECONOMICS.seed_phase_blocks as u32 + 10;
    let need = calculate_min_coin_requirement(height as u64);
    let parent_seed = [0x5au8; 32];
    const POOL: usize = 32;
    let pool: Vec<SqisignKeypair> = (0..POOL).map(|_| SqisignKeypair::generate().expect("keygen")).collect();
    let scores: Vec<num_bigint::BigUint> = pool.iter().map(|kp| {
        spek_util::bigint::calculate_hash_distance(&spek_core::derive_election_seed(&parent_seed, kp.public_key_bytes()))
    }).collect();
    let best = (0..POOL).max_by(|a, b| scores[*a].cmp(&scores[*b])).expect("pool");
    let kp = &pool[best];
    let pk = *kp.public_key_bytes();
    let mut chain = ChainState::new(genesis(&SqisignKeypair::generate().expect("keygen")));
    // The only stake the chosen key holds: a non-coinbase output created at height-1.
    chain.utxo_set_mut().add(
        UTXO { txid: [7u8; 32], vout: 0 },
        UTXOEntry::new(TxOutput { value: need, pubkey_script: spek_script::p2pk_script(&pk) }, (height - 1) as u64, false),
    );
    let reward = calculate_block_reward(height as u64);
    let cb = Transaction::coinbase(height, reward, spek_script::p2pk_script(&pk));
    let root = *spek_util::MerkleTree::new(&[cb.hash()]).root().expect("root");
    let header = BlockHeader::new(1, chain.best_block_hash(), root, spek_core::DA_COMMITMENT_RESERVED,
        spek_core::derive_election_seed(&parent_seed, &pk), height, pk, kp);
    let block = Block::new(header, vec![cb]).expect("block");
    let validator = BlockValidator::new(ECONOMICS.coinbase_maturity);
    let result = common::validate_full(&validator, &block, &chain);
    assert!(result.is_ok(), "stake funded one block earlier must satisfy stage 9: {:?}", result);
}

E10 turns this into stake_funded_one_block_before_is_rejected: the same construction with a stake output (hashed v1 with the slot commitment), slot fields from slot_fields_for, and the new score, expecting BlockValidationError::NotEligible { reason: Ineligible::NoStakeOutput } (the E6 name of the former InsufficientCoins).

Appendix D — Test vectors#

Computed in E4 (2026-10-20) and pinned by unit tests: slot_secret_tests.rs (appendix_d_slot_secret_commitment_and_reveal_vectors), election_tests.rs (appendix_d_beacon_score_and_launch_beacon_vectors), blockheader_tests.rs (test_election_seed_v2_appendix_d_vector), ticket_record_tests.rs (appendix_d_ticket_record_and_launch_vectors) in spek-core, and hashed_commit_tests.rs (appendix_d_hashed_commit_and_stake_script_vectors, and appendix_d_hashed_commit_l5_vector added in E13) in spek-script, and appendix_d_nums5_vector (E14) in spek-config/src/allocation_tests.rs.

Input patterns (byte i of each field): slot seed i; pk_e 0xa5 ^ i; other_pk_e 0x5a ^ i; hashed-commit commitment 0x21 ^ i; beacon seed 0x77 ^ i; genesis merkle root 0x4c ^ i; parent seed 0x13 ^ i; ticket record A 0x3c ^ i; launch open anchor 0x6b ^ i; seal anchor 0x9d ^ i. S = 8640 (mainnet) throughout. The score.r_h and election_seed.r_h inputs are slot_secret.expected_r_h. The launch seal digest is over message "spek-vector-message" and allocation bytes "spek-vector-allocation" (ASCII); its other inputs are listed in the block.

slot_secret:
  slot_seed: 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
  expected_sigma: f3f56c56f3339701db9c835709a1d2a0dbe6107dbb34e4f059a646429e92322f
  S: 8640
  segment: 7
  expected_s_g: e1e8598593f696af6f7cf1d907d430ac6a7f46ab7677fe04bac637ca20383745
  expected_T_g: 597fc5db6088d4f9f4ea0a613978a81573bf2b8f0e264967b0568f2ae4e474fb
  offset: 100                      # height 60580
  expected_r_h: 87c34a0c068dc19b61d1b0685c2d0f4e49a4898d21ce1770e77bc812ee3f00aa
commitment:
  sigma: f3f56c56f3339701db9c835709a1d2a0dbe6107dbb34e4f059a646429e92322f
  pk_e: a5a4a7a6a1a0a3a2adacafaea9a8abaab5b4b7b6b1b0b3b2bdbcbfbeb9b8bbba85848786818083828d8c8f8e89888b8a95949796919093929d9c9f9e99989b9ae5e4e7e6e1e0e3e2edecefeee9e8ebeaf5f4f7
  J0: 7
  expected_C: 471e1e5cec462ff716b0a6a75250e5d2355c6574fb5778b77adf20bacf8a82d3
  other_pk_e: 5a5b58595e5f5c5d52535051565754554a4b48494e4f4c4d42434041464744457a7b78797e7f7c7d72737071767774756a6b68696e6f6c6d62636061666764651a1b18191e1f1c1d12131011161714150a0b08
  expected_C_other_pk_e: e1cb4679ee2385112ed9fc487d8819f8cea6ec8616f937789311b6a00ee6f00b
  leaf_index: 3
  expected_path:
    - 4936298429e49a032d7b5a73520eff9b645d222824a3a8253059364092c19990
    - fe7e059407c506cdb2c2cdd432a349dee150d2be3f320127e4c8ce41671a4f1a
    - d312520afaa18cf6d3d8a0b2e7d02d5c4f6d95d8aa94c033ac3d11830cd54d03
    - 4eb8c5dc745e6103eea5be87d29232aff14b094487ef5c07343d9ee093ef6385
    - 7d692241e754bc384f0e29fb9127f1a316f19d427865ef575830a29e675b938b
    - 7da2cd61f7e5050c62be688e253597f958aacb0c5a2e7afff57cbcf84da7a4f7
    - be3664993c84b2093e9569fc7da3e1f69a8115afa6ba6856ebb098e05cf6b969
    - 5c74636671a942d1d25a159eb1de2123783b629cdd71a81ea2dec22fac1d5ebd
    - 8aee551594362d9f3858917b277b9b7d1027aab6ecc59df31d0e05c42390d15f
    - da6381a637598ce671df54c14fc48eeff8200e77920b69423ac627c7834dc78a
hashed_commit:
  pubkey: a5a4a7a6a1a0a3a2adacafaea9a8abaab5b4b7b6b1b0b3b2bdbcbfbeb9b8bbba85848786818083828d8c8f8e89888b8a95949796919093929d9c9f9e99989b9ae5e4e7e6e1e0e3e2edecefeee9e8ebeaf5f4f7
  commitment: 212023222524272629282b2a2d2c2f2e313033323534373639383b3a3d3c3f3e
  expected_program: add45e618a7727eb7ccc8fb60937d28bc6ac83d84e8fd3f5a8ff5ea20fa9aa0e
hashed_commit_l5:                  # E13: tag "SpekHashedCommitL5", 169-byte key, byte i = 0xa5 ^ i
  pubkey: a5a4a7a6a1a0a3a2adacafaea9a8abaab5b4b7b6b1b0b3b2bdbcbfbeb9b8bbba85848786818083828d8c8f8e89888b8a95949796919093929d9c9f9e99989b9ae5e4e7e6e1e0e3e2edecefeee9e8ebeaf5f4f7f6f1f0f3f2fdfcfffef9f8fbfac5c4c7c6c1c0c3c2cdcccfcec9c8cbcad5d4d7d6d1d0d3d2dddcdfded9d8dbda25242726212023222d2c2f2e29282b2a35343736313033323d3c3f3e39383b3a05040706010003020d
  commitment: 212023222524272629282b2a2d2c2f2e313033323534373639383b3a3d3c3f3e
  expected_program: e4a0ba95b12a64a73ca02a836d78d4546fb504822773430d127f5223fd25c070
  expected_script: 5220e4a0ba95b12a64a73ca02a836d78d4546fb504822773430d127f5223fd25c070
nums5:                             # E14: allocation_internal_pubkey(), 169 bytes
  marker: "SPEK-ALLOCATION-NUMS-L5"
  digest_input: "Spek//allocation//NUMS//no-key//l5"
  forced: byte 83 = 0xff, byte 167 = 0xff
  expected_key: 5350454b2d414c4c4f434154494f4e2d4e554d532d4c35f1fca654cfa133c265cd0e5978c1a8903921b76b9ac435840c6bf74d03e68c4800000000000000000000000000000000000000000000000000000000ff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ff00
stake_script:
  pk_e: a5a4a7a6a1a0a3a2adacafaea9a8abaab5b4b7b6b1b0b3b2bdbcbfbeb9b8bbba85848786818083828d8c8f8e89888b8a95949796919093929d9c9f9e99989b9ae5e4e7e6e1e0e3e2edecefeee9e8ebeaf5f4f7
  C: 471e1e5cec462ff716b0a6a75250e5d2355c6574fb5778b77adf20bacf8a82d3
  J0: 7
  expected_Cs: f180d92fde4e87d3f0d6ab042d980dbaf1b51765110b3e260bbd53d1786fbad7
  expected_script: 5120b4351e695cc6e5eadff513ee116b7e25fda043df0df434539a8b26df646193f8
beacon:
  L: 1440
  seed_at_beacon_height: 77767574737271707f7e7d7c7b7a797867666564636261606f6e6d6c6b6a6968   # height 1439
  epoch: 2
  expected_B: 9dd65e70f20d734c781aab782e376fd83efda971beb0d854cd217a519ab64887
score:
  beacon: 9dd65e70f20d734c781aab782e376fd83efda971beb0d854cd217a519ab64887
  height: 2880
  pk_e: a5a4a7a6a1a0a3a2adacafaea9a8abaab5b4b7b6b1b0b3b2bdbcbfbeb9b8bbba85848786818083828d8c8f8e89888b8a95949796919093929d9c9f9e99989b9ae5e4e7e6e1e0e3e2edecefeee9e8ebeaf5f4f7
  r_h: 87c34a0c068dc19b61d1b0685c2d0f4e49a4898d21ce1770e77bc812ee3f00aa
  expected_distance: 43813965606344861784628516953337253655045076724059360372205811897811326582859
  same_inputs_other_pk_e: 5a5b58595e5f5c5d52535051565754554a4b48494e4f4c4d42434041464744457a7b78797e7f7c7d72737071767774756a6b68696e6f6c6d62636061666764651a1b18191e1f1c1d12131011161714150a0b08
  expected_distance_other_pk_e: 14322723839324946880311993013056696646570569004125711846385990543513345745862
launch_beacon:
  genesis_merkle_root: 4c4d4e4f48494a4b44454647404142435c5d5e5f58595a5b5455565750515253
  expected_B0: 417b5ebc9495912c495ea133ca4edc3542dce31bce024d9b11aba97fd59fc01a
  expected_B1: fe2bf8340d2e1d520fadf9f2e91e98ea427dc257c6fb3885273d686bb8b8d5ca
slot_root_from_reveal:
  height: 86500                    # segment 10 = leaf 3 of J0 7, offset 100
  pk_e: a5a4a7a6a1a0a3a2adacafaea9a8abaab5b4b7b6b1b0b3b2bdbcbfbeb9b8bbba85848786818083828d8c8f8e89888b8a95949796919093929d9c9f9e99989b9ae5e4e7e6e1e0e3e2edecefeee9e8ebeaf5f4f7
  r_h: 83dae7d4ec3a5829cc658cd362f229c46cec6faa71d0b5d1e54e87e7aab37281
  J0: 7
  path: commitment.expected_path
  expected_root: 471e1e5cec462ff716b0a6a75250e5d2355c6574fb5778b77adf20bacf8a82d3
launch:
  network: "mainnet"
  open_anchor: 6b6a69686f6e6d6c63626160676665647b7a79787f7e7d7c7372717077767574
  seal_anchor: 9d9c9f9e99989b9a95949796919093928d8c8f8e89888b8a8584878681808382
  T: 1800000000
  sealed_list:                     # given out of order; the digest sorts them
    - 534b5452030000003c3d3e3f38393a3b34353637303132332c2d2e2f28292a2b242526272021222315cd5b0700000000
    - 534b5452000000003c3d3e3f38393a3b34353637303132332c2d2e2f28292a2b24252627202122230700000000000000
  expected_K_L: 9a37e76b67be6f1d5cee65130fb53f9f51f022626cfa8ea9ec0a02e0a09d29b5
  expected_seal_digest: 9e6b2a4361e65af804ad9a5df0a9e3f8e67777353789442488d517eafb4975d1
  expected_launch_nonce: b15209f673b84188f1b2440f72854aece4b6babd50a25529800700a8ca371df9
election_seed:
  parent_seed: 13121110171615141b1a19181f1e1d1c03020100070605040b0a09080f0e0d0c
  pk_e: a5a4a7a6a1a0a3a2adacafaea9a8abaab5b4b7b6b1b0b3b2bdbcbfbeb9b8bbba85848786818083828d8c8f8e89888b8a95949796919093929d9c9f9e99989b9ae5e4e7e6e1e0e3e2edecefeee9e8ebeaf5f4f7
  r_h: 87c34a0c068dc19b61d1b0685c2d0f4e49a4898d21ce1770e77bc812ee3f00aa
  expected_seed: 849949ba66bd2c64fc5e775f5c986140a9f5b3d2bdd7b615bc637b50ff36350b
ticket_record:
  round: 3
  A: 3c3d3e3f38393a3b34353637303132332c2d2e2f28292a2b2425262720212223
  nonce: 123456789
  expected_script: 6a30534b5452030000003c3d3e3f38393a3b34353637303132332c2d2e2f28292a2b242526272021222315cd5b0700000000

The stake-program ticket preimage (TicketPreimageV2) is pinned against the Spek RandomX variant in spek-randomx/src/hasher_tests.rs (PINNED_V2_VECTORS: program 0x3c ^ i; round 0, zero seed, nonce 0 → f2aa678bc5dac97ec546b2cf0c5473154751888141c23114c94c010c72fd7587; round 7, patterned seed, nonce 42 → 67140ac4b81fa19a698765ad579ced65bc1f0a6fd27a66c3f5b89f6b13d40ee4).

Appendix E — Simulation results (E1, 2026-10-20)#

Scripts and outputs are in rfcs/rfc-0012/sim/ (<name>.py with <name>.txt). Each runs with python3 -I -B <name>.py, uses the standard library only, and fixes its random seed. BLAKE2b-256 stands in for BLAKE3 where real hashing is used; the arguments need only a random oracle. saddlepoint.py is a shared tail helper (Lugannani–Rice) that S4 and S8 load by path; it is checked against direct Monte Carlo in ticket_select.txt.

Sim Question Result
S1 beacon_predict Can anyone predict B_e before its cutoff? Option A: exact prediction r slots ahead equals p^r (measured 0.358 vs 0.366 at p = 0.99, r = 100), and within two misses 0.92. This revision: 0 matches in 10^6 one-slot predictions; an insider knows the beacon only by winning every remaining slot (measured ≈ q^r); at the cutoff that is q^(L+1), below 10^-440 for q < 0.5.
S2 stake_fit What does fitting stake to a known beacon buy? Option A, beacon known: 5% stake with 10^5 keys wins 23.7% of the epoch, 10% with 10^6 wins 48.4%, and any chosen 50-slot run is coverable with probability 0.95 at 10^6 keys. This revision: 5.1% and 10.0%, the key share.
S3 withhold_grind What does an evaluating withholder gain? Option A reproduces the review (33 and 79 deep). This revision: net 1.9–3.0% more of its own wins per controlled beacon, 0.3–0.6% averaged over all epochs, no compounding (§3.7). G1-6 confirmed.
S4 ticket_select Ticket shopping with and without the registry Without: 1.06–2.18 times the fair yield per known epoch (N = 5–100 holders, 10 to 10^7 candidates per ticket), and a chosen 20-slot run with 0.98–0.999 at 10^5 candidates. With: 1.000 (selection is against a beacon that does not exist yet).
S5 renewal_scale Renewal transactions per year §3.5 and G1-21. With creation one year ahead (the default since 2026-10-25), depth 10: 0 up to 100k miners, about 41,600 at 1M, 482,000 at 5M. With creation 36 days ahead: 0 up to 10k miners, 4,399 at 100k, 107,577 at 1M, 582,387 at 5M. The rev 4 figures (about 0 up to 1M, 101,000 at 5M) hold only when the next horizon is created as the current one starts.
S6 header_verify_load Header checks per node per slot Event simulation on random graphs where every miner is a node: mean 28–45 for 500–20,000 miners (p99 about 1.3 times the mean), close to 5(1 + ln(N/5)); extrapolated mean 60–64 at 1M and 67–72 at 5M.
S7 commitment_copy Copied commitments under each binding §11.3 E1 table: no binding q/(1−q); leaf only below q for the copier but about 40% loss for its victims; score only q; both q.
S8 launch_grind Launch members grinding epochs 0–1 Known beacon (zero seed): one member with 10^6 tries gets 1.12 times its fair share (5 members) and 1.29 times (20 members). Merkle-root beacon with commit-reveal: fair share; withholding some of its own records never pays (best subset is always "reveal all", 1.000–1.003).
S9 deep_branch_beacon Deep private branches §3.22: beacon choice alone breaks even at q* = 0.467 / 0.459 / 0.452 for 10^6 / 10^9 / 10^12 candidates. Stake fitting inside a private branch is possible (the branch knows its own beacons before their cutoffs); inside the restoration pin it adds about 3% (thresholds unchanged); beyond it, steady rotation breaks even at q ≈ 0.445 (10^5 candidates) and 0.42 (10^6). A density window over the first L slots after the ancestor needs q ≥ 0.48 for a 1.6% chance (§3.22, G1-22).