mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
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]>
76 lines
3.1 KiB
Go
76 lines
3.1 KiB
Go
// 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")
|
||
}
|
||
}
|