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.
- Each mining key derives one hash chain per 3-day segment from a random slot
seed stored next to it in the miner key file. The chain tops form a Merkle
tree whose root
Cis part of the stake output. The stake output is an ordinary hashed-key output, so the commitment needs no registration transaction, no new output template and no fee. - The score is
distance(BLAKE3(B_e ‖ h ‖ pk_e ‖ r_h)), and the election seed mixes inr_h. Nobody can rank keys for a future slot, a copied commitment gives a different draw, and every winner adds secret entropy to the next beacon. - Stake is one output at the stake address. It (and, in the seed phase, an on-chain ticket record) counts only if it was created one full epoch before the beacon's last inputs begin.
- The header grows from 349 B to 743 B (673 B on SQIsign Round 2) and drops the reserved
da_commitmentslot.Cis not carried: the verifier derives it from the reveal and the Merkle path. - The coinbase pays the stake address only until it holds a stake output, then pays a separate payout address, so rewards do not pile up under a hot key that every header makes public.
The same wave makes three related changes, because all of them are genesis-frozen:
- Hashed-key outputs: public keys stay hidden until spent, and P2PK is removed.
- SQIsign Level V as an opt-in output type for long-lived custody.
- A naming cleanup ("finality catchup" becomes "catchup").
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#
- Concatenation and hashing.
‖is byte concatenation.BLAKE3(x)is the default 32-byte BLAKE3 hash (blake3::hash). Domain tags are the ASCII bytes shown, with no length prefix and no terminator. Integers in preimages are little-endian with the stated width (u32 LE,u64 LE). - Tagged hashes.
TH(tag, x) = BLAKE3(BLAKE3(tag) ‖ BLAKE3(tag) ‖ x)(BIP-340 style). This is the form the existing ScriptCommit commitment uses (scriptcommit.rsmodule docs). - Heights.
BlockHeightisu32in the header. Every arithmetic in this RFC is done inu64with checked or saturating operations. No function may panic on any input (Rule 5). - Distances. BigUint only (Rule 1).
- Names. New identifiers are given in
code fontand are normative. Sessions use them exactly as written. - Files. Paths are relative to the repository root. Line numbers are as of master cc40956 and are hints only; sessions locate code by symbol name.
- Tests. In sibling
<module>_tests.rsfiles registered with#[path](CLAUDE.md test conventions). Run only targeted tests. - Branch. All sessions work on branch
rfc0012, cut from master at the start of E1. Each session ends at a compiling commit (§11.1).
1. Motivation#
1.1 Current rule (as of master cc40956)#
crates/spek-core/src/blockheader.rsderive_election_seed(parent_seed, miner_pubkey)=BLAKE3(ELECTION_SEED_DOMAIN_TAG ‖ parent.election_seed ‖ miner_pubkey).BlockHeader::calculate_score()=calculate_hash_distance(&self.election_seed)(null header → 0). The genesis score is the distance ofGENESIS_ELECTION_SEED(all zeros), which is 2^255.- Fork choice is the sum of scores (cumulative chainwork):
- Propagation fork (
spek-network/src/sync/propagation_fork.rs): equal-height compare at depthclamp(MIN_FORK_COMPARE_DEPTH=2, PROPAGATION_FORK_DEPTH=4). - IBD (
ibd_completion.rsibd_reorg_authorized): heavier at any depth.
- Propagation fork (
- The only hard rollback stop is
ChainState::rollback_floor(): the highest restoration or hard checkpoint at or below the tip. The restoration pin sits one 4000-block interval below the tip, so blocks become irreversible only after roughly 33–67 h. - Stake:
validation/common.rscheck_coin_requirement→mature_stake_balance→UTXOSet::get_mature_balance. This is an exact-script lookup ofp2pk_script(miner_pubkey). A non-coinbase output counts immediately; only coinbase outputs waitcoinbase_maturity(3000 on mainnet). Stage 9 evaluates against the parent ledger view. - Autonomous mining pays its coinbase to
p2pk_script(miner_pubkey)(spek-node/src/node/mining.rs, the coinbase build in the mining path). - Seed phase: a header is valid only with an 8-byte RandomX ticket nonce in
da_commitment[0..8]forTicketPreimage(round, round_seed, miner_pubkey, nonce)(spek-randomx/src/preimage.rs,spek-consensus/src/seed_ticket.rs). The round-r seed (r ≥ 2) is the hash of block(r−1)·W(round_seed_height), with W = 8640. - Outputs (
spek-script/src/script_type.rsis_admissible_output): P2PK (67 B), ScriptCommit v1 (OP_1 <push 65 pubkey> <push 32 commitment>, 100 B), and zero-value OP_RETURN data carriers. Witness versions 2–16 are reserved for future signature schemes (spek-script/src/address.rs). - The miner key is
<data_dir>/miner_key(or--miner-key-file), stored as hex ofsecret_key(353) ‖ public_key(65)(spek-node/src/node/miner_key.rs). - Every online miner publishes a header every slot. Relays forward only
headers that hold a top-5 rank (
HEADER_RELAY_TOP_N=MAX_CANDIDATES_PER_CHAIN= 5). Every received header gets a SQIsign verify before it is ranked (handlers/header.rs, the floodsub header verify job callingVerifiedHeader::verify). consensus.fork_comparison_depth,fork_min_confirmationsandtimeslot_precision_ms(spek-config/src/params.rs) have no consumers.
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:
- fixed draws, which reduce the hindsight gain to a factor of 1.2–1.5 (Appendix A);
- checkpoints and restoration pins, which are the only bound on depth;
- M2 forfeiture for winners that sign two parents (deferred, G1-4).
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) secret until used;
- (b) unique per height, so it cannot be ground;
- (c) committed before the beacon exists.
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#
- I1 — Fork-invariant draws. Two chains that share every block at heights
≤
beacon_height(epoch(h))give the same(h, key, r_h)the same score. Every propagation fork (≤ 5 deep, L ≥ 4) shares that prefix; a shorter L breaks that for a deep fork, which the fork manager hands to IBD (§3.12). - I2 — Non-malleable seed and beacon.
election_seedis a pure function of the prior seed, the winner key and the winner's committedr_h. It never hashes producer-malleable bytes (signature, merkle_root, header hash). - I3 — Keys and commitments fixed before the beacon's inputs. A stake
output (post-seed) or ticket record (seed phase) counts at height h only if
created below
stake_cutoff(h), one full epoch below the beacon height. - I4 — Unique per-height value. For each (key, commitment) and height,
exactly one
r_hverifies. Uniqueness per key alone is not a protocol property: one key may back two stake outputs built from two slot seeds (I7). - I5 — One score function. Exactly one function computes a score. The old
calculate_scoreand v1 seed derivation are deleted, not kept alongside. - I6 — Explicit beacon resolution. Every scoring site obtains its beacon
through a
BeaconSource. An unresolvable header is quarantined, never scored with a guess. - I7 — One attempt per stake output per height. Each eligible header names one stake output, and each stake output must hold a full stake unit, so attempts scale with stake, not with keys or horizons. Two headers from one key at one height and parent are equivocation. The protocol cannot check that a key uses a single slot seed (the seed is secret), so I7 is stated per stake output, not per key.
- I8 — No new liveness hazards. Rules 9, 10, 11 and 12 hold. No new work runs on the receipt gate or the SlotCritical lane beyond what §3.11 states.
- I9 — Genesis-frozen. Pre-launch: no activation height, no migration, no compatibility paths; rehearsal-1 data is discarded.
- I10 — Key-bound draws and commitments. Both the score preimage and every
commitment leaf contain the signing key
pk_e.- Score binding is the load-bearing half. Any replayed
r_hgives the replayer an independent draw, never the victim's score. - Leaf binding makes a copied commitment invalid under any other key.
- Leaf binding alone would not suffice: a segment's top is public as soon as
its owner reveals once in that segment. A copier could then commit a tree
whose leaf at that segment is
H(tag ‖ pk_copier ‖ T_g), fund it, and replay the victim'sr_hfor the rest of the segment once the stake counts. Segments start on epoch boundaries, so such a copy always ages the full 3L and is live for half of each 6L segment. S7 measured it: the copier ends below its key share (0.5·q/(1−q)), but each shadowed victim loses about 40% of its blocks while the copy is live, which is griefing at the copier's own expense. Score binding removes both. pk_eis fixed inside the stake address before the cutoff, so neither binding opens grinding.
- Score binding is the load-bearing half. Any replayed
- I11 — Slot secrets independent of SQIsign. The slot seed is independent
random bytes, never derived from the SQIsign secret key, so a SQIsign key
recovery does not reveal future
r_h.
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):
L ≥ 2,S ≥ 1andS % L == 0.seed_phase_blocks % L == 0andseed_ticket_round_blocks % L == 0.- Mainnet: 129,600 = 90 × 1440 and 8640 = 6 × 1440.
seed_ticket_round_blocks / 2 ≥ 2·L + 3(integer division, asround_seed_height(1)computes W/2).- Why: a record for round r can land only after its round seed exists
(height
(r−1)·W, or W/2 for round 1) and counts for an epoch only below that epoch's cutoff (§3.6). Round 1's full-coverage window isW/2 − 2L − 2slots and every later round's isW − 2L − 2. Below this bound round 1 has no full-coverage window, and with W = L no record could ever count for its own round, so the seed phase would stall after the genesis records. - Mainnet: 4320 ≥ 2883.
- Why: a record for round r can land only after its round seed exists
(height
- Regtest defaults change to L = 10, round W = 60 (30 ≥ 23, leaving round 1 an
8-height full-coverage window) and
REGTEST_SEED_PHASE_BLOCKS= 180 (three rounds). Epochs 0–2 (30 slots) accept only genesis ticket records (§3.14). ticket_records_per_block_max ≥ 1(a zero cap would let no key register after genesis).- One
const fn validate_network_params(&EconomicParams, &ConsensusParams)holds every rule above. Both shipped profile pairs pass it inconstasserts, and a regtest pair passes it again after its env overrides. - Knob parsing follows the existing
REGTEST_*pattern, where a typo is a hard error.
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:
- Only earlier heights are exposed.
r_(h+1)is a preimage ofr_h, so revealingr_hexposes nothing about later heights. - σ comes from a random slot seed, not from the SQIsign key (I11).
- The miner key file gains a third field. Its format becomes hex of
secret_key(270) ‖ public_key(83) ‖ slot_seed(32)(385 bytes). The slot seed is drawn from the OS RNG (getrandom, the sourceSqisignKeypairgeneration already relies on) when the key is generated. - A file of any other length is a hard error with a descriptive message that says to generate a new key. There is no two-field compatibility form.
- The file is still the only backup, and there is still no wallet dependency
(
spek-nodemust not depend onspek-wallet, RFC-0007 §5). - Why not derive σ from the secret key: an attack that recovers SQIsign
secret keys (the way the 2022 SIDH break did) would then also reveal every
future
r_h, and the reason for keeping headers on Level I (§3.18) would be gone.
- The miner key file gains a third field. Its format becomes hex of
- One r_h per key and height, by convention. Because σ is fixed per key
file and s_g uses the global segment, every horizon of one key reveals the
same
r_hat a given height, so renewals never leak future values. Verifiers cannot check this (I7); it only protects the honest miner's secrecy. SlotRevealError(thiserror, descriptive messages):HorizonOutside { height, horizon_start };LeafOutside { leaf }(aslot_pathleaf index ≥ 2^d);HorizonNotHeld { horizon_start }(aSlotSecretStoreasked for a horizon it was not built with).- Miner-side precompute. Building C is 1024 × S hashes plus 1024 leaf
hashes (8.8M mainnet, about 1–3 s). The miner keeps the 1024 tops (32 KB) and, for the current segment,
checkpoints every 96 steps (90 values, 2.9 KB). Producing
r_hthen takes fewer than 96 steps. - Verifier cost. At most S + d + 1 hashes (8651 mainnet, 0.9–2.6 ms). The average walk is S/2. Leaf binding adds 1024 hashes per commitment build (negligible next to 8.8M) and one per verify.
API (spek-core/src/slot_secret.rs, new module):
derive_slot_sigma(slot_seed: &[u8; SLOT_SEED_BYTES]) -> Zeroizing<[u8; 32]>segment_secret,slot_step,segment_top,slot_revealslot_commit_root(sigma, pk_e, j0, s) -> [u8; 32]slot_path(sigma, pk_e, j0, s, j) -> [[u8; 32]; SLOT_HORIZON_DEPTH]slot_root_from_reveal(h, pk_e, r_h, j0, path, s)- The segment length
sis aNonZeroU64, sog(h)ando(h)cannot divide by zero;ElectionSchedule(§3.6) supplies it from the active profile. SlotSecretStore(pure, no IO): built from the slot seed,pk_eand one or morej0values; it holds σ inZeroizing, the tops per horizon and the current-segment checkpoints, and answersslot_fields(height, j0) -> Result<SlotFields, SlotRevealError>. It also answerscommit_root(j0)for building the stake address. Persistence and the horizons file are node concerns (§3.15).SlotFields { horizon_start, reveal, path }andSlotFields::NULLlive in this module (the header embeds them in E6). TheirDebugprints onlyhorizon_start: a reveal for a future height is secret until published.MinerKeyFile(parse,encode,generate_for, accessors) andfresh_slot_seed(§3.15) are in this module from E4; E6 ports the readers.
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):
- Key path. The witness is exactly 2 items:
[signature, reveal], wherereveal = pubkey ‖ commitment(115 B for v1, 201 B for v2).- Require
TH(tag, reveal) == A, then verify the signature against the pubkey. - A v2 key-path reveal whose key is the Level V allocation NUMS key
(
allocation_internal_pubkey, E14) is refused (NumsKeyPathRefused). No output commits to a Level I NUMS key, so v1 carries no such refusal.
- Require
- Script path. The witness is
[inputs..., script, control_block], as today. The control block isversion ‖ internal_pubkey ‖ merkle_proof(1 + 83 + 32kB for v1,1 + 169 + 32kB for v2).- Compute
commitmentfrom the internal key and the proof exactly as today, then requireTH(tag, internal_pubkey ‖ commitment) == A, then execute the leaf.
- Compute
- Disambiguation. It is by the last item's length. Key-path reveals (115 / 201 B) can never equal a control-block length (84 + 32k / 170 + 32k B).
- Sighash. Unchanged (
SighashDomain, Rule 13). It already commits to the spent output's script and amount.
Other surfaces:
detect_script_typegainsHashedCommit(and, in E13,HashedCommitL5).is_admissible_outputadmits it.- Bech32m: witness version 1 accepts program length 115 (plain) or 32 (hashed).
derive_address_from_scriptandderive_script_from_addresshandle both. - Disassembly,
tx_builder, the PSBT format (RFC-0008 gains an optional per-inputrevealrecord, 115 or 201 B), the wallet, the decodedscriptPubKey(typeandaddress) thatgetblock,getrawtransactionandgettxoutreturn, and the test-clientscript_zooall learn the hashed form. The node has no address-validation or script-decoding RPC. - Wallet policy (not consensus): every new receive, change and coinbase address
is hashed. Covenant and allocation families (E14) use v2 templates
OP_2 <push 32> TH("SpekHashedCommitL5", NUMS5 ‖ commitment)under the fixed Level V NUMS internal key (RFC-0011 version 2).
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
- Eligibility rebuilds
stake_scriptfrom the header'sminer_pubkey, theCderived byslot_root_from_reveal, andhorizon_start, then looks up one stake output at that script (§3.6). There is no new output template. - Spending is an ordinary hashed key-path spend:
[signature by pk_e, pk_e ‖ Cs]. There is no valid script path, because no script root hashes to Cs. - Horizon selection (node). A new horizon gets
J0 = g(tip + 3·L), the first segment in which it can be eligible.tipthroughout these rules is the higher of the main-chain tip and the clock's settled tip, so a node that boots or resyncs behind the network chooses as it would synced.- The node keeps its horizons in
<data_dir>/stake_horizons, a JSON list of{ j0, commit_root (hex32), created_at_height }, written withspek_util::write_atomicand read withspek_util::read_bounded. - It creates the next horizon automatically when
g(tip) ≥ J0_current + 2^d − lead, usingJ0_next = J0_current + 2^d − 2(two segments of overlap).leadis--horizon-renewal-lead-segments, default 122 segments: about one year of the 8.4-year mainnet horizon. The node refuses a lead below2 + ⌈3·L / S⌉ + 1(the overlap, the stake aging time and one segment for the funding to be mined; 4 on mainnet) or above2^d − 2. - Drills may set any J0 through RPC (
createstakehorizon { j0 }, regtest only) to reach a horizon end quickly.
- The node keeps its horizons in
- Coinbase payout (autonomous mining). Node policy, not consensus.
- Let
target(h) = calculate_min_coin_requirement(max(h, seed_phase_blocks)).- Post-seed this is the requirement at the current height. That is enough
for every later height, because the requirement never rises after the
seed phase: it is 80% of a reward that only halves, floored at
FLOOR_REQUIREMENT. E4 asserts that it is non-increasing. - During the seed phase the requirement is 0, so the current height would mark every horizon covered and no seed-phase winner would build stake. The first post-seed requirement is used instead.
- Post-seed this is the requirement at the current height. That is enough
for every later height, because the requirement never rises after the
seed phase: it is 80% of a reward that only halves, floored at
- A horizon is covered when its stake address holds one unspent output of
value ≥
target(h). That output may still be aging (below the cutoff or immature); it only has to exist. - The node's horizons are checked in order: current (eligible now, or the
one that becomes eligible first), then next (created by the renewal rule,
up to the renewal lead ahead, about a year by default). The coinbase pays the stake address of the first
horizon that is not covered.
- Why every horizon and not only the newest one eligible within 3L: a
coinbase counts only after
max(coinbase_maturity, 2L+2 to 3L+1)slots. Paying the next horizon only 3L before it starts would be too late for its first epochs. Paying it as soon as it exists keeps renewal free for winners.
- Why every horizon and not only the newest one eligible within 3L: a
coinbase counts only after
- A single coinbase covers a horizon while the block reward is at least
FLOOR_REQUIREMENT / 0.8= 0.125 coin: the requirement ismax(80% of the reward, 0.1 coin), and below 0.125 coin the floor is the larger term. On mainnet that holds for the first 9 halvings (about 36 years); after that a coinbase alone cannot fund a horizon, and renewal comes from the renewal transfer, fees or the operator's wallet. The floor stays: it is the lowest price of a key, and without it the requirement would fall with the reward toward 1 bit. - Once every horizon is covered, the coinbase pays the operator's payout
address (
--payout-address <bech32m>; the node has no config file, so the flag is the only form).- It must decode to a hidden-key output:
HashedCommit, orHashedCommitL5once E13 lands. Plain ScriptCommit (key visible) and OP_RETURN are refused, and so is any stake address of this node. - A bad value is a hard error at boot, with a descriptive message.
- It must decode to a hidden-key output:
- With no payout address configured, the coinbase keeps paying the current
horizon's stake address. The node logs
PAYOUT_ADDRESS_UNSET(warn) at boot and once per day while it mines. - Why:
pk_eis public in every header and lives on the mining machine. Rewards collected at the stake address would all be lost if that machine is stolen, and would all sit under a public Level I key. One stake output is all eligibility needs (§3.6), so anything more there only adds exposure. generatetoaddressstill pays the requested script.
- Let
- Moving coins off the stake address. Spending outputs at the stake
address other than the one counted stake output does not affect
eligibility. The node's
sweepstakeRPC (§3.16) is the withdrawal path: it spends a horizon's spendable indexed outputs, largest first, up to 64, to a hidden-key address, and withkeep_counted(the default) on a live horizon it leaves the counted output and the largest one at or above the target in place. It works for any horizon the horizons file records, live or ended, so an ended horizon's leftover output and rewards paid to the stake address (no payout address set) can leave. Outputs under the index floor (STAKE_REQUIREMENT_FLOOR) are not indexed and are not swept. Automatic sweeping of ended horizons is not done.- Addresses carry no network. An address has one human-readable prefix on every network, so a payout or sweep address cannot be checked for the network it belongs to. This does not put funds at risk across networks: every signature commits to its chain's genesis block hash (§3.5 sighash domain), so a transaction signed for one chain is invalid on another.
- A lost or stale horizons file. The file records the commitment
behind every stake address of the key, but every commitment is a
function of the slot seed,
pk_eandJ0, so it can be rebuilt from the main chain. The rule is that the file lists every horizon of the key that the synced main chain shows, and only a synced chain can say which those are. So every process runs one reconcile scan, off the event loop, the first time its main chain is within one block of the clock's settled tip. The scan derivesCfor everyJ0from 0 up to the later ofg(tip) + leadandg(tip + 3·L): each segment top is computed once, then 2047 tree hashes perJ0, sized byS,Land the lead only, and refused pastMAX_COMMIT_SCAN_TOPS(2^20 tops). It keeps each unrecorded horizon whose stake program has a ticket record or whose stake address holds an unspent output, records it in the file (created at the earliest such height), holds it while live and logsHORIZONS_FILE_RECOVERED, orHORIZONS_RECONCILEDwhen nothing is missing. This covers three cases: a file lost together with the chain DB (the boot sees tip 0 and opensJ0 = 0;HORIZONS_RECOVERY_PENDINGsays the scan will follow), a file lost mid-sync, and a file restored from an old backup. A boot past genesis with an empty file runs the same scan before choosing a first horizon, so a node whose chain is already synced does not open a needless new one. When that finds nothing, the boot logsHORIZONS_FILE_MISSINGand starts a new horizon, and the reconcile still runs once the chain is synced. A horizon whose stake was fully spent and whose records were pruned is not recovered; it holds nothing to spend. - Coinbase below the index floor. Once the block reward (fees are not known when the payout is chosen) falls below the index floor, the coinbase would never be indexed or merged at a stake address, so with a payout address set it pays that address whatever the coverage.
- Renewal. A key that wins renews through its coinbase, because the payout
rule pays the new horizon's address until it is covered. A key that never
wins must fund its next horizon's stake output before
stake_cutoffof the epoch in which that horizon starts. The node warns 30 days ahead (HORIZON_EXPIRING, §3.21).- Renewal transfer (node policy, on with the stake merge;
--no-stake-mergeturns both off). When the next horizon is not covered, the node moves surplus from the current horizon's stake address to the next one's in one transaction it signs and relays (STAKE_RENEWAL_TRANSFER_SUBMITTED). Surplus is every spendable indexed output there except the output stage 9 counts now and the largest one at or above the target; neither is spent, so the current horizon stays eligible and there is no gap. Inputs are taken largest first up to the target plus the fee floor, at most 64. A key whose current address holds only its counted output has no surplus and still needs a win or its wallet. A destination that already has a pooled (unconfirmed) output paying its address at least the target counts as covered, so a merge followed by a transfer does not fund one horizon twice. A plan the node's own admission refused is remembered per plan: a refusal that repeats (an invalid transaction or signature) until the address's outputs change, a temporary one (a floor-fee transaction refused by a full mempool) retried after 1, 2, 4, ... slots, at most one election epoch. - Moving the old stake output into the new address spends it, which ends the
old horizon's eligibility at once, while the new output only counts 2L+2 to 3L+1
slots later. A renewal by moving therefore costs a 24–36 h gap. Funding the
new address from other coins, while the old output stays, avoids the gap;
the old output can be moved after the new one counts.
sweepstakedoes both (keep_countedtrue or false), and when it spends the counted output of a live horizon its result states that the key is not eligible until the address is funded again and the funding output ages past the stake cutoff. - Expected renewals per year that a win does not fund, at depth 10 (one
transaction each, wallet or renewal transfer, about 450 B; S5):
(N / 8.41 years) · e^(−window / N)forNkeys, the window running from horizon creation to the no-gap deadline,lead·S − 3·Lslots. At the default lead (364.5 days) that is 0 up to 100k miners, about 41,600 at 1M (19 MB per year) and 482,000 at 5M (217 MB). At the earlier 12-segment lead (34.5 days) it was 4,400 at 100k, 108,000 at 1M and 583,000 at 5M. - The lead time is node policy, not consensus (G1-21). A longer lead lowers these renewals but keeps a second funded stake output under the public hot key for longer: at the default, two exposed stake units per key for the last year of each 8.4-year horizon (12% of it). Creating the next horizon when the current one starts gives about 17 per year at 1M and 101,000 at 5M, with two exposed units for most of the horizon.
- Renewal transfer (node policy, on with the stake merge;
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)
-
One output, not a sum. Stake must sit in a single output of at least the requirement. Several smaller outputs at the stake address do not add up.
- Why: today's check sums every output at the script
(
UTXOSet::get_mature_balancewalksoutpoints_for_script_key). That costs time proportional to the number of outputs at the script, under thechain_stateread lock, on the receipt gate, for every header. The stake address is public, so anyone can slow every node's check on a top miner by sending it dust. - Nothing is lost: one coinbase already meets the requirement, and a
mining node merges a stake address funded in pieces itself (automatic
stake merge, node policy, on by default,
--no-stake-mergeto turn off): when no single output there reaches the requirement but outputs of at leastSTAKE_INDEX_MIN_VALUEdo together (at most 64, largest first, plus the fee), it spends them to one output at the same address. The merged output counts after the usual stake age (two to three epochs). - Summing a few outputs (say the top five) was reconsidered and kept rejected: the walk would have to read outputs below the requirement, so each step an attacker adds would cost as little as the index floor instead of a stake unit, and bounding it needs a step cap that becomes an eligibility-denial lever.
- Why: today's check sums every output at the script
(
-
Stake value index.
UTXOSetgainsstake_value_index: BTreeSet<(ScriptHash20, Reverse<u64>, UTXO)>.- It holds exactly the outputs with
value ≥ STAKE_INDEX_MIN_VALUE. That is a pure function of the output, soaddandremovemaintain it symmetrically, and reorgs, undo and boot rebuilds need no special case. - It is maintained next to
script_indexinadd,removeand every path that rebuildsscript_index.script_indexkeeps its(txid, vout)order for RPC pagination. - Lookup: walk the script's range in descending value and stop at the first output whose value is below the requirement. Skip outputs that are not counted (too new, immature) and return the first counted one. Every output walked holds at least the requirement, so each extra step costs an attacker a full stake unit.
- Why the membership threshold is the floor, not the current requirement: the requirement falls at each halving. An era-based threshold would need an O(UTXO) rebuild inside one commit at the era boundary (a Rule 10 stall), plus an exact reversal for reorgs across it. The floor never changes, so no rebuild exists. The cost is one extra entry for each output of 0.1 coin or more (§8).
- It holds exactly the outputs with
-
API. Replace
get_mature_balance/mature_stake_balancewithfind_stake_output(script, requirement, height, coinbase_maturity, stake_cutoff) -> Option<(UTXO, u64)>on theMatureBalanceSourcetrait, renamedStakeOutputSource, and implement it forUTXOSetand theLedgerViewoverlay.- E4 adds
StakeOutputSourceandentry_counts_as_stakebeside the summing forms, which stage 9 keeps using until E6 deletes them. - A requirement below
STAKE_INDEX_MIN_VALUEis raised to it (smaller outputs are not indexed; the post-seed requirement never falls below the floor, so this changes no post-seed answer). - The overlay merges the base index walk (skipping outpoints it marks spent)
with its own created outputs for that script, sorted the same way. The
overlay predicate (
entry_is_mature, renamedentry_counts_as_stake) and the base filter stay byte-for-byte equivalent; extend the existing parity comment. - Spendable-balance displays that are not stake use a separate function and never feed eligibility.
- Fix the stale comment above
candidacy_receipt_gate(handlers/header.rs), which says the lookup is "O(1) … viapubkey_index". That index no longer exists, and after this change the lookup is bounded by the number of outputs at the script holding at least the requirement.
- E4 adds
-
Why the cutoff sits one epoch below the beacon height. It freezes the stake set before any block of the beacon's last input epoch (e−2) exists.
-
When new stake counts. New stake first counts 2L to 3L slots after creation (24–36 h mainnet).
-
Selling the stake. Spending the stake output ends eligibility from the next block.
-
One attempt per stake output per height (I7).
- The equivocation identity stays
miner_pubkey, viaattempt_identity_hash= BLAKE3(prev_block ‖ miner_pubkey). - A header is counted only against the exact stake script it rebuilds, and each stake script needs its own full stake output. Two horizons of one key are therefore two stake units, which is economically the same as two keys.
- Two headers for one height and parent from one key are equivocation, handled by the existing negation.
- The node names its eligible horizon with the earliest end (in the seed phase, the horizon whose program holds the round's ticket record).
- The equivocation identity stays
-
Single source of the cutoff.
check_coin_requirementderives the cutoff, the requirement and the stake script itself from the header (with theCderived byslot_root_from_reveal), so no caller can pass wrong ones. The receipt candidacy gate (candidacy_receipt_verdict) and themine_headerself-check call the same function. -
Reorgs. On a fork, the branch's own LedgerView supplies
entry.heightand spentness.
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
- Epochs 0 and 1 read the genesis
merkle_root, notGENESIS_ELECTION_SEEDand not the genesis block hash. The epoch index keeps B_0 ≠ B_1.- Why not the all-zeros seed: it is known forever, so genesis-record holders could try keys and slot seeds offline until their draws for epochs 0–1 (2880 slots) were good.
- Why not the genesis block hash: the genesis header carries a SQIsign signature, and signing is randomised. Whoever signs genesis could re-sign it until the hash gave a good beacon (the same reason block hashes are never beacons, I2).
- The merkle root commits to the genesis coinbase (every launch ticket record, the allocation outputs, the vout-0 message and the launch nonce), and the build is deterministic from a sealed submission list and a Bitcoin block hash (the seal anchor) mined after the seal was witnessed (§3.14 Genesis). So no party, the organiser included, can choose it after learning B_0 or B_1: every input was public and fixed before the one input that decides them existed. (The commit-reveal it replaces left the organiser an informed abort per record, because only the organiser knew the launch nonce while the reveals arrived.)
BeaconSourceimplementations read it from the pinned genesis header.GENESIS_ELECTION_SEEDstays all zeros as the seed chain's start.
beacon_height(e) ≤ h − L − 1for every h in epoch e ≥ 2.B_eis unpredictable until produced, because every block of epoch e−2 mixes a secretr_hinto the seed.- Newtype
pub struct Beacon([u8; 32])inspek-core/src/election.rs. Its only public constructors arederive_beacon(epoch, &LinkedSeed)(epoch ≥ 2; errors otherwise),derive_launch_beacon(epoch, genesis: &BlockHeader)(epoch 0 or 1; checksblock_height == 0), and a test-only helper (pattern:LinkedSeed).
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):
- Net gain in its own epoch wins, after the withheld blocks, averaged over the beacons it controls: 3.0% at q = 0.1, 2.4% at q = 0.2, 1.9% at q = 0.33. Averaged over all epochs: 0.3–0.6%. A fixed large choice set gains more (8 choices: 8.0% at q = 0.1, 5.1% at q = 0.2, 3.4% at q = 0.33), but runs that long are rare.
- A planned run of k near-certain wins (each at least 90%) stays rare: at q = 0.33 the chance of a 4-run in a controlled epoch rises from 0.6% to 3.0%, and 6-runs or longer do not occur in the samples (closed form below 10^-3 even with 32 choices).
- Steering the tail of epoch e to gain more choices for epoch e+2 does not compound: the net share stays at q (q = 0.45: 0.450 with steering, 0.453 without). The expected number of choices, (1−q)/(1−2q), stays bounded for q < 0.5, and steering costs the wins it would buy.
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
pk_eis in the preimage (I10).- Without it, an attacker could fund a stake address that reuses a victim's
commitment:
TH(pk_attacker ‖ Cs_victim)when leaves are not key-bound, or a tree with leafH(tag ‖ pk_attacker ‖ T_g_victim)once the victim's segment top is public. The attacker would then replay the victim'sr_hwithin the same slot, tie its score, and win Rule 8 with a lower pubkey (cheap to generate). - Changing the tie-break cannot fix this, because an attacker can grind any tie-break field (pubkey, merkle root). The scores themselves must differ.
- Measured before this fix (
rfcs/rfc-0012/sim/commitment_copy.py, casenone): an attacker share q becomes q/(1−q) of blocks (0.33 → 0.49, 0.45 → 0.82), and every copied miner earns nothing. - With
pk_ebound, a copied commitment gives an independent draw that the copier learns only after the victim reveals, which is no better than its own chain. pk_eis committed inside A before the cutoff, so binding it opens no grinding.
- Without it, an attacker could fund a stake address that reuses a victim's
commitment:
- Middle-out distance from 2^255 is kept, with the Rule 1 edge cases. The security is the same as a one-sided score, because the preimage holds only values fixed before the beacon.
- Tie-breaking is unchanged (Rule 8): highest score → lowest pubkey → lowest merkle_root.
- A score is valid only after the signature,
slot_root_from_revealand eligibility pass. Receipt may rank on the claimed score before verifying it (§3.11). - API:
BlockHeader::score(&self, beacon: &Beacon) -> BigUint, plus the free functionbeacon_score(beacon: &Beacon, height: u64, pk_e: &[u8; 83], r_h: &[u8; 32]) -> BigUint.calculate_score(&self)is deleted.
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
derive_election_seed(parent_seed, miner_pubkey, slot_reveal)replaces the two-argument form (197 call sites, counted 2026-10-20).validate_election_seed_link,validate_genesis_election_seed,LinkedSeed,SeedChainWalkand every linkage site (live commit, IBD apply, fork walk, boot sweep) keep their shape with the new preimage.
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:
-
slot_revealis the per-height secret. -
slot_pathlinks the reveal to the commitment using only the header. The alternatives are worse:- a per-node cache of proven segments makes header validity depend on node state, which breaks headers-first IBD;
- moving the path into the body lets an unverifiable high claimed score win the slot ranking.
-
horizon_startgives the leaf index and is part ofCs. Dropping it means one global horizon grid, so every miner would have to renew in the same window every 8.4 years. -
Cis not carried.slot_root_from_revealderives it (§3.3), which saves 32 B per header (about 34 MB per year).- What it costs: a wrong reveal can no longer be told apart from "no stake
at that address". Stake views differ across forks, so a header that does
not build on the node's tip is dropped silently. A header that does build
on the tip is judged on consensus inputs only (its bytes, the tip's
ledger), so every node on that tip reaches the same verdict, and no honest
node relays an ineligible header or a signature twin of one: the
delivering peer takes one Header violation
(
HEADER_INELIGIBLE_STRIKE), and later copies of the same block hash on that tip are dropped before any signature check and struck the same way. - The per-peer verify cap bounds the work (§3.11).
- What it costs: a wrong reveal can no longer be told apart from "no stake
at that address". Stake views differ across forks, so a header that does
not build on the node's tip is dropped silently. A header that does build
on the tip is judged on consensus inputs only (its bytes, the tip's
ledger), so every node on that tip reaches the same verdict, and no honest
node relays an ineligible header or a signature twin of one: the
delivering peer takes one Header violation
(
-
Removed:
da_commitment,DA_COMMITMENT_RESERVED,validate_da_commitmentand every reference (272 counted 2026-10-20). It was a reserved placeholder (32 zero bytes in every post-seed header) whose only live use, the seed-ticket nonce, moves to the ticket record (§3.14). -
Constructor.
BlockHeader::new(version, prev_block, merkle_root, election_seed, block_height, miner_pubkey, slot: SlotFields, keypair), wherepub struct SlotFields { horizon_start, reveal, path }. Also addSlotFields::NULL(all zeros). -
Signing and serialization.
signing_bytes()writes the first 543 bytes in the order above.serialize()appends the signature.deserialize()rejects any length ≠ 743 first and is then a total fixed-width parse. The serde form must produce the same bytes; extend the existingheader_wire_encoding_matches_serdetest. -
Null header. Same length, with
slot=SlotFields::NULL. -
Genesis header.
slot=SlotFields::NULL; work =GENESIS_WORK. -
validate_basic. Length and version pins; null-form consistency (a null header has null slot fields); for a non-null header,0 ≤ g(h) − horizon_start < 2^d. These checks need only the header and are O(1). The reveal walk is not a basic check. -
Size. About 781 MB per year of headers (743 B × 1,051,200 slots; 367 MB before), independent of miner count. Update
MAX_BLOCK_SIZEaccounting, header-batch byte limits, IBD batch sizing and every otherBLOCK_HEADER_SIZEconsumer (21 production references). -
Hard-coded sizes. Replace literal header sizes with the constants (
grep -rnw "349\|201" crates --include=*.rs; for exampleHEADER_WIRE_BYTESinspek-node/src/node/rpc_node_status.rs). Every message that embeds a header derives its size caps fromBLOCK_HEADER_SIZE: equivocation proofs, header batches, IBD header responses and the FloodSub header message.
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):
- Length and version pins (fail fast,
handle_header_message). - Claimed-score pre-filter (new). Resolve the beacon from the tip context
and compute the claimed score from
miner_pubkeyandslot_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.
- A header can rank iff its parent's tracked chain (
- 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). VerifiedHeaderstays a proof object. It carries the signature proof and the derivedC, exposed asVerifiedHeader::slot_root().- A bad signature or
HorizonOutsideis an objective invalidity (a violation, as a bad signature is today).
- SQIsign verify (1.4–5 ms), then
- Candidacy (
candidacy_receipt_verdict, on the event loop with thechain_stateread 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.
- Scoring happens at buffering with the tip context.
Load:
- With top-5 relay a node checks about 5·(1 + ln(N/5)) headers per slot: 31 at 1k miners, 66 at 1M. Walks cost about 40–170 ms of CPU per slot.
- A junk header with a garbage
r_hcosts at most one signature check, one walk and one bounded stake lookup. The cost is the same whether or not the sender holds stake. It stays underFLOODSUB_HEADER_VERIFY_PER_PEER_CAP.
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:
- A live header with
epoch(h) ∉ {epoch(tip+1), epoch(tip+1)+1}is quarantined (provisional), never ranked. - Propagation-fork blocks resolve on the main chain while the block producing
their epoch's beacon is at or below the common ancestor, which always holds
for a fork of at most
PROPAGATION_FORK_MAX_PULL_DEPTH= 5 blocks when L ≥ 4 (mainnet L = 1440, regtest default L = 10). With a shorter epoch (a drill network at L = 2) a fork can reach a beacon height above its ancestor; the fork manager then declines to compare work (its scores would use the main chain's beacon) and hands the fork to IBD, which resolves each candidate's epochs from its own headers. - An IBD candidate forking below a
beacon_heightresolves that epoch from its own headers. If that header is not yet staged, scoring is deferred (the work delta isUnknown, whichibd_reorg_authorizedrefuses). BeaconErrorvariants (thiserror):HeightNotOnChain { height },CandidateHeaderMissing { candidate, height },OutsideHorizon { epoch, tip }, plus the derivation and sweep errorsEpochNotTracked { epoch },LaunchEpoch { epoch },NotLaunchEpoch { epoch },NotGenesisHeader { height },SeedMismatch { height }and (E7, the IBD candidate source)SeedUnlinked { height }.BeaconTracker::new(schedule, genesis)derives B_0 and B_1 up front;observe(header, &LinkedSeed)records the beacon of the epoch a beacon-height header feeds and keeps the three most recently recorded epochs (BEACON_TRACKER_RETAINED_EPOCHS), so a whole-chain sweep holds O(1) state.
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):
TicketPreimage::build(round: u32, round_seed: &[u8; 32], stake_program: &[u8; 32], nonce: u64).TICKET_DOMAIN_TAGbecomes"SPEK-SEED-TICKET-V2"(19 bytes, same length as V1).TICKET_MINER_PUBKEY_LEN(65) is replaced byTICKET_STAKE_PROGRAM_LEN(32).- E4 adds this form as
TicketPreimageV2/TICKET_DOMAIN_TAG_V2beside v1, because the live validator and miner feed v1 the header'sminer_pubkeyand no stake program exists before E6. The hasher accepts either through the sealedTicketInputtrait. E6 deletes v1 with the header-nonce ticket path and takes the names above (user decision, 2026-10-20). - The RandomX key (the genesis hash), the target D and
round_seed_heightare unchanged.
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):
- Only coinbase outputs whose script has this exact shape are records. Elsewhere the same bytes are ordinary OP_RETURN data and carry no meaning.
- At seed-phase heights x, a coinbase may carry at most
ticket_records_per_block_max(16) records. - Each record must satisfy all of:
- its round is
round(x)orround(x) + 1, and that round starts belowseed_phase_blocks(a record for a round past the seed phase is invalid); - its round seed height is < x on the block's own chain;
- RandomX(genesis hash; preimage) < D, checked with the existing
SeedTicketVerifierpath and itshash_below_targetlittle-endian convention; - no other record for the same
(A, round)exists in the block or on the chain below it.
- its round is
- A violation makes the block invalid.
- After the seed phase, a coinbase with record-shaped outputs is invalid.
- Validation cost: up to 16 light-mode RandomX evaluations (15–30 ms each),
parallel on the pool. Records seen at admission are hits in the
TicketCache(keyed(round, A)).
Counting:
- A record counts for epoch e iff
record.height < stake_cutoff(e·L)orrecord.height == 0. - Seed-phase eligibility (§3.6) needs a counted record for
(A(header), round(h)).
State.
- RocksDB column family
CF_TICKET_RECORDS = "ticket_records"inspek-storage/src/persistence/mod.rs(next toCF_UTXO): keyround u32 BE ‖ A, valuerecord_height u32 LE. - The in-memory view used by validation is a
TicketRegistryinChainState, loaded from the column family at boot. The branch overlay for fork and IBD validation follows theLedgerViewpattern: records of the branch's blocks above the fork point are added, and records of the disconnected main-chain blocks are hidden. - It is written in the same WriteBatch as the block's commit. Disconnect deletes the disconnected block's records (they are in the block, so no undo data is needed).
- Entries with
round < round(tip) − 1are pruned. - The keyspace is unused after
seed_phase_blocks. The boot sweep rebuilds it from stored seed-phase coinbases if it is missing.
Relay and pool.
- New FloodSub topic
ticket-records, with its protocol string derived fromWIRE_GENERATION. - The message is the 48-byte payload.
TicketRecordPool:- bounded to 4096 entries, FIFO by arrival;
- deduplicated by
(A, round); - admits a record only if its round is current or next, it is not already on chain, and it passes the RandomX check on the Mempool lane (never the receipt gate or SlotCritical, Rule 10), with a per-peer admission rate of 4 per second;
- an invalid record is a peer violation.
- Producers include up to 16 pending records in every seed-phase coinbase, oldest first. The coinbase is prefilled in compact blocks, so compact relay needs no change.
Timing.
- One ticket per key per round, valid for its round, as today. A ticket binds to one stake program (one horizon), so the node mines each seed-phase round under the horizon whose program holds that round's record.
- To cover a whole round, a record must land at least 2 epochs before the round
starts.
- Exactly: it lands at a height x with
round_seed_height(r) < x < stake_cutoff(first height of round r). - For r ≥ 2 that is
(r−1)·W < x < r·W − 2L − 1, a full-coverage search window of W − 2L − 2 = 5758 slots (about 48 h; it was 72 h). - A later record still counts for the remaining epochs.
- Exactly: it lands at a height x with
- Round 1 (seeded at height W/2) has a W/2 − 2L − 2 = 1438-slot full-coverage
window. The §3.1 bound
W/2 ≥ 2L + 3keeps it at least 1.
Genesis.
-
Epochs 0–2 count only genesis records: the launch set, written into the genesis coinbase after the existing vout-0 message and allocation outputs (
spek-core/src/genesis.rs). Launch keys useJ0 = 0. -
A genesis record is a round-0 record whose ticket is valid under the launch key
K_L(below), not under the genesis hash, which does not exist while the tickets are searched:RandomX(K_L; TicketPreimage::build(0, K_L, A, nonce)) < DK_Lis both the RandomX key and the round seed.Dis the network's seed-ticket target (D9: no new parameter), compared with the samehash_below_targetlittle-endian rule as every other ticket. -
Nodes do not re-check genesis records: the genesis block is pinned, so its records are valid because the pinned genesis was built from a verified selection. The check runs in the tools (
--launch-select,--audit-launch,--verify-substitution), where anyone can repeat it from public inputs. The per-block cap does not apply to genesis; the launch set holds at mostMAX_GENESIS_TICKET_RECORDS(1,973: the coinbase output cap less the allocation outputs) records. -
Launch ceremony (open search, seal, Bitcoin anchors, deterministic build). Epochs 0 and 1 draw from the genesis merkle root (§3.7), so the coinbase must be fixed before anyone, the organiser included, can learn B_0 and B_1. Two Bitcoin block hashes do the timing: the open anchor (block
H_open) starts the search, so no ticket can be found before it, and the seal anchor (blockH_seal) feeds the launch nonce, so the beacons are unknown until after every other input is sealed. Every seat costs one ticket, so the launch set is open to anyone who searches.-
Announce. The organiser publishes the network,
H_open, the submission deadline,H_seal(far enough past the deadline for the seal to be published and witnessed first, about one day), the fallbackH_seal + 144, and the two submission channels (the website and the GitHub repository). The genesis timestampT, the vout-0 message and the allocation may be fixed at any point up to the seal, so the RFC-0006 custody ceremony can run alongside the search. -
Search. Once
H_openhas 6 confirmations, anyone computesK_Land searches. Each searcher makes a miner key file with itsJ0 = 0horizon (A is that horizon's stake program, which commits to the slot secrets, so the slot seed is fixed before any beacon exists, I3, I11) and searches nonces for a ticket underK_L, keeping the lowest hash found (spek-miner-keygen --launch-search). One key is one seat; more seats take more keys and more tickets. -
Submit. Before the deadline a searcher submits its 48-byte record through either channel. A channel runs
spek-genesis-gen --check-launch-ticketon each entry as it arrives and lists publicly every entry that passes, with its ticket hash, so anyone can recheck the listing. An entry that fails is answered with the reason and not listed. Well-formed junk costs nothing to make, so a channel that listed it could fill the list past its cap; a valid ticket costs about2^256 / Dhashes. -
Seal. After the deadline the organiser runs
--seal-submissionsover the channel listings and publishes the sealed list withT, the message, the allocation and their seal digest. The sealed list is every entry listed publicly by the deadline whose ticket is valid, nothing added after the deadline, in the canonical form below. The seal step checks every ticket again and leaves out and reports any that fails, so the list holds valid tickets only, and itsMAX_LAUNCH_SUBMISSIONScap can be reached only by real work. One file is read up to that same cap before screening, so a channel export larger than it is split into files under it (every line to exactly one file; the tool names this). Independent witnesses record the digest, and an archive.org snapshot is taken, before blockH_sealexists. If the digest is not witnessed beforeH_sealis mined, the seal anchor is the fallbackH_seal + 144(fixed at the announcement, so it is not a choice), and the digest must be witnessed before that block. A second miss restarts the ceremony from step 4 with a newly announcedH_seal. A late seal gives the organiser no grinding choice: whichever anchor applies is mined after the digest is public, so the organiser never sees the beacons for a list it can still change. -
Entropy. Once the seal-anchor block has 6 confirmations its hash is the seal anchor.
-
Select (
select_launch_set). From the sealed list:- verify every entry's ticket under
K_LandD. An entry that is not round 0 or whose ticket fails is skipped and reported, never a reason to abort (D5); - keep one entry per A: the one with the lowest ticket hash;
- order the rest by ticket hash read as a 256-bit little-endian integer
(the
hash_below_targetconvention; an ordering, not a hash distance), then by A; - take the first
MAX_GENESIS_TICKET_RECORDS. When there are no more valid entries than seats, every valid entry is selected. Selection reads only the sealed list,K_LandD, never an anchor after the open anchor, so it is fixed when the list is sealed. Under an oversubscribed window a lower hash wins a seat, so a searcher who keeps searching keeps improving its odds.
- verify every entry's ticket under
-
Build. The genesis builder is deterministic. The coinbase input commits
"spek/genesis-launch/v1" ‖ launch_nonce, the records follow the allocation outputs sorted by(round, A)(GenesisRecordSet), and the coinbase holds no timestamp or other randomness. The merkle root, and so B_0, B_1 and the genesis block hash, follow from the sealed inputs and the seal anchor alone. Only the header signature bytes are random, and they feed neither the beacons nor the hash. The merkle root and the genesis hash are published as the build checksum. -
Audit. Anyone runs
spek-genesis-gen --audit-launchover the public inputs (network, both anchors, the sealed list,T, message, allocation) and the witnessed digest (--expect-seal-digest, required on every network). It recomputesK_L, the seal digest, every ticket, the selection and the launch nonce, and passes only when:- the derived digest equals the witnessed one (on mainnet and testnet the compiled digest must equal it too). The witness is what ties the list to what was public before the seal anchor existed, so a binary compiled from a list that leaves entries out fails here;
- the genesis coinbase is byte-identical to the rebuild
(
check_launch_genesis: the allocation outputs, then the selected records, under the derived nonce); - the header's signed bytes are the network's genesis header over that merkle root.
Comparing the whole rebuild leaves no free field: a coinbase version, lock time, input sequence or record-output value moves the merkle root and the launch beacons. The file genesis loader also refuses a coinbase outside that canonical form and a record output that carries value. The tools never contact Bitcoin: operators copy the anchor hashes in and auditors check them against any block explorer.
-
-
Derivations. Every variable-length part is prefixed by its byte length as u32 LE, so each preimage parses only one way. The network is its name in ASCII (
"mainnet","testnet","regtest"). An anchor is a Bitcoin block hash as the 32 bytes of its usual display hex (the byte order block explorers show), so operators copy it unchanged.K_L = BLAKE3("spek/launch-key/v1" ‖ len ‖ network ‖ open_anchor) sealed_list = the submitted 48-byte payloads whose ticket is valid, byte-identical duplicates removed, sorted by payload bytes seal_digest = BLAKE3("spek/launch-seal/v1" ‖ len ‖ network ‖ T u64 LE ‖ len ‖ message ‖ len ‖ allocation_bytes ‖ len ‖ sealed_list) launch_nonce = BLAKE3("spek/launch-nonce/v2" ‖ K_L ‖ seal_digest ‖ seal_anchor)allocation_bytesis the serialized allocation outputs in coinbase order;Tis the genesis timestamp in seconds. Submissions travel as JSONL, one{"record": "<hex48>"}per line, at mostMAX_LAUNCH_SUBMISSIONS(2^20) lines, read withspek_util::read_bounded(D6); outputs are written withspek_util::write_atomic. -
Why these bindings.
K_Lbinds the network and the open anchor: no ticket exists beforeH_open, and a ticket for one network or launch is worthless on another.- The seal digest binds
T, the message and the allocation as well as the list, so none of them can be chosen after the seal anchor is known (D3).Tis public before the genesis hash can be computed. - The launch nonce binds
K_L, the seal digest and the seal anchor. It is unique per launch (each launch has its own anchors), which keeps two launches built from the same keys and records apart: the block hash leaves the signature out, so without it they would share a genesis hash and with it the sighash domain. It is derived, not drawn, so nobody holds a free input: the organiser cannot redraw it, and the seal anchor did not exist when every other input was fixed. - Submitters have no informed abort: a record is public and sealed before the seal anchor exists, and B_0 and B_1 depend on that anchor.
- The residual choices:
- The organiser leaving entries out of the sealed list. The public channels and the witnesses are the backstop: an omitted entry is visible, and the audit refuses any list other than the witnessed one.
- The organiser abandoning the whole ceremony once the seal anchor is mined. From then on anyone can compute B_0 and B_1, so a walk-away is an informed abort of the entire launch. It is bounded by its public cost: the abandoned seal, digest and anchor stay on record, a new launch needs a new announcement, open anchor and search window that every searcher sees, and each retry is one visible draw, not a silent one.
- A Bitcoin miner withholding the seal-anchor block. That costs it the Bitcoin block reward, far more than the 4,320 launch blocks are worth.
- A reorg deeper than 6 blocks at an anchor height, which is negligible.
-
Placement:
LAUNCH_KEY_TAG,LAUNCH_SEAL_TAG,LAUNCH_NONCE_TAG,launch_key,seal_digest,launch_nonce, the canonicalSealedListandMAX_LAUNCH_SUBMISSIONSlive inspek-core/src/genesis.rsbesideGENESIS_LAUNCH_TAG;GenesisRecordSet(structure only: 1 toMAX_GENESIS_TICKET_RECORDSrecords, all round 0, no duplicate A, sorted by(round, A)) inspek-core/src/ticket_record.rs. Ticket verification andselect_launch_setlive inspek-consensus/src/launch_ticket.rs(spek-corehas no RandomX dependency), over the sameTicketHasherandhash_below_targetas every other ticket. Under the regtest always-pass target selection builds no hasher, so clusters pay no RandomX cost. -
Mainnet and testnet compile the ceremony's outputs: the anchors and the seal digest (
GENESIS_LAUNCH_ANCHORS_*, aLaunchAnchorsof open anchor, seal anchor and seal digest), the derivedGENESIS_LAUNCH_NONCE_*and the selectedGENESIS_TICKET_RECORDS_*(D1).--verify-substitution(verify_compiled_launch) refuses placeholder anchors, recomputesK_Land the launch nonce against the compiled constant, and verifies every compiled record's ticket underK_L(light mode, in parallel on the tool's thread pool).--audit-launchalso recomputes the seal digest and the selection from the published sealed list. Node boot does not re-run the ticket checks. -
Steering B_2 and B_3 from the tail of epochs 0 and 1 is the withholding gain (§3.7) with a small N. It cannot be removed, but before epoch 3 it only shifts wins between genesis-record holders.
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#
- Miner key file.
miner_key.rsgenerates, persists and loads the 450-byte form of §3.3 (secret ‖ public ‖ slot_seed). Writes usewrite_atomic, reads useread_bounded, and the slot seed lives inZeroizingmemory. Any other length is a hard error that tells the operator to generate a new key.- Every reader and writer of the format moves with it, and none may slice it
at a hand-coded offset:
spek-node/src/bin/miner_keygen.rs(spek-miner-keygen) andspek-test-client/src/snapshot_gen.rs(read_miner_key_bytes). - In particular,
snapshot_gen.rscurrently takes the public key asbytes[SQISIGN_SECRETKEY_BYTES..], which would returnpublic ‖ slot_seedon a 450-byte file. All three parse through one sharedMinerKeyFile::parseinspek-core/src/slot_secret.rs, next to the slot seed it carries.spek-coreis the one crate thatspek-nodeandspek-test-clientboth depend on (the test client does not depend onspek-node), so the offsets live in one place. - Cluster scripts that call
spek-miner-keygenneed no change.
- Every reader and writer of the format moves with it, and none may slice it
at a hand-coded offset:
SlotSecretStore(the spek-core type from §3.3, owned by the node and built from the slot seed at boot):- σ, held in
Zeroizingmemory only and never written to disk, logs, RPC or crash output; - per horizon, the 1024 tops (rebuilt at boot, 1–3 s mainnet; optionally
cached in
<data_dir>/slot_tops_<j0>withwrite_atomic; on load the root over the cached tops must equal thecommit_rootrecorded for that horizon, else the cache is discarded and rebuilt); - checkpoints for the current segment.
- σ, held in
mine_header:- Pick the eligible horizon (§3.6).
- Compute
r_hand the path from the store. - Compute the seed v2.
- Check its own eligibility with the stage-9 function. The node never mines a header its own stage 9 would reject.
- Fill
SlotFields, then sign.
ticket_search.rs: tickets are keyed by(round, A). When a ticket is found the node submits its record to the pool and the topic, re-announces it every 60 s until it is on chain, and stops searching for that round. Persisted tickets bound to another A are discarded, as today for another pubkey.- Coinbase: §3.5 payout rule, including
--payout-addressandPAYOUT_ADDRESS_UNSET.
3.16 Wallet and RPC surface#
- New RPCs (cost class Cheap, registered like
getseedphasestatus):getstakeaddress→{ current, next }. Each entry hasaddress(Bech32m),horizon_start,eligible_from_height,eligible_until_height,stake_output({ outpoint, value, counts_from_height }for the output §3.6 would count, or null),covered(§3.5),excess_value(value at the address beyond the counted output) andticket_rounds_recorded(seed phase). The top level addspayout_address(or null).createstakehorizon { j0 }: regtest only; a hard error on other networks.sweepstake { horizon_start, to_address, keep_counted = true }: every network, trusted (loopback) bind only, Expensive cost class. The node signs with its stake key and relays one transaction (§3.5) and answers withtxid,inputs_spent,amount,fee, the outputs left behind and why, the index floor and, when it spent the counted output of a live horizon, an eligibility note.to_addressfollows the payout address's rule. A horizon missing from the horizons file, a refused address, or nothing to sweep is an error with the reason.
- The RPC that reports mining stake today (
rpc_node_status.rs) adds the same eligibility fields (getminingstatus.current_horizon, agetstakeaddressentry) inspek-rpc/src/types.rs. - Header shape in RPC:
horizon_start,slot_reveal,slot_path.da_commitmentis removed. RPC header views may add a derived, read-onlyslot_root(fromslot_root_from_reveal). It is never parsed back into a header. - Wallet: the hashed form is the default for receive, change and coinbase
addresses. Stake is one output of at least the requirement at a stake
address (§3.6); a mining node merges an address funded in pieces into one
output itself (automatic stake merge, §3.6,
--no-stake-mergeto turn off).sweepstakeleaves the counted stake output in place by default (§3.5). PSBT carries the reveal (RFC-0008). - GUI: the mining or status tab shows the eligibility window and expiry only where it already shows stake; otherwise this is a countdown follow-up.
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:
ScriptType::P2PK,p2pk_script,has_p2pk_shape,validate_p2pk;- the P2PK arms in
is_admissible_output, the PSBT signer (psbt.rs), the wallet binary, the RPC methods, the test-client wallet,snapshot_gen.rs,relink.rsandscript_zoo; - the genesis builder's private copy;
- 90 references (counted 2026-10-20, tests included); fixtures move to the hashed form.
After this, is_admissible_output admits exactly:
- plain ScriptCommit v1;
HashedCommit;HashedCommitL5(after E13);- zero-value OP_RETURN.
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:
- Live slots. A forger cannot win a future slot as someone else, because it
also needs that key's unrevealed
r_h. That value comes from an independent slot seed (I11) and only BLAKE3 protects it, so even a full secret-key recovery does not reveal it.- A forger can still copy a just-revealed
r_hinto a second header for the same key and slot. That is equivocation, so every winner could be negated: a liveness attack, not a theft.
- A forger can still copy a just-revealed
- Past heights. Every header reveals all earlier
r_hof its segment, so past values are public. A forger could therefore sign branches for past heights with any honest key and rewrite history down to the restoration pin (33–67 h). That holds at any signature level if that level breaks, so it is not an argument for a higher header level. - Stake outputs.
pk_eis public in every header, so stake outputs are not protected by hashing. The §3.5 payout rule keeps their value at one stake unit per key. - Mining keys rotate every horizon, so moving headers to a new level later is a hard fork that phases in as horizons expire, with no coin moves.
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:
- Vendoring.
- Level V comes from the same upstream commit as Level I (
the-sqisigndd133d7): ref and broadwell stacks, namespacedsqisign_lvl5_*, withPQCsignKAT_*_SQIsign_lvl5.rsp, checksums inCHECKSUMS.sha256, andVENDORED.mdupdated. - Duplicate symbols must fail at link time (the existing namespacing discipline).
- Level V: 169 B public keys, 406 B signatures, 549 B secret keys.
- Level V comes from the same upstream commit as Level I (
- Seeded keygen (wallet). A Level V seeded stack in
spek-crypto-seeded, behind the samewalletfeature and symbol firewall. The RFC-0007 context isspek-wallet-v1/sqisign-lvl5/<account>/<index>, which is a new key domain from the same mnemonic. RFC-0007 §2–§4 gain the Level V entries and an end-to-end vector. - Output. The v2 hashed form only (§3.4):
0x52 0x20 <A5>.derive_script_from_addressstops returningReservedWitnessVersionfor v2 with a 32-byte program. Versions 3–16 stay reserved.- Script-path leaves under a v2 output use Level V keys:
OP_CHECKSIG,CHECKSIGVERIFYandCHECKSIGADDrequire 169-byte keys and 406-byte signatures there. - Leaves under v1 outputs stay Level I.
- Verify cost.
-
The per-input sigop budget (
spek-script/src/vm.rs) debitsSIGOP_VERIFY_COST_L5 = 406 + 169 = 575per Level V verify, the same bytes-per-verify rule as Level I. A v2 leaf execution starts from base budgetSIGOP_VERIFY_COST_L5(Level I keepsSIGOP_BUDGET_BASE). -
Integer form of the vsize rule:
extra = ((RHO_L5_NUM − RHO_L5_DEN) × witness_bytes).div_ceil(RHO_L5_DEN)in u64 with checked arithmetic.witness_bytesis the sum of that input's witness item lengths. -
To keep block verify time bounded, block and mempool size accounting count each v2 input's witness bytes at
ρtimes their size:tx_vsize = size_bytes + Σ_{v2 inputs} (ρ − 1) · witness_bytes.ρ = w5 · 283 / 575, wherew5is the measured Level V : Level I verify time ratio and 283 = 200 + 83 and 575 = 406 + 169 are the signature-plus-key bytes one verify needs at each level (the sigop costs above). -
Measured (Round 3, 2026-10-30; first set in E12 on Round 2).
sqisign-bench, 1000 verifies per cell, medians, warm and cold (64 MiB cache eviction before each operation), on both backends, one pinned core:Backend Level I verify Level V verify w5 warm w5 cold ADX/BMI2 2.680 / 2.688 ms 16.399 / 16.411 ms 6.119 6.105 ref 5.030 / 5.038 ms 26.134 / 26.655 ms 5.196 5.291 The guarantee below must hold on whichever backend a node runs, so
w5is the largest measured ratio, 6.119 (ADX/BMI2, warm), giving ρ = 6.119 · 283 / 575 = 3.0117. It is rounded UP to two decimals:RHO_L5_NUM = 151,RHO_L5_DEN = 50(ρ = 3.02). On the ref backend, whose ratio is lower, this over-charges Level V witness bytes, which is the safe direction. The ratio is a property of the two algorithms, not of one machine's absolute speed, so the constant does not size anything to the measuring host. Round 2 (E12) measured w5 = 6.754 against 213 / 421 bytes per verify, ρ = 171/50; Round 3's larger Level V signatures carry more of the cost in bytes, so ρ falls. Measured on one Zen 4 host; Rule 11's worst case across architectures still needs a second x86 vendor and aarch64. Full table:docs/final_tests_2.md. -
ρis a consensus constant stored as a rational (RHO_L5_NUM/RHO_L5_DEN), set from the benchmark (151/50 above). The block rule becomesBLOCK_HEADER_SIZE + Σ tx_vsize ≤ MAX_BLOCK_SIZE. The mempool template selector and fee rate use vsize. -
Result: no block can take longer to verify than a full Level I block does today.
-
- Defaults. Everything defaults to Level I, cold storage included; Level V
is an explicit opt-in (user decision 2026-10-22, amending the earlier "cold
storage defaults to Level V").
spek-wallet pubkeyandspek-wallet addresstake--level 1|5(default 1);--level 5derives the index's Level V custody key under the RFC-0007 Level V context and prints its 169-byte key or its v2 address.--level 5with--miner-key-outis refused, because mining keys are always Level I. - Guardian and covenant families. They are on Level V (G1-13,
2026-10-20; E14). All 28 ceremony keys, guardian shares and
the guardian policy signer are Level V. Every allocation output is one of
eight 34-byte v2 templates under the Level V NUMS internal key
NUMS5(a compiled constant whose two field halves have top byte0xff, so it is not a canonical key; Appendix D), and genesis covenant data version 2 carries the eight commitments. A guardian cancel carries exactlyveto_mLevel V signatures and pays the floor of its exact vsize at the staged output's creation height (RFC-0011 version 2). The covenant fee allowance is4 × floor(h) × max(vsize, 3,000) / 3,000with one integer division, capped at 10,000,000 bits. - Supersedes the 2026-07-17 decision in countdown §B3 (one level at genesis).
3.19 Naming cleanup#
- "finality catchup" becomes "catchup" in every comment, doc and script,
and in CLAUDE.md Rule 10. That is about 61 occurrences in about 30 files
(
grep -rniE "finality[ _-]?catch-?up"). The pool class is alreadyPriority::Catchup. The path is the propagation-fork / shallow-reorg catch-up and has nothing to do with finality. - Dead log pattern. Delete the
FinalityCatchupalternative from the log patterns inscripts/sync-timeline/sync-timeline.shandscripts/sync-timeline/monitor-visual.sh; no Rust code emits it. - Renames.
fork_finality_depth→propagation_fork_windowandFINALITY_DEPTH→PROPAGATION_FORK_WINDOW(193 references:grep -rn "fork_finality_depth\|FINALITY_DEPTH\b" crates). The value (4) and behaviour are unchanged. Update the compile-time assert message and the doc text "Finality depth … (4 blocks = 2 minutes)". MIN_PIN_BURIAL_INTERVALS(spek-config/src/checkpoints.rs) is never enforced: delete it, and record in the commit message that pins are governed by the existing restoration rule.
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)#
- Rollback cap and finality. Option A's cap K = 720 with E1–E4 let both
sides of a partition longer than 6 h finalize and refuse each other forever;
an eclipsed node finalized the eclipser's chain. This wave adds no new cap.
- Existing hazard, unchanged by this wave: the restoration pin already acts
as a rollback cap of 33–67 h. Pins advance with no isolation or
network-evidence guard (
restoration_pin_snapshottakes the tip as given), so a partition, or an isolated miner that keeps mining alone, longer than that pins its own branch and refuses the other on reconnect. Only a partition shorter than the pin heals by weight. rollback_floor()stays as it is in this wave. A later change may add a cap using a participation rule, finalizing only when the window's score density shows at least 2/3 of trailing participation, and must state its partition behaviour.- Residual, measured by S9 (
deep_branch_beacon.py). Only the attacker signs a private branch, so it knows every beacon of that branch before the beacon's stake cutoff. That gives it two levers:- Beacon choice. Choosing which own key signs near each beacon height gives as many candidate beacons as it can evaluate (one candidate costs about A·L hashes, about 0.13 s per core). A chosen epoch breaks even at q* = 0.467, 0.459 and 0.452 for 10^6, 10^9 and 10^12 candidates (10^9 is about 1,500 cores for a day).
- Stake fitting inside the branch. The branch can move stake units to
keys and slot seeds fitted to its own future beacons, the option A
attack on beacons it controls. I3 holds for every beacon with an
honest input, not for these. A moved unit stops counting at the spend
and counts again three epochs later, so fitting has to rotate.
- Inside the restoration pin (at most 8,000 slots, of which the first 3L run on stake fixed before the fork), fitting improves the branch by about 3% (q = 0.47: deficit ratio 1.084 against 1.121 without), so the beacon-choice thresholds above stand. Running nodes are bounded here.
- Beyond the pin (a fresh node in IBD, bounded only by compiled checkpoints), steady rotation breaks even at about q = 0.445 with 10^5 candidates per fit and 0.42 with 10^6, falling about 0.025 per tenfold more compute (a candidate costs about 10^4 hashes). This is below the 45% line; it was accepted at G1-22 because fresh nodes already rely on compiled checkpoints for every long-range attack, including bought or dormant keys, which need no fitting at all.
- Density window (E14A, VOID 2026-10-23: not adopted; see G1-22). Comparing forks deeper than L by weight over the first W ≤ L slots after the common ancestor (the Ouroboros Genesis rule) removes both levers. Those slots use shared beacons and stake fixed before the fork, so a branch needs q ≥ 0.48 for a 1.6% chance at W = L. It is a fork-choice rule, not a validity rule, so it can be added later without invalidating blocks. It shares the window-weight machinery of the participation cap above and the pin evidence rule below, and belongs with them. Decided against (E14A void): honest null slots score 0, a full 2^255 deficit per slot, so a dormant-stake branch wins some early window far below the modelled q ≥ 0.48 once the honest chain has ordinary outages (q = 0.30 wins 95% of the time at 1.4 nulls a day, N = 1000, 30 days). Fresh nodes keep relying on compiled checkpoints.
- Existing hazard, unchanged by this wave: the restoration pin already acts
as a rollback cap of 33–67 h. Pins advance with no isolation or
network-evidence guard (
- Advance restoration pins only with network evidence. Its own follow-up item, separate from this wave. A pin interval should advance only when the interval shows enough of the network producing it (and the node is not flagged isolated). The deferred participation-density rule above is the natural test, so the earlier finality work carries over. Until then, the hazard above stays.
- Proof pruning (node policy). Pruned nodes could drop
slot_path(320 B) from headers below the restoration pin (stored header 353 B), saving about 336 MB per year.- Deferred because the saving is small against the published minimum disk, it adds a second stored-header form, and it shrinks the set of peers that can serve full headers to IBD.
- It is not consensus, so it can be added later without a fork. Revisit when header storage passes 10 GB (about 13 years).
- M2 cross-parent double-sign forfeiture. Not in this wave (G1-4,
2026-10-20). A block's score does not depend on its parent, so a key that
signs two parents adds the same weight to both and gains nothing, and
evidence included only on the honest chain does not stop a retroactive
branch. The honest-signer guard below is a node fix that does not need M2
and is tracked separately.
- Evidence: two headers with the same key and height, different parents, and valid signatures and reveals. Carried in a capped evidence form.
- Effect: the key is ineligible for P·L slots, with an index and block undo.
- Includes the honest-signer guard
highest_signed_height(persisted withwrite_atomicand fsync before broadcast), which replaces thelast_mined_slot == 0restart detector.
4. Consensus Impact#
Type: Consensus (hard, genesis-frozen).
- Score, election seed, chainwork, fork choice, IBD ranking and candidacy.
- Header format (349 B → 743 B, 673 B on Round 2;
da_commitmentremoved;Cderived, not carried). - Output forms: hashed v1 and v2 added, P2PK removed, and the vsize rule for v2 inputs.
- Stage-9 eligibility: one stake output at least the requirement (no summing), cutoff margin and horizon window.
- Score preimage and commitment leaves bind
pk_e; epochs 0–1 draw from the genesis merkle root. - Seed phase: coinbase ticket records replace the in-header nonce.
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)#
- Panics on input. No
unwrap/expecton beacon resolution, reveal walks, Merkle paths, J0 arithmetic, cutoff arithmetic, record parsing or witness parsing. - Unbounded work.
- Reject a header with
g(h) < J0org(h) − J0 ≥ 2^dbefore walking; the walk is ≤ S steps and the path is exactly d. - Header verifies stay under the per-peer cap.
- Record RandomX work stays on the Mempool lane with its rate limit, and at most 16 per block.
- Reject a header with
- Lock scope. Beacon reads happen in the same
ChainStateborrow as the tip they score against. - Determinism. BigUint only. No floating point, including in ρ (a rational).
- Reorg undo. Stake-age outcomes, the ticket keyspace and
stake_value_indexrevert exactly. The index is a pure function of the UTXO set, so a boot rebuild must reproduce it bit for bit (assert this in the restoration test). - Index parity.
UTXOSet::find_stake_outputand theLedgerViewoverlay return the same outpoint for the same ledger state. That includes the tie-break when two outputs have equal value:Reverse<value>, then(txid, vout)ascending. - Score preimage.
beacon_scoretakespk_e. No call site may build a score preimage itself (I5, I10). - Key file. The slot seed is generated with
getrandomand never derived from the SQIsign key (I11). It appears only in the miner key file and inZeroizingmemory. - Secret handling. σ, tops and checkpoints never appear in logs, RPC, panic
messages or crash dumps. σ lives in
Zeroizingmemory. - DoS.
- Headers whose epoch lies outside the tip context (no resolvable beacon) are quarantined with the existing provisional budget.
- Headers whose J0 puts
g(h)outside their own horizon failvalidate_basic(a violation).
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#
- Option A as drafted. Rejected: §1.5.
- A signature over (beacon ‖ height) as the miner's randomness. It is sound only with a verifiably unique signature. SQIsign, deterministic or not, would be ground at one signature per try. The hash-based unique alternatives reduce to this RFC's committed chain.
- A zero-knowledge proof of eligibility instead of a public key. About 50–200 KB per header, and it still needs a committed per-height secret.
- Per-miner registration transactions or a registry contract. Rejected: a fee to mine, and chain growth proportional to the miner count.
- RANDAO commit-reveal transactions. Rejected: transactions per participant, last-revealer bias, and a deposit needed to deter non-reveal.
- VDF-delayed beacon. Rejected. The old reason ("predictable inputs") no
longer holds, because every input
r_his now secret until revealed, so a delay could stop the beacon-height winner from evaluating publish versus withhold. But a post-quantum delay function means either every node redoing the delay or a STARK proof per epoch. That is too much cost and code for a measured 1–5% withholding gain (§3.7, G1-6). - Cold-staking fields in the stake output. +32 B per header. Not adopted (G1-5); a mining box stays a hot wallet holding one stake unit, as today.
- Carrying
Cin the header (slot_commit, 32 B). Rejected: the walk derives it. Carrying it would let a mismatched reveal count as a fork-independent violation, but it costs 34 MB per year, and the code already checks the signature before the stake (§3.10, §3.11). - Deriving σ from the SQIsign secret key. Rejected (I11): a key-recovery
break would reveal every future
r_h. - Only one of score binding and leaf binding. Rejected; both are adopted (I10). Leaf binding alone leaves an in-segment copy, because a segment top is public after its owner's first reveal (S7: below key share for the copier, but griefing for the victim). Score binding alone leaves copied commitments valid (harmless, but they are dead stake that validators must still look up). Leaf binding costs 1024 hashes per build and one per verify.
- Genesis block hash or all-zeros seed for B_0 and B_1. Rejected: the hash can be ground by re-signing, and the zero seed is known forever (§3.7).
- Summing stake outputs, or an era-threshold stake index. Rejected (§3.6): summing is O(outputs) and dust can inflate it; an era threshold needs an O(UTXO) rebuild inside a commit.
- Paying every coinbase to the stake address. Rejected (§3.5): it piles rewards under a hot, public Level I key.
- A dedicated stake script template. Rejected: a hashed v1 output with the slot commitment needs no new template and cannot collide with a witness version.
- A plain v2 (Level V) output form. Not needed: the hashed v2 form carries both key-path and script-path spends.
- Horizons of 4.2, 16.8 or 33.6 years. Depth 9 needs about 491,000 renewals per year at 5M miners; depths 11 and 12 add 32–64 B per header for no gain below 5M.
- Six-day segments. They double the walk and IBD walk CPU to save 34 MB per year.
- Per-key horizon depth. Every miner would choose the maximum.
- One-sided score. Security-neutral; middle-out is kept.
- SQIsign Level III or V for headers and all keys. Rejected: verify budget, 24–38% fewer transactions per block, and a structural break would hit every level at once.
- A variable-length header (per-network depth). Rejected: one header size
everywhere keeps
BLOCK_HEADER_SIZEa constant. - Ticket records as a new transaction kind or block section. Rejected: zero-input transactions are invalid, and a body section would change the merkle and compact-block formats. The coinbase OP_RETURN form needs neither.
- Count per-height wins; block-hash beacon; rolling beacon. Rejected (2026-10-18 analysis).
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#
- Read §0, this section, your session entry, and the sections it names. Read
only the
design_architecture_analysisfiles named (navigate from00_TABLE_OF_CONTENTS.md). - 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.
- Run only the targeted tests listed (§0 env vars). Never run the workspace suite.
- 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. - 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. - Tests go in sibling
<module>_tests.rsfiles with#[path].- Test fixtures that need slot fields use a shared helper,
spek_core::slot_secret::testing::slot_fields_for(keypair, height, j0)(featuretesting, added in E4). It caches tops per key in aOnceLockmap, 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.
- Test fixtures that need slot fields use a shared helper,
- End every session with:
- a commit on
rfc0012that ends with the attribution line; - the session row in countdown §B4;
- a memory entry;
- the §15 change log if the design moved.
- a commit on
- 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 --workspaceand 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.
- 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/:
-
S1
beacon_predict.py:B_eprediction rate under option A (expect p^r) and underr_h(expect no match in 10^6 trials). -
S2
stake_fit.py: an epoch's block share for 5–10% stake choosing from 10^5–10^6 keys, option A versus this revision (expect the key share). -
S3
withhold_grind.py: reversible-window depth under option A (reproduce 34 / 80), and the residual underr_h, for q ∈ {0.1, 0.2, 0.33} with 1–2 withholds.- The
r_hattacker evaluates every choice. It knows all of its own futurer_hand every competing header at the boundary, so it scores each candidate beacon by its exact per-slot win chances (start fromwithhold_eval.py). - Report the epoch-win gain and the change in the probability of a planned run of k ∈ {4, 6, 10} near-certain wins.
- Model the number of choices from the attacker's run of tail wins (geometric in q), not as a fixed 2.
- The
-
S4
ticket_select.py: ticket yield with and without the registry, N ∈ {5, 20, 50, 100}. -
S5
renewal_scale.py: renewals per year at 1k–5M miners, depths 9–12. -
S6
header_verify_load.py: header checks per slot under top-5 relay at 1k–5M miners. -
S7
commitment_copy.py: copied-commitment share in four cases. Extend the seed script with the key-bound cases.Binding Expected share Measured (E1, q = 0.2 / 0.33 / 0.45) no binding q/(1−q) 0.249 / 0.490 / 0.822 leaf binding only, in-segment copy after the victim's first reveal above q 0.125 / 0.248 / 0.405 when copying (0.5·q/(1−q): live half of each segment), so own trees are better; victims lose about 40% score binding only q 0.205 / 0.334 / 0.454 both bindings q 0.202 / 0.334 / 0.454 (copies find no stake) -
S8
launch_grind.py: the share of epochs 0–1 a launch member gets by grinding keys and slot seeds against a known beacon (zero seed) versus the merkle-root beacon with commit-reveal. Use 5 and 20 members and 10^4–10^6 tries. -
S9
deep_branch_beacon.py: the share needed for a reversal deeper than two epochs when a private branch chooses its signing keys near beacon heights.
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):
StakeShift { pool_size }: after each own win, move the stake unit to the pool key with the best draw for the next slot.PrivateBranch { fork_depth, key_choice: Greedy | Search { beam } }.RetroSign { dormant_keys }.StakeFit { pool_size }: post-fix only; funds the keys predicted best and expectsSTAKE_AGE_REJECTor no gain.WithholdGrind { max_withholds }: post-fix only.TicketShop { candidates }: post-fix only, seed phase.JunkReveal { rate }: post-fix only; headers with garbager_hfor real stake outputs.CommitmentCopy { victims }: post-fix only; funds stake addresses that reuse victims' published C and J0 with low-pubkey keys, and replays each victim's revealedr_hin the same slot. Expects no share gain (I10).StakeDust { outputs }: post-fix only; sends many small outputs (and some at least the requirement but too new) to a top miner's stake address. Expects the receipt lookup time on the robust node's gauge to stay flat (§3.6).
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:
- D1-A
StakeShift pool=4096, 300 post-seed slots. Expected FAIL: more than 80% of slots after the first natural win. - D2-A
PrivateBranch fork_depth=20 key_choice=Search{24}, 30% share. Expected: a reorg deeper than 5.
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/.
- Add
election_epoch_blocks,slot_segment_blocksandticket_records_per_block_maxto the consensus struct and every profile, with knobs and validation (§3.1). Change the regtest round and seed-phase defaults. - Delete the three dead params and their docs.
- Rename
fork_finality_depth→propagation_fork_windowandFINALITY_DEPTH→PROPAGATION_FORK_WINDOWeverywhere (193 references), including the assert message and doc text. - Do the catchup rename in comments, docs, scripts and CLAUDE.md Rule 10, and
delete the dead
FinalityCatchuplog patterns. - Delete
MIN_PIN_BURIAL_INTERVALS. - Tests (
params_tests.rs): validation rejectsS % 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.
spek-core/src/slot_secret.rs: everything in §3.3, includingSlotSecretStoreandSlotRevealError. Add the test helperslot_secret::testing::slot_fields_for(keypair, height, j0)behind the existingtestingfeature (the one that exposesBlockHeader::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 aSlotSecretStoreper key in aOnceLockmap.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, withpk_e),GENESIS_WORK.- Add
derive_election_seed_v2as a new function. E6 renames it toderive_election_seedand 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. 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.spek-randomx:TicketPreimageV2(stake program) andTICKET_DOMAIN_TAG_V2beside v1.spek-core/src/ticket_record.rs: theTicketRecordcodec (encode,parsefrom an output script) and the ceremony hashes.- Unit tests (sibling files):
- the derived root equals
slot_commit_rootat 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 tor_h;- σ differs per slot seed; tags pairwise distinct;
- the same
(B, h, r_h)under two differentpk_egives different scores, and the same tops under two differentpk_egive different roots, so a reveal and path valid for one key derive a non-matching root for the other (I10); calculate_min_coin_requirementis 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_beaconrefuses epochs 0 and 1; null and genesis scores; Rule 1 edge cases throughbeacon_score; - record codec round trip and every malformed length;
- preimage layout.
- the derived root equals
spek-corestake lookup (§3.6), not yet consumed:stake_value_indexinUTXOSet, maintained inadd,removeand everyscript_indexrebuild path;StakeOutputSource::find_stake_outputforUTXOSetand theLedgerViewoverlay, with the overlay merge;entry_counts_as_stake. Tests inutxo_tests.rsandledger_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.
- 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.
ScriptType::HashedCommit: detection (exactly0x51 0x20+ 32 bytes),is_admissible_output,as_str("witness_v1_hashedcommit"), and disassembly.- Spend validation (§3.4): key path and script path, disambiguation by last-item length, and the NUMS refusal on the key path.
- Bech32m: v1 program length 32 or 97, both directions; canonical-encoding checks unchanged.
tx_builderand wallet: hashed receive, change and coinbase addresses (key-path-only commitment = zeros); signing produces[sig, reveal].- RFC-0008: an optional per-input
revealrecord. Update the PSBT codec and signer and bump the format version if RFC-0008 versions it. - 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.
- The new
BlockHeaderlayout,SlotFields, constructor, signing bytes, serialize/deserialize, serde, null and genesis forms,validate_basic; the 673/525 constants; removeda_commitmenteverywhere in production code. - Rename
derive_election_seed_v2toderive_election_seedand delete the old function. AddBlockHeader::score(&Beacon)and deletecalculate_score. Update the spek-core and spek-consensus production sites, with chain.rs commit seams resolving viabeacon_for_epoch. - Stage 9: eligibility per §3.6.
- Derive C with
slot_root_from_reveal, rebuild the stake script, then callfind_stake_outputwith the cutoff and requirement. - Delete
get_mature_balance,mature_stake_balanceandMatureBalanceSource; no caller of the summing form may remain. VerifiedHeadergainsslot_root()(§3.11).
- Derive C with
- Seed phase:
- record validation in the coinbase (§3.14 inclusion rules);
- the
ticket_recordskeyspace, written in the commit batch and deleted on disconnect; - seed-phase eligibility through the keyspace;
- delete the header-nonce ticket path (
SeedTicketInputvariants that readda_commitment), the v1TicketPreimageandTICKET_DOMAIN_TAG; renameTicketPreimageV2/TICKET_DOMAIN_TAG_V2to the plain names and drop theTicketInputtrait if only one preimage type is left.
- Bump
WIRE_GENERATION. - 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_epochon the main chain. That is correct for every live path, and E7 replaces it where §3.12 names another source. miner_key.rsmoves to the 450-byte key file with the slot seed (§3.3, §3.15): generate, persist and load, rejecting any other length. Addspek_core::slot_secret::MinerKeyFile(parse,encode, accessors); portminer_key.rs,miner_keygen.rsandsnapshot_gen.rsto it. Tests go inminer_key_tests.rs: round trip, every wrong length, and the public key read back equal to the generated one.mine_headerbuilds aSlotSecretStorefrom the slot seed at boot, with one horizonJ0 = g(tip + 3·L)held in memory, and fills realSlotFieldsfrom 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.
- Score sites without their §3.12 source yet call
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.
- The
BeaconSourceimplementations per §3.12, including the IBD candidate cache andBeaconTrackerin every sweep. - 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_PREFILTERandSLOT_REVEAL_REJECT.- Rewrite the stale "O(1) … via
pubkey_index" comment abovecandidacy_receipt_gateto the bounded index walk.
- the pre-filter (claimed score with
- 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_stagedstays storage-only; keep its torn-write and WriteBatch invariants intact). - RPC header shape (§3.16);
refresh_node_statusand logs. - 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).
SlotSecretStore, horizons file (withcommit_root), horizon creation rule,HORIZON_CREATEDandHORIZON_EXPIRING.mine_headerper §3.15. The coinbase payout rule per §3.5:target(h), the covered test,--payout-address/payout_addresswith boot validation;PAYOUT_ADDRESS_UNSETandCOINBASE_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.
- Ticket search keyed by
(round, A); record submission and re-announce;TicketRecordPool; theticket-recordstopic; admission on the Mempool lane with the rate limit; producer inclusion;TICKET_RECORD_*instruments. - 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.
- RPCs
getstakeaddressandcreatestakehorizon(regtest only), plus the eligibility fields. - 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-addressonce covered;createstakehorizoncreates 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 noJ0 ≥ 0ends 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.
- Remove P2PK from production code and tools (§3.17), with no compatibility path. Every caller moves to the hashed form.
is_admissible_outputadmits exactly plain ScriptCommit v1,HashedCommitand zero-value OP_RETURN (HashedCommitL5arrives in E13).- 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).
- Migrate fixtures to
slot_fields_for(§11.1 item 6) and updateBlockHeader::new_test; movecreate_test_blockpatterns to the helper. - Turn Appendix C into
stake_funded_one_block_before_is_rejected(expectNotEligible { reason: NoStakeOutput };InsufficientCoinsbecame that variant in E6). - 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 theL + 1age a new output needs. The originalstake_created_inside_reorg_range_uses_branch_viewis kept as E16 drill D30, on the deep reorg path where it is reachable.)
- 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):
beacon_fork_invariance(real branches above and below a beacon height);propagation_reorg_scores_fork_blocks_under_the_branch_beacon;private_branch_key_choice_has_no_effect_within_epoch(real chain beacons, planned branches committed);test_chainwork_paths_agree_commit_ibd_staged_and_boot_restoration(spek-node: commit seam, IBD staged underCandidateBeacons, boot restoration);tip_context_horizon;prefilter_drops_unrankable_headers_before_verify;junk_reveal_flood_is_bounded_by_the_per_peer_verify_share;commitment_copy_has_no_share_gain(CommitmentCopy mode);stake_dust_does_not_slow_receipt(StakeDust mode);coinbase_payout_switches_when_covered;ticket_record_relay_and_inclusion.
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.
- Confirm no P2PK is left anywhere, tests included
(
grep -rn "p2pk_script\|ScriptType::P2PK\|validate_p2pk\|has_p2pk_shape" cratesis empty; E9 removed production code and E10–E11 migrated tests). - 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. ExposeSqisignL5Keypair,SqisignL5PublicKey,SqisignL5Signatureandsqisign_l5_verifyinspek-crypto. - Add the seeded Level V stack in
spek-crypto-seededbehind thewalletfeature, plus the RFC-0007 Level V context and an end-to-end vector. - 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, setw5, and fixRHO_L5_NUM/RHO_L5_DENin §3.18 and §10. (Done 2026-10-22; recorded indocs/final_tests_2.md, sincedocs/final_tests.mdis closed to new records.) - 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.
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).- 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_L5and the v2 budget base. tx_vsizein block validation, the mempool, the template selector and fee rate.- 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). - 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.
- A Level V NUMS internal key (non-canonical encoding, refused on the key path).
- v2 covenant templates for new descriptors.
- Guardian daemon Level V shares.
- RFC-0011 interop version bump with new vectors.
- Genesis allocation families on v2.
- 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/.
- 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.
- If adopted, the rule:
window_weight(chain, a, W)= the sum ofscore(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_weightwins. 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 (insidePROPAGATION_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_weightas the shared helper the deferred participation cap and pin evidence rule (§3.22) will reuse.
- Tests in sibling
_tests.rsfiles: 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. - 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)#
- Review the full diff since the E1 base against §7.2 and the threat model.
- Run the unused-item scan over touched crates. There must be no TODOs and no
leftover
calculate_score,da_commitment,slot_commit, P2PK, "header-local", summed-stake or v1 seed paths, and no transitional_v2names (grep -rn "calculate_score\|da_commitment\|slot_commit\b\|p2pk\|header-local\|election-seed/v1\|get_mature_balance\|mature_stake_balance\|derive_election_seed_v2\|pubkey_index"). - Check I10 and I11 by reading every score preimage and every slot-seed source.
- Check the diff for banned terms, emoji and issue labels.
- Fix wave,
cargo fmt, clippy, and targeted tests for every fixed site.
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)#
design_architecture_analysis/:- 01: header layout, receipt order, candidacy, scoring, commit;
- 02: hashed outputs, stake outputs, ticket records, vsize;
- 03: consensus timing; delete the old 4-block finality text;
- the IBD and checkpoint sections: beacon resolution per candidate;
- 07: RPC.
- Describe what the code does, with no fix history.
- CLAUDE.md:
- Rule 1 rewritten (score v2 with
pk_e, seed v2 withr_h, derived C, I1–I4, I7, I10, I11); - Rule 10 "catchup";
- 673 B wherever 349 B appears;
- Rule 13 mentions hashed outputs if signing text changes.
- Rule 1 rewritten (score v2 with
- WHITEPAPER.md: election (slot secrets, beacon, stake age, ticket registry), header size, settlement (Appendix B table B1, pins, no cap), signature levels and the §8.5 migration text. Merges with WEBSITE_PLAN §15 S1.
- RFC-0007 and RFC-0008 revisions; RFC-0011 (E14); refresh countdown §B3 and §B4.
E18 — Land (about 250k)#
- Commit the wave to master as reviewable commits, each ending with the attribution line.
- Regenerate estate, fixtures and every network's genesis, with the ceremony
ticket records.
(2026-11-05, user decision: the estate and fixture regeneration is not repeated;
the E16 closing run's regeneration on
634a6a1stands, seecountdown.md.) - Prepare rehearsal #2: fleet reprovisioned from scratch; signer set per the launch guard.
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#
- Q1: answered by E12 (ρ = 171/50 on Round 2) and re-measured on Round 3: ρ = 151/50 (§3.18, G1-11).
- Q2: answered by E1 (Appendix E). S3 stays below 5% (G1-6 confirmed). S9's beyond-pin fitting threshold falls below 45%; the user accepted it (G1-22) and the density window was considered for E14A and not adopted (E14A void, §3.22).
- Q3: answered (G1-4 no, G1-13 yes).
- Q4: network-evidence rule for advancing restoration pins (§3.22), its own follow-up item, together with the participation cap. The density window is not adopted (E14A void).
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#
- Ouroboros Praos (David, Gaži, Kiayias, Russell 2018): epoch randomness from private VRF outputs; stake snapshots taken before the nonce is fixed.
- Ouroboros Genesis (Badertscher et al. 2018): chain density rule (basis for any later participation-based finality rule).
- Micali, Rabin, Vadhan 1999: verifiable random functions from unique signatures.
- Lamport 1979; Winternitz one-time signatures; XMSS: hash-based one-time keys.
- BIP-141 and BIP-341: witness versions, 32-byte programs, tagged hashes.
- Rosenfeld 2014: hashrate-based double spending (forward race baseline).
- SQIsign specification v2.0 (NIST additional-signature round 2): parameter sets I, III, V.
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:
- honest best deficit ~ Exp(rate a), a = 1 − q;
- attacker best deficit ~ Exp(rate b), b = m·q, where m = 1 for fixed draws and m = e for path grinding.
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). |