Files
consensus/engine/chain/multinode_liveness_multiround_test.go
zeekayandHanzo Dev d029323697 fix(engine/chain): durable + epoch-keyed + funnel-pruned vote-once guard (Red HIGH-1/HIGH-2/MEDIUM-1)
Fast-follow hardening of the per-height vote-once fork fix (v1.33.2). Red's
re-review found three residuals that leave post-crash equivocation reachable;
this closes all three and adopts Red's harness-free suite as the standing gate.

HIGH-1 — PERSIST committedSlot. The guard was an in-memory map: a crash/restart
between casting a vote and finalizing the height forgot every unfinalized binding,
so the restarted node could sign a conflicting sibling → a cross-node fork with
ZERO Byzantine intent under a rolling upgrade / k8s eviction / OOM. New
vote_guard.go adds a durable VoteGuardStore (Tendermint priv_validator_state.json
pattern): the (height,epoch)→canonical binding is fsync'd BEFORE the signature is
cast (reserveSlotForSign returns true only after the durable write commits — a
write failure FAILS CLOSED) and reloaded on startup (WithVoteGuard seeds
committedSlot). The one concrete store is an atomically-replaced, crc32c-checked
snapshot file (write-temp → fsync → rename → fsync-dir); a corrupt file is a hard
error at open (a signer must not start with equivocation memory it cannot trust).

HIGH-2 — key (height,epoch), matching the consensus2 reference (SlotKey={height,
epoch}). epoch = the block's ValidatorSetRoot, already folded into the signed vote
message. Height-only keying is SAFE but can STALL a quorum at a validator-set
change (a contested height re-proposed under a new epoch is refused by everyone who
signed the old-epoch block). Cross-epoch safety is preserved independently by the
per-height finalize gate (ErrHeightAlreadyFinalized) plus the induction that the
set at a height is a function of the uniquely-finalized lower chain. A fixed-set
chain leaves epoch=Empty, so the slot degrades to height-only (backward-safe).

MEDIUM-1 — fix the prune leak. pruneCommittedSlotsBelow ran ONLY from
HandleIncomingCert, which the dominant LOCAL finalize path never reaches (it
short-circuits at pending.Decided). Moved the prune into the sole finalizer
acceptWithCertCore (beside pruneSlashingBelowWindow), the one funnel EVERY finality
path shares, so committedSlot stays bounded on every validator; removed the now-
redundant HandleIncomingCert prune.

Tests (all -race): Red's red_independent_fork_test.go adopted verbatim as the gate
(mechanical API adaptations only — epoch arg, SlotKey; leak test promoted from Skip
to a hard MEDIUM-1 assert); TestBlue_VoteGuard_CrashRestart proves a reopened store
refuses a sibling after a simulated crash; TestBlue_CrossEpochLiveness_NoStall
proves no stall at a set-change boundary while intra-epoch safety holds;
fail-closed / corrupt-file / round-trip store gates; a multi-round down-proposer
liveness test with an ACTIVE short RoundTO (fork-fix × liveness-fix interaction).
Verified: neutering the guard flips OwnVoteRefusedForSibling + equivocators=2 RED
(teeth); full engine/chain suite green; 50× emergent fork probe = 0 forks.

Co-authored-by: Hanzo Dev <[email protected]>
2026-06-30 23:18:15 -07:00

76 lines
3.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// multinode_liveness_multiround_test.go — the fork-fix × liveness-fix INTERACTION gate.
//
// The existing down/wedged/forked-proposer suite (multinode_proposer_test.go) parks
// RoundTO at 30s so finalization is driven by the FIRST emergent gossip pass, not the
// re-poll ticker — it proves the liveness fix in isolation. Red asked for the
// complementary test: a SHORT RoundTO so the re-poll / round-change recovery ACTIVELY
// fires, over MULTIPLE heights, with a permanently-down proposer — so the per-height
// vote-once guard (the fork fix) and the undecided-own-proposal re-solicit (the liveness
// fix) are exercised TOGETHER under sustained round changes. Every re-solicit re-presents
// the SAME canonical, which the guard admits idempotently; a conflicting sibling is
// never signed. The gate: the chain makes progress at every height AND never forks.
package chain
import (
"testing"
"time"
"github.com/luxfi/consensus/config"
"github.com/luxfi/ids"
)
// prodParams5Fast is prodParams5 with a SHORT round timeout so the background re-poll
// ticker actively re-solicits — the condition under which round-change recovery and the
// fork guard must compose. K=5, α=4 (zero-margin quorum once one validator is down).
func prodParams5Fast() config.Parameters {
p := prodParams5()
p.RoundTO = 200 * time.Millisecond
return p
}
func TestMultiNode_DownProposer_MultiRoundLiveness_NoFork(t *testing.T) {
net := newSimNet(t, 5, prodParams5Fast())
net.down(0) // the designated proposer is permanently DOWN — the 4 up nodes are the exact α=4 quorum
const heights = 5
parentID := ids.Empty
parentStateRoot := simGenesisRoot()
for h := uint64(1); h <= heights; h++ {
// Rotate the substitute across the UP nodes {1,2,3,4} so different builders drive
// successive heights (real substitution, not one lucky node).
builder := 1 + int((h-1)%4)
blk := newHonestBlock(parentID, parentStateRoot, h, "multiround-h")
net.build(builder, blk)
if !waitFor(emergeTO, func() bool {
all, fork := net.finalizedEverywhere(blk)
return all && !fork
}) {
t.Fatalf("MULTI-ROUND HALT at height %d: the 4 reachable validators did not converge on %s under "+
"active re-poll (heads=%v). Round-change recovery and the vote-once guard must compose.",
h, blk.ID(), net.headsAtHeight(h))
}
// SINGLE-HEAD invariant at this height across every up node — no fork accrued.
if seen := net.headsAtHeight(h); len(seen) != 1 {
t.Fatalf("FORK at height %d under sustained re-poll: divergent heads %v", h, seen)
}
parentID = blk.ID()
parentStateRoot = blk.stateRoot
}
// Every height remained singular after the whole sequence — no late divergence.
for h := uint64(1); h <= heights; h++ {
if seen := net.headsAtHeight(h); len(seen) != 1 {
t.Fatalf("height %d head diverged after the full multi-round run: %v", h, seen)
}
}
// The down node received nothing and finalized nothing.
if _, ok := net.nodes[0].rt.FinalizedBlockAtHeight(1); ok {
t.Fatal("the DOWN proposer must not have finalized anything")
}
}