# Mission item 4: weight-gated deep fork choice, the gate (status log) Lane: horizon (mission items 4 and 12), started 7 October 2026, 12:41 UK, on main's brief. Branches: `horizon` (this repo, on master d1285cef) and `horizon-node` (the fork, on release-0.3.20-node dc141409, the 0.3.20 line; this work targets 0.3.21). Worktrees `igneum-wt-horizon` and `igneum-wt-horizon/vendor/igneum-node-horizon`. Times are UK (BST) unless marked Z. The item (mission.md 2.4): a tip whose fork point is older than D (10 min of past-median time) is a fork-choice candidate only if the keys that built it hold at least a third of the weight table at the fork point. Gate, fast time: a 51 percent fresh-key fork from 15 min back is refused by every honest node; a one-third-weight fork is accepted; partition heal unchanged. Known-failed cases first. ## 1. What already existed (read before writing anything) | Where | What | State | |---|---|---| | fork `consensus/src/processes/finality.rs` `deep_fork_refusal`, asked by `sink_search_algorithm` for every popped candidate | the rule, behind `fork_gate_activation_daa` (never on every network; in the digest once set) with `fork_gate_window_daa` 600 s; the fork point is the highest chain ancestor of the candidate on the sink's chain; depth = the larger of sink minus fork and candidate minus fork; builders = the vote keys of every block of the candidate's chain since the fork (chain blocks and mergesets); the table = `voters_at(fork)`; refused under `3 x weight < total`; node-local memo per refused block | on the 0.3.20 line since 61b22057 (6 October), fixes 3301cf32 (depth along both chains) and aa0182aa (no extension shortcut) | | fork unit tests | `without_the_fork_gate_a_minor_keys_deep_fork_wins` (known-failed), `the_fork_gate_refuses_a_deep_fork_under_a_third_and_passes_the_rest` (25 percent refused, 75 percent passes, inside the window passes) | green on that line | | `infra/fast-time/fork-gate.mjs` on branch ca3-v4-node (never on master) | a TWO-node harness: A at 75 percent, B at 25 percent, A IDLE through the split; cases gate-on hold (PASS 23:45Z 6 Oct), gate-off reorg (PASS), gate-off hold (FAIL as it must); run on the Mac; a `pkill -f` on the devnet suffix | the record in `docs/plans/counter-asic-3-node.md` 7.5 | | the live devnet and Devnet 2 override files, the testnet object | no `fork_gate` field anywhere: the switch is never on every network | unchanged by this lane | What the mission gate asks beyond that record: a FRESH key (0 of the table, not 25 percent), the honest side MINING through the split (the record idled it), EVERY honest node (two, not one), the one-third case accepted, and the partition heal shown unchanged with the gate on and off. Nothing in the rule needed to change for any of these; this lane adds the proof. ## 2. What this lane added | Piece | Where | What | |---|---|---| | Fresh-key unit test | fork `a_fresh_keys_deep_fork_wins_only_with_the_gate_off` | known-failed first: at never a key with no block in the table wins by blue work; at 0 it is refused with weight 0 of the table | | One-third line unit test | fork `the_gate_passes_at_one_third_of_the_table_and_refuses_under_it` | eight (honest mix, fork height) pairs; each outcome equals `3 x weight >= total` read from the table the gate reads; both sides seen | | Three-node harness | `infra/fast-time/fork-gate.mjs` (new on master's line) | H1 and H2 honest and mining through every phase, B the third node; modes attack (B fresh, 3 of 5 threads), third (B at about half of the table), partition (H1 against H2); a reorg is read from the chain (`getVirtualChainFromBlock` of the pre-cut tip lists removed blocks), never from a key; blue work compared as BigInt; leftovers stopped by pid file | | The six-case runner | `infra/fast-time/fork-gate-gate.mjs` | known-failed cases first, then the gate line, the one-third case, the two partition heals; GREEN only when every case gives the verdict it must and the two heals agree | | Box wrapper | `tools/fast-time-remote.sh` (whole-body block; listed in `tools/ci/whole-body-check.sh`) | runs a harness on a box under a slot with a holder line and a JSONL row, the node binaries from a fork worktree's target on the box, results fetched back | ## 2a. The hole the known-failed cases found (13:1x to 13:3x UK) The one-third unit test failed on box 2 at its second pair: B on every fourth block, fork at height 50, B holding 12 of 49 at the fork, and the private tip WON. A probe test (temporary, since removed) read the gate's own answer at fork heights 40, 45, 48, 50, 55, 60 and 65: refused at 40, 48 and 60; let through at 45, 50 and 55 (65 is inside the window, a legitimate pass). The let-through rows read "keys 2, signed 44 of 44": the honest key A was in the attacker's builders. The pattern is the fork block's own key: heights that are multiples of 4 sit on B's blocks and were refused; the others sit on A's blocks and passed. Cause: `collect_builders` took each chain block's mergeset (blues and reds) and GHOSTDAG's `mergeset_blues[0]` is the block's selected parent, so the first private block above the fork carried the FORK block into the builders, and the fork block was A's. A 25 percent key forking right after a 75 percent key's block walked through the gate with A's weight on its side. The same reading let a private chain that merges honest blocks as side parents inherit every merged key. The 6 October harness PASS (23:45Z) was luck: its pre-cut sink was B-built, so the leaked key was B's own. Fix (fork, `collect_builders(block, sink, keys)`): a builder is the key of a block the sink does NOT have (not a DAG ancestor of the sink, the sink itself included). The fork block is the sink's; honest blocks the attacker merged are the sink's; a partition side's own blocks are not, so a heal is unchanged. New unit test, known-failed shape first: `a_deep_fork_counts_only_the_blocks_the_sink_lacks_as_builders` (B from A's block at 45 refused; a private chain merging honest blocks 46 to 80 wins at never and is refused at 0). Also seen: `ban_is_decided_by_the_carrying_block_so_nodes_agree_on_every_voter_list` died with `UnexpectedDifficulty` in the full crate run (box 2 under load 65) and passed alone (1 of 1, 13:1x UK); not this lane's change, noted for the suite count. ## 3. Runs ### Commits | Repo | Branch | Commit | What | |---|---|---|---| | fork | horizon-node (on release-0.3.20-node dc141409) | eb32d2e0 | the builders fix (`collect_builders` skips every block the sink has) and the fork-gate tests on the two-instance lab | | fork | horizon-node | a6a71ac2 | the lab inserts into the judge first and rebuilds a block refused on `UnexpectedDifficulty` (the suite's flake class) | | repo | horizon (on master d1285cef) | 0dc2adff | the three-node harness, the six-case runner, the box wrapper, the whole-body check row | ### Suites (box 2, igneum-build-2, nice 10 on 32 cores) | Run (UK) | Command | Result | |---|---|---| | 12:5x | `-- test --release -p kaspa-consensus` (the first test version, no fix) | 111 of 113: my one-third test FAIL (the hole), `ban_is_decided_by_the_carrying_block...` FAIL on `UnexpectedDifficulty` (load 65; passes alone 13:1x, 1 of 1) | | 13:2x | `-- test --release -p kaspa-consensus --lib processes::finality::tests` (the fix, the lab) | 24 of 24 | | 13:3x | `-- test --release -p kaspa-consensus` (eb32d2e0 content) | 112 of 114: two lab tests FAIL on `UnexpectedDifficulty` (the same flake class, now reachable by the lab's second instance) | | 13:3x | `-- test --release -p kaspa-consensus` at a6a71ac2, 0dcaf4c5, ca7acb99 (the lab's rebuild, a pause between rebuilds, salted hashes) | 111 or 112 of 114 each: the two labs whose instances share one config still FAIL at block 61 under load; the probes showed `UnexpectedDifficulty` thirty times over 7.5 s, so neither clock nor hash memo | | 13:38 | `-- test --release -p kaspa-consensus` at 6eb21fc9 (the lab on `DifficultyRule::KaspaSampled`, DAG-only bits) | 113 of 114 in the lib target, 3 ignored; the one FAIL is the pre-existing `ban_is_decided_by_the_carrying_block_so_nodes_agree_on_every_voter_list` (the same class: the dual rule's template bits and header stamp disagree at block 61, the first retarget, under load; passes alone). The lib target failing stops cargo before the integration targets. | Fork commits after eb32d2e0, test-only: a6a71ac2, 0dcaf4c5, ca7acb99, f225ed9f, 6eb21fc9 (the lab inserts into the judge first and rebuilds on the flake; a pause between rebuilds; every lab its own hash words; the DAG-only difficulty rule; the import). The binary content of kaspad and igneum-miner is the same from eb32d2e0 on; the gate binary carries 6eb21fc9. ### Build (box 1, igneum-build-1) | Run (UK) | Command | Result | |---|---|---| | (pending) | `tools/build-remote.sh --no-fetch -- build --release -p kaspad -p igneum-miner --features kaspad/igneum-pow` from the fork worktree at a6a71ac2 (igneum-pow at 8c728ca3 through an untracked paths override to `igneum-pow-amend`, the 0.3.20 pairing); two earlier submissions of the uncommitted tree were stopped by pid so the gate binary carries the commit | | ### Tree checks (the Mac, checks only) `tools/ci/pre-push.sh --ci` on horizon 0dc2adff: GREEN, 43 checks in 31 s (13:3x UK). ### The gate (box 2, igneum-build-2, by main's word at 13:4x UK: box 1's slots stay with the 0.3.20 pin) Binary: `igneumd` and `igneum-miner` of horizon-node 6eb21fc9 (igneum-pow 8c728ca3), built on box 2 at 13:5x UK (`tools/build-remote.sh --box 2`, 113 s); run through `tools/fast-time-remote.sh --box 2` (one slot, the bounded class, 583 s for the six cases side by side), case summaries and logs in `docs/plans/mission-item-4-gate/`. Window 60 DAA, joint 240 s, split 180 s, watch 150 s; honest nodes H1 and H2 at one CPU thread each through every phase; the attacker at 3 threads in the split (60 percent of the CPU hash: the refusal reads weight, not how much heavier the chain is). | Case | Must | Got | Side 2 at the fork | Fork depth (DAA) | Blue work at the heal, side 2 against H1 | Honest nodes after the heal | |---|---|---|---|---|---|---| | attack, gate off, expect reorg (the rule's known-failed case) | PASS | PASS | 0 bps (fresh key) | 191 | 17,470,131 against 14,893,198 | H1 and H2 reorged at 510 s, 0 refusal lines | | attack, gate off, expect hold (the harness's own failed shape) | FAIL | FAIL | 0 bps | 179 | 19,683,187 against 17,459,678 | H1 and H2 reorged at 480 s, 0 refusal lines | | attack, gate on, expect hold (THE GATE LINE) | PASS | PASS | 0 bps | 197 | 20,297,242 against 14,807,915 | H1 and H2 HELD for the whole watch, 177 refusal lines each, both know side 2's tip; side 2 kept its chain | | third, gate on, expect reorg (a one-third-weight fork accepted) | PASS | PASS | 6,000 bps | 166 | 28,969,222 against 24,628,833 | H1 and H2 reorged at 477 s, 0 refusal lines | | partition, gate on, expect reorg | PASS | PASS | 3,719 bps | 194 | 18,089,683 against 11,490,863 | H1 reorged at 507 s, 0 refusal lines; H2 kept its chain | | partition, gate off, expect reorg (the same outcome) | PASS | PASS | 5,583 bps | 191 | 19,576,159 against 12,270,631 | H1 reorged at 507 s, 0 refusal lines; H2 kept its chain | GATE GREEN, 13:58 UK: 6 of 6 cases gave the verdict they must; the partition heal is the same with the gate on and off (both reorg H1 onto the heavier side, 0 refusals). Reading "15 min back" at fast time: the devnet window is 600 s and the mission's 15 min is one and a half windows; the harness keeps the window at 60 DAA and the split at 180 s, three windows deep, so the refusal was asked from 61 s of the split on and held for its whole second half. ## 4. Consequences per tier One sentence: with the fix, a renter's or a chain-splitter's deep reorg is bounded by weight for every tier alike, and an honest key's own blocks are never refused. | Tier | What the number means | |---|---| | Home miner (one card, any size, Windows, Linux or macOS, any vendor) | its node refuses the same tips a rig's or a pool's node refuses; a fresh-key chain from more than ten minutes back never becomes its sink while the key that built it holds under a third of the window; nothing to configure, nothing to pay; the memo costs one mergeset per refused block | | Rig | the same; a rig's own blocks after a connectivity gap are built by its own keys and pass on share | | Pool user | the pool's node holds the chain the pool mined; a renter cannot reorganise the pool's payouts past ten minutes without a third of the weight | | Exchange and holder | a 12-hour double spend during a pause or in the first month (51-percent.md section 5 rank 2, USD 146 at 1 GH/s) is gone: the deep fork is refused at every honest node, lock or no lock | | The rule's cost | none for certificates; a partition side under a third cannot reorg the other past the window (the intended outcome, unchanged by the fix) | Before the fix the bound held only when the fork sat on a block of a small key; a renter forking right after a large miner's block, or merging honest blocks into its chain, walked through on every tier. ## 5. Open | Item | Owner | |---|---| | Merge: horizon-node is on release-0.3.20-node dc141409; main's word is to rebase onto the 0.3.20 pin once named (c4459193 the candidate) for 0.3.21; never a push to a release branch | this lane, when main names the pin | | The difficulty red: `ban_is_decided_by_the_carrying_block...` and, before the lab moved to the DAG-only rule, two lab tests, on `UnexpectedDifficulty` at block 61 (the first retarget) under the full suite's load; the dual rule's template bits come from a virtual resolved at one instant and the header is stamped at a later one | the node lane for 0.3.21 (main's word: logged, not fixed here) | | The fork's test block builder asks the gate (it runs the sink search from the parents it is given): any future test that builds a private chain on a gated instance builds it on the fork block instead; the lab's two-instance shape is the pattern | the node lane; a note in `consensus/src/consensus/test_consensus.rs` is owed | | `tools/box` named in the brief does not exist; the box tooling is `tools/build-remote.sh` on `infra/build-server/lib.sh`, which `tools/fast-time-remote.sh` uses; the wrapper's first run checked the worktree root out on box 2 and took the fork sources with it (fixed the same hour: overlay only, 10140f9f) | main's reading | | The devnet, Devnet 2 and the testnet object carry no `fork_gate` field: the switch stays never everywhere until a cut sets it (in the digest once set) | the shipper and the founder | ## 6. Rebase for 0.3.21 (14:0x UK, the shipper's order) horizon-node rebased onto release-0.3.20-node c4459193 (the 0.3.20 pin) in one round, no conflict: tip 437f0438, on both box mirrors under its name. Suite on build-2 at 437f0438: `-p kaspa-consensus` lib 117 of 119, 3 ignored (the ban difficulty flake and `a_chain_that_misses_an_adopted_lock_never_locks_here`, which passes alone 1 of 1 and passed in every earlier full run; load 113). Digest on tools/fleet/override.json (origin/master): 7bd98cc4..., the same as the pin's own binary on that file. Handed to the node lane a283f5f0d364ceef0 for the 0.3.21 merge after 55768f88, miner-reliability-20 and pool-finish-node. Digest on the file hub-1 runs (/root/fleet/override.json, sixteen fields, the copy at /tmp/igneum-devnet/override-v3-live.json on build-1): eada4bda8aa8368c2b2c3d17744bc7a70a0ff0e996dad681884d3ac5de1207eb, the shipper's string, read 14:1x UK from the branch binary on build-2; 7bd98cc4 was master's tools/fleet/override.json, another file. The live digest is unchanged by this branch.