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]>