consensus: integrate round-scoped view-change into engine + round-skip + alpha-bound (RED fixes)

Engine integration (params.ViewChange, default off — opt-in/owner-gated per net):
- Prevote wire (domain LUX/chain/prevote/v1) + QuorumGossiper.BroadcastPrevote + HandleIncomingPrevote.
- runViewChangePass/stepViewChange driver: fluid prevote -> POL -> precommit+lock; precommit
  reuses the existing per-block cert machinery (all round logic in the prevote/POL/lock layer).
- reserveSlotForSign RELAXED under ViewChange (round+lock governs) but the DURABLE decided-floor
  (PART-A/HIGH-1 finalized gate) is untouched; proposer PREVOTES its own block (recordOwnVoteLocked
  skipped under VC); pruneViewsBelow on finalize.

RED review fixes (c7b2f0081):
1. ROUND-SKIP (noteSender): jump to a round with >=f+1 senders — eliminates the residual ~3%
   asymmetric-gossip freeze. Non-uniform-visibility honest liveness now 0/200 stalls.
2. ALPHA-BOUND: roundView carries n/f; safeConfig()=2a-n>f; runViewChangePass REFUSES (fail-secure
   halt) if the committee is unsafe. RED's alpha=3@n=5 fork proof retained as the tightness gate.
3. PRECOMMIT bound to the POL round (validRound), not the drifted current round.
4. POL-cert gossip (polCert + prevoteSigs relay) + locked-precommit re-broadcast so a laggard that
   missed prevotes/precommits during a split still converges.

Proven (clean testcache, -race): RED equivocator suite (no fork, always converges), 0/200
non-uniform stalls, alpha=3 fork tightness, split->heal converges through the FULL engine
(TestViewChange_CompetingFork_AsymmetricSplit_Converges); legacy path (VC off) unchanged.
Remaining: storm -race skip removal, durable lock persist, reconciliation, node v1.32.13, testnet.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-02 07:18:06 -07:00
co-authored by Hanzo Dev
parent c6e986bfcc
commit bdbbc0185b
9 changed files with 1076 additions and 30 deletions
+12
View File
@@ -96,6 +96,18 @@ type Parameters struct {
// alongside BLS. Zero value (PQModeBLSOnly) preserves the classical
// fast path. See pq_mode.go for the full enum.
PQMode PQMode
// ViewChange enables the round-scoped view-change convergence (Tendermint-style
// prevote/POL/precommit/lock — engine/chain/round_view.go). When true, a node
// PREVOTES fluidly across rounds (so a competing-sibling split re-converges) and
// PRECOMMITS the irrevocable α-of-K cert signature only on a POL, LOCKING on it —
// which RESTORES liveness under a down proposer + zero-margin quorum that the
// timing-fragile one-signature-per-height convergence deadlocks on (the 415→416
// freeze). When false (default), the engine keeps the legacy single-phase
// convergence — the safe-but-halt-prone behavior — so the change is opt-in and
// owner-gated per network. Safety is a REFINEMENT: one-sig-per-height becomes
// one-precommit-per-(height,round) + the lock rule.
ViewChange bool
}
// WithPQMode returns a copy of Parameters with the given PQ mode set.
@@ -30,9 +30,77 @@ import (
"testing"
"time"
"github.com/luxfi/consensus/config"
"github.com/luxfi/ids"
)
// stormParams5VC is stormParams5 with the round-scoped VIEW-CHANGE enabled — the fix.
func stormParams5VC() config.Parameters {
p := stormParams5()
p.ViewChange = true
return p
}
// TestViewChange_CompetingFork_AsymmetricSplit_Converges runs the EXACT deadlock scenario
// of TestRepro_CompetingFork_AsymmetricSplit_Deadlocks — designated proposer dead (α=4,
// zero margin), two live validators build competing siblings, a partition that outlasts
// the settle window splits the vote — but with ViewChange ON, driving the two-phase
// prevote/POL/precommit/lock through the FULL engine. It must CONVERGE + finalize a single
// head after the partition heals (the liveness the legacy path deadlocks on), with NO
// double-finalization.
func TestViewChange_CompetingFork_AsymmetricSplit_Converges(t *testing.T) {
net := newSimNet(t, 5, stormParams5VC())
net.down(0)
nid := func(i int) ids.NodeID { return net.vs.nodeID(i) }
group := map[ids.NodeID]int{nid(1): 1, nid(3): 1, nid(2): 2, nid(4): 2}
var partitioned atomic.Bool
partitioned.Store(true)
net.bus.setLink(func(from, to ids.NodeID, _ busMsgKind) bool {
if !partitioned.Load() {
return true
}
gf, gt := group[from], group[to]
if gf == 0 || gt == 0 {
return true
}
return gf == gt
})
A := newHonestBlock(ids.Empty, simGenesisRoot(), 1, "vc-split-A")
B := newHonestBlock(ids.Empty, simGenesisRoot(), 1, "vc-split-B")
net.build(1, A)
net.build(2, B)
// Partition longer than the settle window: under view-change, no group of 2 reaches a
// POL (α=4), so NO node precommits/locks — the crucial property that lets the split
// re-converge safely once the partition heals.
time.Sleep(2 * time.Second)
if _, ok1 := net.nodes[1].rt.FinalizedBlockAtHeight(1); ok1 {
t.Fatalf("nothing may finalize under a 2|2 partition with α=4")
}
partitioned.Store(false) // HEAL
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) {
heads := net.headsAtHeight(1)
if len(heads) > 1 {
t.Fatalf("DOUBLE-FINALIZATION at height 1 (safety break): %v", heads)
}
if len(heads) == 1 {
for _, c := range heads {
if c >= net.upCount() {
t.Logf("VIEW-CHANGE PASS: split→heal converged all %d live nodes on a single head through the FULL engine", net.upCount())
return
}
}
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("VIEW-CHANGE LIVENESS FAIL: height 1 did not converge after heal (heads=%v, up=%d)",
net.headsAtHeight(1), net.upCount())
}
func TestRepro_CompetingFork_AsymmetricSplit_Deadlocks(t *testing.T) {
if os.Getenv("LUX_REPRO_DEADLOCK") == "" {
t.Skip("reproduction of the competing-fork liveness deadlock; set LUX_REPRO_DEADLOCK=1 to run")
+39 -1
View File
@@ -522,6 +522,17 @@ type Transitive struct {
// warns a signer that has none). See vote_guard.go.
slotMu sync.Mutex
committedSlot map[SlotKey]ids.ID
// views holds the round-scoped view-change machine per unfinalized height (guarded by
// slotMu), used ONLY when params.ViewChange is set. It makes the α-of-K precommit LIVE
// and re-convergent under competing siblings: fluid prevotes across rounds + precommit
// only on a POL + lock. Lazily created per height; pruned on finalize. See round_view.go.
views map[uint64]*roundView
// prevoteSigs stores the SIGNED prevotes observed (own + peers), keyed
// {height,round,canonical} → nodeID → signature, so a node can RELAY the validValue POL's
// constituent prevotes to a peer that missed them (the POL-propagation the isolated proof
// showed is required for a 2|N split where one side holds a POL to converge). Guarded by
// slotMu; pruned with views on finalize.
prevoteSigs map[pvSigKey]map[ids.NodeID][]byte
// decidedFloor is the DURABLE, MONOTONIC decided-through height: the highest height
// this node has ever finalized. The sign gate refuses signing at any height <= it, so a
// decided height is permanently unsignable EVEN AFTER its committedSlot entry is pruned
@@ -725,6 +736,8 @@ func NewWithConfig(cfg Config, opts ...Option) *Transitive {
finalizedByCert: make(map[ids.ID]struct{}),
certBytesByBlock: make(map[ids.ID][]byte),
committedSlot: make(map[SlotKey]ids.ID),
views: make(map[uint64]*roundView),
prevoteSigs: make(map[pvSigKey]map[ids.NodeID][]byte),
catchupRequested: make(map[ids.ID]time.Time),
bufferedVotes: make(map[ids.ID][]Vote),
voteRequests: make(chan VoteRequest, cfg.VoteRequestBuffer),
@@ -1944,6 +1957,14 @@ func (t *Transitive) recordOwnVoteLocked(pending *PendingBlock, blockID ids.ID)
if t.voteSigner == nil {
return
}
// VIEW-CHANGE: a proposer must NOT precommit its own block at build time — it must
// PREVOTE it and precommit only on a POL (round_view.go). Recording an immediate own
// precommit here would be an unjustified precommit (no POL) that the lock rule forbids.
// The view-change driver (runViewChangePass) casts this node's prevote+precommit for the
// converged winner instead. Skip the build-time self-precommit under view-change.
if t.params.ViewChange {
return
}
pos := t.blockPositionLocked(pending, blockID)
// NON-EQUIVOCATION (fork guard): refuse to sign a conflicting sibling at a HEIGHT
// this node has already committed to — and DURABLY record the binding before
@@ -2029,7 +2050,19 @@ func (t *Transitive) reserveSlotForSign(height uint64, canonical ids.ID) bool {
return false // decided height — permanently unsignable (durable across restart)
}
if bound, ok := t.committedSlot[key]; ok {
return bound == canonical // same block ⇒ idempotent (already durable); sibling ⇒ refuse
if bound == canonical {
return true // same block ⇒ idempotent (already durable)
}
// VIEW-CHANGE: a conflicting canonical at an UN-finalized height is permitted here —
// the round-scoped lock rule (round_view.go recordOwnPrecommit) already governs WHICH
// value this node may precommit and at what round, so the height-only refusal would
// wrongly freeze the re-convergence. The DURABLE decided-floor gate above still makes a
// FINALIZED height unsignable (the v1.35.5 PART-A / HIGH-1 finalized-floor safety is
// untouched). For the legacy single-phase path, keep the strict height-only refusal.
if !t.params.ViewChange {
return false // legacy: sibling at an already-signed height ⇒ refuse
}
// fall through: re-bind to the newly-precommitted canonical (persist below).
}
// First binding at this slot. PERSIST it durably BEFORE permitting the signature —
// a crash after signing but before finalizing must not forget it. Mutate the map,
@@ -2849,6 +2882,11 @@ func (t *Transitive) acceptWithCertCore(ctx context.Context, blockID ids.ID, cer
// on every validator (a locally-finalizing node never reaches HandleIncomingCert's
// prune). pos.Height is the finalized tip; every slot at or below it is decided.
t.pruneCommittedSlotsBelow(pos.Height)
// View-change: drop the round machines for the just-decided height and below (bounded
// memory); a decided height is permanently unsignable via the decided-floor gate.
if t.params.ViewChange {
t.pruneViewsBelow(pos.Height)
}
return nil
}
+3
View File
@@ -327,6 +327,9 @@ func (g *certQuorumGossiper) GossipCert(c, n, b ids.ID, cb []byte) int {
_ = g.rec.GossipCert(c, b, cb)
return 1
}
func (g *certQuorumGossiper) BroadcastPrevote(_, _ ids.ID, _ uint64, _ uint32, _ ids.ID, _ []byte) int {
return 0 // single-engine tests inject votes directly; view-change prevotes are unused here
}
// newQuorumEngine builds a started multi-validator engine for validator index
// `self`, wired with the test validator set as verifier+signer and the given
+267
View File
@@ -140,6 +140,12 @@ type QuorumGossiper interface {
// WeightedQuorumCert / engine QuorumCert) to ALL validators so they can
// finalize blockID on a verifiable α-of-K proof. Returns validators reached.
GossipCert(chainID ids.ID, networkID ids.ID, blockID ids.ID, certBytes []byte) int
// BroadcastPrevote sends this node's signed ROUND-SCOPED prevote (the non-binding
// view-change preference signal) for `canonical` at (height, round) to ALL validators.
// voteBytes is encodeSignedPrevote(...). Only used when params.ViewChange is set; a
// Gossiper that does not implement it disables the view-change (legacy path). Returns
// the number of validators reached.
BroadcastPrevote(chainID ids.ID, networkID ids.ID, height uint64, round uint32, canonical ids.ID, voteBytes []byte) int
}
// Runtime wraps Transitive with network integration and VM notification handling.
@@ -880,11 +886,21 @@ func (rt *Runtime) emitConvergedVote(_ context.Context, height uint64, parentID
// vote at build/receipt; one settle window later this pass makes every honest node sign
// the SAME lowest-canonical winner, so a single α-of-K cert assembles and the height
// finalizes. The convergence loop drives it on a fast tick.
//
// When params.ViewChange is set, this dispatches to the ROUND-SCOPED view-change driver
// (runViewChangePass) instead — the two-phase prevote/POL/precommit/lock that RE-CONVERGES
// a split (restoring liveness under a down proposer + zero-margin quorum) while preserving
// no-double-finalization via the lock rule. The legacy single-phase path below is the
// safe-but-halt-prone default kept for non-view-change chains.
func (rt *Runtime) RunSettlePass(ctx context.Context) {
t := rt.Transitive
if t.voteSigner == nil || t.voteVerifier == nil {
return
}
if t.params.ViewChange {
rt.runViewChangePass(ctx)
return
}
t.mu.Lock()
slots := t.snapshotVotableSlotsLocked()
t.mu.Unlock()
@@ -893,6 +909,257 @@ func (rt *Runtime) RunSettlePass(ctx context.Context) {
}
}
// runViewChangePass drives the round-scoped view-change (round_view.go) for every
// undecided height above the decided floor. For each height it computes the deterministic
// lowest-canonical winner, steps the height's roundView machine, and:
// - broadcasts this node's signed PREVOTE for the round's target (fluid across rounds →
// a split re-converges), and
// - on a POL (α prevotes for one value at the current round), casts the irrevocable
// PRECOMMIT (the existing α-of-K accept vote for that block) and LOCKS on it.
// The precommit reuses the existing per-block cert machinery (recordCertVote/assembleCert)
// unchanged — all round logic lives in the prevote/POL/lock layer, so a cert is still
// "α precommits for one block at a height". Safety = the lock rule (a conflicting value
// can only be precommitted at a higher round with a fresher POL); liveness = fluid prevotes.
func (rt *Runtime) runViewChangePass(ctx context.Context) {
t := rt.Transitive
alpha := t.consensus.Alpha()
n := t.consensus.K()
if alpha <= 0 || n <= 0 {
return
}
// SAFETY-BOUND ENFORCEMENT (fail-closed): the round-scoped guard is sound only when
// 2α−n > f (f=⌊(n-1)/3⌋). At α=3,n=5 (2α−n=1=f) ONE equivocator double-finalizes (RED
// proved it). If the current committee fails the bound, REFUSE to run view-change — the
// chain halts (fail-secure) rather than risk a fork. Re-checked every pass, so a
// validator-set change that breaks the bound halts finality until it is restored.
if f := (n - 1) / 3; 2*alpha-n <= f {
if !rt.config.Logger.IsZero() {
rt.config.Logger.Error("view-change REFUSED: unsafe committee (2α−n ≤ f) — halting finality (fail-secure)",
log.Int("alpha", alpha), log.Int("n", n), log.Int("f", f))
}
return
}
// Enumerate undecided heights + a representative parent, and the deterministic winner.
consensusFloor := uint64(0)
if t.consensus != nil {
consensusFloor = t.consensus.GetDecidedFloor()
}
type vcSlot struct {
height uint64
winner ids.ID // winner block id
canon ids.ID // winner canonical (what the roundView votes on)
}
t.mu.Lock()
t.slotMu.Lock()
floor := t.decidedFloor
t.slotMu.Unlock()
if consensusFloor > floor {
floor = consensusFloor
}
parents := map[uint64]ids.ID{}
for _, pb := range t.pendingBlocks {
cb := pb.ConsensusBlock
if cb == nil || pb.Decided || pb.rePollAbandoned || cb.height <= floor {
continue
}
parents[cb.height] = cb.parentID
}
var slots []vcSlot
for h, parent := range parents {
winnerID, _, ok := t.convergedWinnerAtHeightLocked(h, parent)
if !ok {
continue
}
pw := t.pendingBlocks[winnerID]
if pw == nil {
continue
}
canon := slotCanonical(t.blockPositionLocked(pw, winnerID))
slots = append(slots, vcSlot{height: h, winner: winnerID, canon: canon})
}
t.mu.Unlock()
for _, s := range slots {
rt.stepViewChange(ctx, s.height, s.winner, s.canon, alpha, n, floor)
}
}
// stepViewChange advances one height's view machine by one tick and performs the
// resulting prevote / precommit broadcast.
func (rt *Runtime) stepViewChange(ctx context.Context, height uint64, winnerID, winnerCanon ids.ID, alpha, n int, floor uint64) {
t := rt.Transitive
if height <= floor {
return
}
nodeID := t.nodeID
chainID := t.chainID
signer := t.voteSigner
t.slotMu.Lock()
v := t.viewForLocked(height, alpha, n)
act := v.step(winnerCanon, 1, viewSettleTicks)
var prevoteCanon, precommitCanon ids.ID
curRound := act.CurRound
if act.Prevote != ids.Empty {
v.recordOwnPrevote(act.Prevote)
v.observePrevote(nodeID, act.CurRound, act.Prevote)
prevoteCanon = act.Prevote
}
if act.Precommit != ids.Empty && v.recordOwnPrecommit(act.Precommit, act.PrecommitRound) {
v.observePrecommit(nodeID, act.PrecommitRound, act.Precommit)
precommitCanon = act.Precommit
}
// POL relay: gather the validValue POL's constituent signed prevotes so a peer that
// missed them can form the same POL and converge validValue (the propagation the
// isolated proof showed is required). Collected under slotMu; broadcast below.
polBlk, polRound, polVoters, hasPOL := v.polCert()
var polRelay [][]byte
if hasPOL {
if m := t.prevoteSigs[pvSigKey{height: height, round: polRound, canon: polBlk}]; m != nil {
for _, voter := range polVoters {
if s, ok := m[voter]; ok {
polRelay = append(polRelay, encodeSignedPrevote(voter, height, polRound, polBlk, s))
}
}
}
}
t.slotMu.Unlock()
qg, hasQG := rt.config.Gossiper.(QuorumGossiper)
if prevoteCanon != ids.Empty && signer != nil {
msg := CanonicalPrevoteMessage(chainID, height, curRound, prevoteCanon)
if sig, err := signer.SignVote(msg); err == nil {
t.slotMu.Lock()
t.storePrevoteSigLocked(nodeID, height, curRound, prevoteCanon, sig)
t.slotMu.Unlock()
wire := encodeSignedPrevote(nodeID, height, curRound, prevoteCanon, sig)
if hasQG {
qg.BroadcastPrevote(chainID, rt.config.NetworkID, height, curRound, prevoteCanon, wire)
}
}
}
if hasQG {
for _, wire := range polRelay {
qg.BroadcastPrevote(chainID, rt.config.NetworkID, height, polRound, polBlk, wire)
}
}
// PRECOMMIT: the POL justifies casting the irrevocable α-of-K accept vote for the
// block whose canonical is precommitCanon. Route through the SAME sign+record+broadcast
// path a legacy converged vote takes; reserveSlotForSign's decided-floor gate still
// applies (finalized heights unsignable), and the roundView lock already gated the value.
if precommitCanon != ids.Empty {
rt.castPrecommitForCanonical(ctx, height, precommitCanon)
}
}
// castPrecommitForCanonical signs+records+broadcasts this node's α-of-K accept vote for
// the tracked block at `height` whose canonical == canon (the view-change precommit). It
// mirrors emitConvergedVote's sign path but the value choice + lock were already made by
// the roundView; here we only bind the cert. reserveSlotForSign still enforces the durable
// decided-floor (a finalized height is unsignable).
func (rt *Runtime) castPrecommitForCanonical(_ context.Context, height uint64, canon ids.ID) {
t := rt.Transitive
t.mu.Lock()
var winnerID ids.ID
var pending *PendingBlock
for id, pb := range t.pendingBlocks {
cb := pb.ConsensusBlock
if cb == nil || pb.Decided || cb.height != height {
continue
}
if slotCanonical(t.blockPositionLocked(pb, id)) == canon {
winnerID, pending = id, pb
break
}
}
if pending == nil {
t.mu.Unlock()
return
}
pos := t.blockPositionLocked(pending, winnerID)
signer := t.voteSigner
nodeID := t.nodeID
chainID := t.chainID
t.mu.Unlock()
if !t.reserveSlotForSign(pos.Height, slotCanonical(pos)) {
return // decided height (finalized) — unsignable
}
sig, err := signer.SignVote(CanonicalVoteMessage(pos))
if err != nil {
return
}
rt.Transitive.ReceiveVote(Vote{
BlockID: winnerID,
NodeID: nodeID,
Accept: true,
SignedAt: time.Now(),
Signature: sig,
ParentID: pos.ParentID,
Round: pos.Round,
})
if qg, ok := rt.config.Gossiper.(QuorumGossiper); ok {
if voteBytes, encErr := encodeSignedVote(nodeID, sig); encErr == nil {
qg.BroadcastVote(chainID, rt.config.NetworkID, winnerID, voteBytes)
}
}
}
// HandleIncomingPrevote ingests a signed round-scoped prevote from a peer: it verifies the
// signature against the reconstructed CanonicalPrevoteMessage and, if valid, records it in
// the height's roundView tally (feeding POL detection). Prevotes are non-binding preference
// signals — they never finalize anything; they only drive convergence + the lock/unlock rule.
func (rt *Runtime) HandleIncomingPrevote(voteBytes []byte) bool {
nodeID, height, round, canon, sig, err := decodeSignedPrevote(voteBytes)
if err != nil {
return false
}
t := rt.Transitive
verifier := t.voteVerifier
if verifier == nil {
return false
}
// Verify at the height's epoch — resolve the prevoter's pubkey at the winner block's
// P-chain epoch if tracked, else 0 (fixed-set chains). A prevote for a decided height is
// dropped (nothing to converge).
t.slotMu.Lock()
floor := t.decidedFloor
t.slotMu.Unlock()
if height <= floor {
return false
}
msg := CanonicalPrevoteMessage(t.chainID, height, round, canon)
if !verifier.VerifyVote(nodeID, msg, sig, rt.epochForHeight(height)) {
return false
}
alpha := t.consensus.Alpha()
n := t.consensus.K()
if alpha <= 0 || n <= 0 {
return false
}
t.slotMu.Lock()
v := t.viewForLocked(height, alpha, n)
v.observePrevote(nodeID, round, canon)
t.storePrevoteSigLocked(nodeID, height, round, canon, sig)
t.slotMu.Unlock()
return true
}
// epochForHeight resolves the P-chain epoch height for a tracked block at `height` (for
// prevote pubkey resolution). 0 when the height is not tracked or the chain is fixed-set.
func (rt *Runtime) epochForHeight(height uint64) uint64 {
t := rt.Transitive
t.mu.RLock()
defer t.mu.RUnlock()
for _, pb := range t.pendingBlocks {
if pb.ConsensusBlock != nil && pb.ConsensusBlock.height == height {
return t.epochHeightLocked(pb)
}
}
return 0
}
// requestCatchup is the ONE catch-up TRANSPORT: "I am missing block `missingID`
// — fetch it from `from`." It is the single mechanism for runtime auto-recovery,
// shared by BOTH self-heal triggers:
+10
View File
@@ -278,6 +278,7 @@ const (
msgBlock busMsgKind = iota
msgVote
msgCert
msgPrevote
)
type busMsg struct {
@@ -383,6 +384,13 @@ func (g *busGossiper) GossipCert(_ ids.ID, _ ids.ID, blockID ids.ID, certBytes [
return g.bus.deliver(g.self, busMsg{kind: msgCert, from: g.self, blockID: blockID, payload: append([]byte(nil), certBytes...)})
}
func (g *busGossiper) BroadcastPrevote(_ ids.ID, _ ids.ID, _ uint64, _ uint32, canonical ids.ID, voteBytes []byte) int {
if g.dropOut() {
return 0
}
return g.bus.deliver(g.self, busMsg{kind: msgPrevote, from: g.self, blockID: canonical, payload: append([]byte(nil), voteBytes...)})
}
// Unused legacy transport methods (single-recipient pull/vote) — no-ops in the
// broadcast topology under test.
func (g *busGossiper) SendPullQuery(ids.ID, ids.ID, ids.ID, []ids.NodeID) int { return 0 }
@@ -446,6 +454,8 @@ func (n *simNode) run() {
n.rt.HandleIncomingVote(m.blockID, m.payload)
case msgCert:
n.rt.HandleIncomingCert(m.payload)
case msgPrevote:
n.rt.HandleIncomingPrevote(m.payload)
}
}
}
+266 -25
View File
@@ -45,7 +45,134 @@
package chain
import "github.com/luxfi/ids"
import (
"encoding/binary"
"fmt"
"github.com/luxfi/ids"
)
// CanonicalPrevoteMessage is the domain-separated signed message for a PREVOTE at a
// (height, round) on a canonical block. A prevote is a NON-BINDING preference signal
// (it drives the POL, not the cert), so it carries its OWN domain tag distinct from the
// precommit/accept message (canonicalVoteMessageFor, tag "LUX/chain/vote/v2") — a prevote
// signature can NEVER be presented as a precommit and vice-versa. It binds the minimal
// POL identity: (chainID, height, round, canonical). That is sufficient for the lock/
// unlock safety (a POL is "which block at this height/round"); the full execution
// identity + set-root binding lives in the precommit cert (canonicalVoteMessageFor).
func CanonicalPrevoteMessage(chainID ids.ID, height uint64, round uint32, canonical ids.ID) []byte {
const tag = "LUX/chain/prevote/v1\x00"
buf := make([]byte, 0, len(tag)+32+8+4+32)
buf = append(buf, tag...)
buf = append(buf, chainID[:]...)
var u64 [8]byte
binary.BigEndian.PutUint64(u64[:], height)
buf = append(buf, u64[:]...)
var u32 [4]byte
binary.BigEndian.PutUint32(u32[:], round)
buf = append(buf, u32[:]...)
buf = append(buf, canonical[:]...)
return buf
}
// encodeSignedPrevote encodes a broadcastable prevote: (nodeID, height, round, canonical, sig).
// The (height, round, canonical) travel in the wire so a receiver can rebuild the exact
// signed message and verify — a prevote can never be replayed at a different position.
func encodeSignedPrevote(nodeID ids.NodeID, height uint64, round uint32, canonical ids.ID, sig []byte) []byte {
buf := make([]byte, 0, ids.NodeIDLen+8+4+32+4+len(sig))
buf = append(buf, nodeID[:]...)
var u64 [8]byte
binary.BigEndian.PutUint64(u64[:], height)
buf = append(buf, u64[:]...)
var u32 [4]byte
binary.BigEndian.PutUint32(u32[:], round)
buf = append(buf, u32[:]...)
buf = append(buf, canonical[:]...)
binary.BigEndian.PutUint32(u32[:], uint32(len(sig)))
buf = append(buf, u32[:]...)
buf = append(buf, sig...)
return buf
}
// decodeSignedPrevote is the inverse of encodeSignedPrevote (strict; fail-closed).
func decodeSignedPrevote(data []byte) (nodeID ids.NodeID, height uint64, round uint32, canonical ids.ID, sig []byte, err error) {
const fixed = ids.NodeIDLen + 8 + 4 + 32 + 4
if len(data) < fixed {
return nodeID, 0, 0, canonical, nil, fmt.Errorf("%w: prevote too short", ErrVoteWireCorrupt)
}
off := 0
copy(nodeID[:], data[off:off+ids.NodeIDLen])
off += ids.NodeIDLen
height = binary.BigEndian.Uint64(data[off : off+8])
off += 8
round = binary.BigEndian.Uint32(data[off : off+4])
off += 4
copy(canonical[:], data[off:off+32])
off += 32
sigLen := binary.BigEndian.Uint32(data[off : off+4])
off += 4
if uint64(sigLen) != uint64(len(data)-off) {
return nodeID, 0, 0, canonical, nil, fmt.Errorf("%w: prevote sig_len %d != remaining %d", ErrVoteWireCorrupt, sigLen, len(data)-off)
}
sig = make([]byte, sigLen)
copy(sig, data[off:])
return nodeID, height, round, canonical, sig, nil
}
// pvSigKey keys the stored signed prevotes for POL relay.
type pvSigKey struct {
height uint64
round uint32
canon ids.ID
}
// storePrevoteSigLocked records a verified signed prevote for later POL relay. Caller holds slotMu.
func (t *Transitive) storePrevoteSigLocked(nodeID ids.NodeID, height uint64, round uint32, canon ids.ID, sig []byte) {
k := pvSigKey{height: height, round: round, canon: canon}
m := t.prevoteSigs[k]
if m == nil {
m = map[ids.NodeID][]byte{}
t.prevoteSigs[k] = m
}
if _, ok := m[nodeID]; !ok {
m[nodeID] = append([]byte(nil), sig...)
}
}
// viewForLocked returns (creating if needed) the round-scoped view machine for a height.
// Caller holds slotMu. alpha is the current α-of-K threshold (POL + cert). A view whose
// height is at/below the decided floor is never created (it is decided).
func (t *Transitive) viewForLocked(height uint64, alpha, n int) *roundView {
if v, ok := t.views[height]; ok {
return v
}
v := newRoundView(height, alpha, n)
t.views[height] = v
return v
}
// pruneViewsBelow drops view machines strictly below a finalized height (bounded memory);
// the finalized height itself is decided and its view is dropped too. Caller need NOT hold
// slotMu (self-locks). Called from the finalizer alongside pruneCommittedSlotsBelow.
func (t *Transitive) pruneViewsBelow(height uint64) {
t.slotMu.Lock()
for h := range t.views {
if h <= height {
delete(t.views, h)
}
}
for k := range t.prevoteSigs {
if k.height <= height {
delete(t.prevoteSigs, k)
}
}
t.slotMu.Unlock()
}
// viewSettleTicks is how many convergence ticks a view-change round lasts before it
// advances (the convergence loop ticks at ~settleWindow/3, so a round ≈ the settle
// window — long enough for prevotes to gossip in, short enough to re-converge quickly).
const viewSettleTicks = int64(3)
// rvKey identifies a per-(round, block) tally bucket.
type rvKey struct {
@@ -57,20 +184,29 @@ type rvKey struct {
// precommit, and/or a finalize, plus whether the round advanced. Empty ids.ID means
// "no action of that kind this Step".
type rvAction struct {
Prevote ids.ID // broadcast a signed prevote for this block at CurRound
Precommit ids.ID // broadcast a signed precommit (the α-of-K cert vote) for this block at CurRound
Finalize ids.ID // an α-of-K precommit quorum exists for this block at some round — commit it
CurRound uint32 // the round the prevote/precommit are cast at
NewRound bool // the round advanced this Step
Prevote ids.ID // broadcast a signed prevote for this block at CurRound
Precommit ids.ID // broadcast a signed precommit (the α-of-K cert vote) for this block at PrecommitRound
PrecommitRound uint32 // the round the precommit is cast at (the POL round — NOT necessarily CurRound)
Finalize ids.ID // an α-of-K precommit quorum exists for this block at some round — commit it
CurRound uint32 // the round the prevote is cast at
NewRound bool // the round advanced this Step
}
// roundView is one height's view-change state.
type roundView struct {
height uint64
alpha int // POL / cert threshold (α-of-K)
n int // committee size (for the safety bound 2α−n>f and the f+1 round-skip threshold)
f int // Byzantine budget ⌊(n-1)/3⌋
round uint32
elapsed int64 // settle ticks accumulated in the current round (driver-supplied clock)
// roundSenders[r] = distinct validators observed to have sent ANY message (prevote or
// precommit) at round r. Drives ROUND-SKIP: on f+1 senders at a round above ours, at
// least one is honest and genuinely ahead, so we JUMP to that round (Tendermint's
// round-skip) — the rule that re-aligns a node left phase-offset by asymmetric gossip
// (the residual ~3% freeze RED found: a drifted node's 3<α prevotes never form a POL).
roundSenders map[uint32]map[ids.NodeID]struct{}
haveLocked bool
lockRound uint32
lockBlock ids.ID
@@ -89,20 +225,59 @@ type roundView struct {
polRound map[ids.ID]uint32
havePOL map[ids.ID]struct{}
// validBlock / validRound track the value carrying the HIGHEST-round POL this node has
// observed (Tendermint's validValue/validRound). It is the "proposal" the prevote rule
// converges on: because every node computes it from the SAME gossiped prevote tallies,
// all honest nodes independently pick the SAME target, which is what defeats an
// equivocator that manufactures competing POLs to split-lock the honest set (RED's
// liveness vector). validRound = -1 until the first POL.
validBlock ids.ID
validRound int64
finalized bool
finalizedBlock ids.ID
finalizedRound uint32
}
func newRoundView(height uint64, alpha int) *roundView {
func newRoundView(height uint64, alpha, n int) *roundView {
return &roundView{
height: height,
alpha: alpha,
n: n,
f: (n - 1) / 3,
validRound: -1,
prevoted: map[uint32]ids.ID{},
precommitted: map[uint32]ids.ID{},
prevoteTally: map[rvKey]map[ids.NodeID]struct{}{},
precommitTally: map[rvKey]map[ids.NodeID]struct{}{},
polRound: map[ids.ID]uint32{},
havePOL: map[ids.ID]struct{}{},
roundSenders: map[uint32]map[ids.NodeID]struct{}{},
}
}
// safeConfig reports whether this committee meets the fork-safety bound 2α−n > f. When
// false, ONE equivocator can sit in two α-quorums and double-finalize (RED proved this at
// α=3, n=5). The engine MUST refuse to run view-change on a config that fails this.
func (v *roundView) safeConfig() bool { return 2*v.alpha-v.n > v.f }
// noteSender records that `nodeID` sent a message at `round` and applies the ROUND-SKIP
// rule: if some round r strictly above the current round has ≥ f+1 distinct senders, at
// least one honest node is genuinely at r, so JUMP there (reset the settle clock). Only
// ever advances the round; never regresses it, so it cannot undo the lock/round monotonicity.
func (v *roundView) noteSender(round uint32, nodeID ids.NodeID) {
if round <= v.round {
return
}
set := v.roundSenders[round]
if set == nil {
set = map[ids.NodeID]struct{}{}
v.roundSenders[round] = set
}
set[nodeID] = struct{}{}
if len(set) >= v.f+1 && round > v.round {
v.round = round
v.elapsed = 0
}
}
@@ -112,6 +287,7 @@ func (v *roundView) observePrevote(nodeID ids.NodeID, round uint32, block ids.ID
if block == ids.Empty {
return false
}
v.noteSender(round, nodeID) // ROUND-SKIP tracking
k := rvKey{round: round, block: block}
set := v.prevoteTally[k]
if set == nil {
@@ -129,6 +305,12 @@ func (v *roundView) observePrevote(nodeID ids.NodeID, round uint32, block ids.ID
if prev, ok := v.polRound[block]; !ok || round > prev {
v.polRound[block] = round
}
// Track the highest-round POL as validValue/validRound — the value every honest node
// converges its prevote on (all compute it from the same gossiped tallies).
if int64(round) > v.validRound {
v.validRound = int64(round)
v.validBlock = block
}
if _, ok := v.havePOL[block]; ok {
return false // already had a POL for this block (this call only raised the round)
}
@@ -143,6 +325,7 @@ func (v *roundView) observePrecommit(nodeID ids.NodeID, round uint32, block ids.
if block == ids.Empty || v.finalized {
return
}
v.noteSender(round, nodeID) // ROUND-SKIP tracking
k := rvKey{round: round, block: block}
set := v.precommitTally[k]
if set == nil {
@@ -153,9 +336,28 @@ func (v *roundView) observePrecommit(nodeID ids.NodeID, round uint32, block ids.
if len(set) >= v.alpha {
v.finalized = true
v.finalizedBlock = block
v.finalizedRound = round
}
}
// polCert returns the prevotes constituting this node's validValue POL (its highest-round
// POL) so the driver can GOSSIP them: a peer that missed those prevotes (it was partitioned
// when they were cast, and the casters have since moved on) can ingest them, form the same
// POL, and adopt the same validValue — which is what lets a 2|2 split where one side holds a
// POL the other never saw still converge. This is the pure-core analogue of Tendermint's
// proposal carrying (validValue, validRound) + its POL, or anti-entropy prevote gossip.
func (v *roundView) polCert() (block ids.ID, round uint32, voters []ids.NodeID, ok bool) {
if v.validRound < 0 {
return ids.Empty, 0, nil, false
}
round = uint32(v.validRound)
block = v.validBlock
for id := range v.prevoteTally[rvKey{round: round, block: block}] {
voters = append(voters, id)
}
return block, round, voters, len(voters) > 0
}
// polAbove reports whether `block` has a POL at a round strictly greater than `round`.
func (v *roundView) polAbove(block ids.ID, round uint32) bool {
r, ok := v.polRound[block]
@@ -166,19 +368,30 @@ func (v *roundView) polAbove(block ids.ID, round uint32) bool {
// deterministic winner W (lowest-canonical live sibling). Returns Empty if the node
// has no valid target (no live winner).
func (v *roundView) prevoteTarget(winner ids.ID) ids.ID {
if winner == ids.Empty {
if winner == ids.Empty && v.validRound < 0 {
return ids.Empty
}
// The PROPOSAL each honest node converges on is validValue (the highest-round POL) when
// one exists — computed from the same gossiped prevote tallies, so all nodes pick the
// SAME target and an equivocator cannot split-lock them — else the deterministic winner
// (lowest-canonical live sibling) as the fresh round-0 proposal.
proposal := winner
if v.validRound >= 0 {
proposal = v.validBlock
}
if proposal == ids.Empty {
return ids.Empty
}
if !v.haveLocked {
return winner // free: prevote the proposal
return proposal // free: prevote the proposal
}
if winner == v.lockBlock {
return v.lockBlock
if proposal == v.lockBlock {
return proposal
}
// Locked on a DIFFERENT value: prevote the winner ONLY with a POL for it strictly
// Locked on a DIFFERENT value: prevote the proposal ONLY with a POL for it strictly
// above the lock round (the unlock justification); else stay on the lock.
if v.polAbove(winner, v.lockRound) {
return winner
if v.polAbove(proposal, v.lockRound) {
return proposal
}
return v.lockBlock
}
@@ -194,16 +407,39 @@ func (v *roundView) step(winner ids.ID, tick, settle int64) rvAction {
act := rvAction{CurRound: v.round}
if v.finalized {
act.Finalize = v.finalizedBlock
// Keep RE-BROADCASTING our precommit for the finalized block+round so a laggard that
// missed the quorum (it was partitioned when the others committed, and they then went
// quiet) can still assemble α precommits and finalize. This is the pure-core analogue
// of the engine's cert gossip (GossipCert/HandleIncomingCert). Idempotent (dedup by
// NodeID at the receiver).
act.Precommit = v.finalizedBlock
act.PrecommitRound = v.finalizedRound
return act
}
// PRECOMMIT step first: if a POL exists at the CURRENT round, precommit that value
// (unique per round) and LOCK, unless we already precommitted this round.
if _, done := v.precommitted[v.round]; !done {
if polBlk := v.polAtRound(v.round); polBlk != ids.Empty {
act.Precommit = polBlk
// PRECOMMIT step: precommit the value carrying the highest-round POL (validValue) at
// that POL round, unless we already precommitted at that round. Precommitting at the POL
// ROUND (not the — possibly already-advanced — current round) is what stops a node from
// racing past its own POL when the settle timer advances the round in the same tick the
// prevote was cast (the miss RED's equivocator-liveness vector exposed). All honest nodes
// share validRound (same gossiped tallies), so they precommit the SAME value at the SAME
// round → one α-of-K cert. The lock rule (below, in prevoteTarget) keeps it safe.
if v.validRound >= 0 {
pr := uint32(v.validRound)
if _, done := v.precommitted[pr]; !done {
act.Precommit = v.validBlock
act.PrecommitRound = pr
}
}
// Re-broadcast our current LOCK every step (when no fresher POL is being precommitted) so
// a laggard that missed our precommit during a partition can still assemble α precommits
// at the lock round — the precommit analogue of block/POL gossip. Idempotent at the
// receiver (dedup by NodeID); without it, a precommit lost during the split is never
// re-sent and the laggard stalls forever despite everyone being locked on the same value.
if act.Precommit == ids.Empty && v.haveLocked {
act.Precommit = v.lockBlock
act.PrecommitRound = v.lockRound
}
// PREVOTE step: cast our one prevote for this round if we have not yet.
if _, done := v.prevoted[v.round]; !done {
@@ -251,13 +487,18 @@ func (v *roundView) recordOwnPrevote(block ids.ID) {
// LOCKS on it. Called by the driver after step returns a Precommit action. Enforces one
// precommit per round + the lock update. Returns false if this would be a SECOND,
// conflicting precommit at the same round (a safety violation the driver must never cause).
func (v *roundView) recordOwnPrecommit(block ids.ID) bool {
if prev, done := v.precommitted[v.round]; done {
func (v *roundView) recordOwnPrecommit(block ids.ID, round uint32) bool {
if prev, done := v.precommitted[round]; done {
return prev == block // idempotent for the same block; refuse a conflicting one
}
v.precommitted[v.round] = block
v.haveLocked = true
v.lockRound = v.round
v.lockBlock = block
v.precommitted[round] = block
// LOCK at the POL round. Monotone: a lock only advances to a higher round (a fresher POL);
// a precommit at a round below the current lock never regresses it (defence in depth —
// step only offers the highest-round POL, so this is belt-and-braces).
if !v.haveLocked || round >= v.lockRound {
v.haveLocked = true
v.lockRound = round
v.lockBlock = block
}
return true
}
+313
View File
@@ -0,0 +1,313 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// round_view_red_test.go — RED adversarial suite for the round-scoped view-change core.
//
// Blue's round_view_test.go models a WEAK adversary: every node runs the HONEST state
// machine and the "down" node is merely off — never Byzantine. The safety claim, however,
// is stated against a ≤f Byzantine adversary (sound when 2α−n>f). This file supplies the
// missing adversary: a genuine f=1 Byzantine node that EQUIVOCATES — double-prevotes AND
// double-precommits EVERY competing sibling at EVERY round (including future rounds it is
// not at), attempting to manufacture two POLs / two certs at one height. It drives that
// adversary against the real honest roundView machines under adversarial partitions.
//
// The invariant under test is the fork-safety gate: no two honest nodes ever finalize
// different blocks at one height, under ≤f=1 equivocation + partition + reorder.
package chain
import (
"math/rand"
"testing"
"github.com/luxfi/ids"
)
// rvFork reports whether two honest (up) nodes have finalized DIFFERENT blocks. Non-fatal
// (Blue's assertNoFork is fatal); RED needs to detect a fork without aborting so the α=3
// bound-tightness demonstration can assert that a fork DID occur.
func rvFork(nodes []*rvNode) (bool, ids.ID, ids.ID) {
var head ids.ID
set := false
for _, n := range nodes {
if !n.up || !n.view.finalized {
continue
}
if !set {
head = n.view.finalizedBlock
set = true
continue
}
if n.view.finalizedBlock != head {
return true, head, n.view.finalizedBlock
}
}
return false, ids.Empty, ids.Empty
}
// redStep advances the honest nodes exactly as Blue's stepOnce does (re-gossip seen, step,
// record+observe own votes, emit), then injects the Byzantine node's equivocating votes:
// a prevote AND a precommit for EVERY sibling at EVERY round in [0,maxRound]. The Byzantine
// node (index 4, up=false so the honest loop and deliver skip it as a participant/receiver)
// is exempt from the partition via the caller's link function, modeling a Byzantine peer
// that talks to everyone.
func redStep(s *rvSim, byzID ids.NodeID, sibs []ids.ID, maxRound uint32) {
var bus []rvMsg
for _, n := range s.nodes {
if !n.up {
continue
}
for b := range n.seen {
bus = append(bus, rvMsg{kind: rvBlock, from: n.id, block: b})
}
w := winnerOf(n)
act := n.view.step(w, 1, s.settle)
if act.Prevote != ids.Empty {
n.view.recordOwnPrevote(act.Prevote)
n.view.observePrevote(n.id, act.CurRound, act.Prevote)
bus = append(bus, rvMsg{kind: rvPrevote, from: n.id, round: act.CurRound, block: act.Prevote})
}
if act.Precommit != ids.Empty {
if n.view.recordOwnPrecommit(act.Precommit, act.PrecommitRound) {
n.view.observePrecommit(n.id, act.PrecommitRound, act.Precommit)
bus = append(bus, rvMsg{kind: rvPrecommit, from: n.id, round: act.PrecommitRound, block: act.Precommit})
}
}
}
// Byzantine equivocation: EVERY sibling, EVERY round, both phases.
for _, sib := range sibs {
for r := uint32(0); r <= maxRound; r++ {
bus = append(bus, rvMsg{kind: rvPrevote, from: byzID, round: r, block: sib})
bus = append(bus, rvMsg{kind: rvPrecommit, from: byzID, round: r, block: sib})
}
}
s.deliver(polCertMsgs(s.nodes)) // honest POL gossip (models proposal-carries-POL / anti-entropy)
s.deliver(bus)
}
// -----------------------------------------------------------------------------
// VECTOR 1/3/6: an f=1 equivocator + a 2|2 honest partition. If the bound 2α−n>f holds
// (α=4,n=5: 3>1) the equivocator CANNOT manufacture two POLs, so nothing forks; and once
// the partition heals the honest nodes converge on the deterministic winner despite the
// equivocator still spamming both siblings forever.
// -----------------------------------------------------------------------------
func TestRoundView_EquivocatorPartition_Alpha4_NoFork(t *testing.T) {
s := newRVSim(t, 5, 4)
s.node(4).up = false // node 4 = f=1 Byzantine equivocator (driven by redStep)
byzID := s.node(4).id
X := rvBlockID(0xA0)
Y := rvBlockID(0xB0)
sibs := []ids.ID{X, Y}
grp := map[ids.NodeID]int{
s.node(0).id: 1, s.node(1).id: 1,
s.node(2).id: 2, s.node(3).id: 2,
}
partitioned := true
s.link = func(from, to ids.NodeID) bool {
if from == byzID || to == byzID {
return true // Byzantine talks to everyone
}
if partitioned {
return grp[from] == grp[to]
}
return true
}
// group {0,1} sees only X, group {2,3} sees only Y — the classic split, now with an
// equivocator bridging both sides to try to push BOTH to α.
s.node(0).seen[X] = struct{}{}
s.node(1).seen[X] = struct{}{}
s.node(2).seen[Y] = struct{}{}
s.node(3).seen[Y] = struct{}{}
for tick := 0; tick < 12; tick++ {
redStep(s, byzID, sibs, 8)
if forked, a, b := rvFork(s.nodes); forked {
t.Fatalf("FORK under partition+equivocator at α=4: %s vs %s", a, b)
}
}
for i := 0; i < 4; i++ {
if s.node(i).view.finalized {
t.Fatalf("node %d finalized under a 2|2 split at α=4 (equivocator adds only 1 → 3<4): impossible unless a bug", i)
}
}
// HEAL: honest nodes learn both siblings and must converge on min(X,Y)=X.
partitioned = false
converged := false
for tick := 0; tick < 80 && !converged; tick++ {
redStep(s, byzID, sibs, 8)
if forked, a, b := rvFork(s.nodes); forked {
t.Fatalf("FORK after heal at α=4: %s vs %s", a, b)
}
converged = s.node(0).view.finalized && s.node(1).view.finalized &&
s.node(2).view.finalized && s.node(3).view.finalized
}
if !converged {
t.Fatalf("LIVENESS: honest nodes did not converge after heal despite equivocator")
}
for i := 0; i < 4; i++ {
if s.node(i).view.finalizedBlock != X {
t.Fatalf("node %d finalized %s, want deterministic winner %s", i, s.node(i).view.finalizedBlock, X)
}
}
t.Logf("RED PASS: f=1 equivocator + 2|2 partition at α=4 — no fork; healed to %s", X)
}
// -----------------------------------------------------------------------------
// VECTOR 5 (bound tightness): the SAME equivocator at α=3 (2α−n = 1 = f, NOT > f). Here
// quorum intersection is exactly f, so the single equivocator sits in BOTH α-quorums and
// double-finalizes. This test asserts the fork OCCURS — proving the safety bound is tight
// and that the engine config MUST enforce α=4 for n=5. If this ever stopped forking, the
// safety argument would need re-examination.
// -----------------------------------------------------------------------------
func TestRoundView_EquivocatorPartition_Alpha3_ForksProvingBound(t *testing.T) {
s := newRVSim(t, 5, 3) // α=3 → 2α−n = 1 = f=1 → bound 2α−n>f VIOLATED
s.node(4).up = false
byzID := s.node(4).id
X := rvBlockID(0xA0)
Y := rvBlockID(0xB0)
sibs := []ids.ID{X, Y}
grp := map[ids.NodeID]int{
s.node(0).id: 1, s.node(1).id: 1,
s.node(2).id: 2, s.node(3).id: 2,
}
s.link = func(from, to ids.NodeID) bool {
if from == byzID || to == byzID {
return true
}
return grp[from] == grp[to] // permanent partition
}
s.node(0).seen[X] = struct{}{}
s.node(1).seen[X] = struct{}{}
s.node(2).seen[Y] = struct{}{}
s.node(3).seen[Y] = struct{}{}
forked := false
var fa, fb ids.ID
for tick := 0; tick < 12 && !forked; tick++ {
redStep(s, byzID, sibs, 4)
forked, fa, fb = rvFork(s.nodes)
}
if !forked {
t.Fatalf("expected a FORK at α=3 (2α−n=f, bound violated) but none occurred — re-examine the safety model/harness")
}
t.Logf("BOUND CONFIRMED TIGHT: at α=3 (2α−n=1 = f) ONE equivocator double-finalized %s vs %s. "+
"The engine config MUST enforce α=4 for n=5 (2α−n=3>1). round_view.go itself does NOT self-check this.", fa, fb)
}
// -----------------------------------------------------------------------------
// VECTOR 2/6 (cross-round + future-round injection): all honest nodes first reach consensus
// on X while the equivocator spams Y prevotes/precommits at EVERY round (including rounds
// ahead of the honest current round) from the very start, trying to (a) manufacture a
// POL(Y) at a high round to unlock X-locked nodes, or (b) push a Y precommit-quorum. The
// invariant: Y never reaches a POL and never finalizes anywhere.
// -----------------------------------------------------------------------------
func TestRoundView_EquivocatorFutureRounds_CannotForge_POL(t *testing.T) {
s := newRVSim(t, 5, 4)
s.node(4).up = false
byzID := s.node(4).id
X := rvBlockID(0xA0)
Y := rvBlockID(0xB0)
sibs := []ids.ID{X, Y}
s.link = func(from, to ids.NodeID) bool { return true } // fully connected; only the equivocator is adversarial
// All 4 honest nodes see X (the honest sibling). Y exists ONLY as the equivocator's
// fabricated votes — no honest node ever prevotes Y.
for i := 0; i < 4; i++ {
s.node(i).seen[X] = struct{}{}
}
converged := false
for tick := 0; tick < 60 && !converged; tick++ {
redStep(s, byzID, sibs, 20) // Y prevotes/precommits at rounds 0..20 every tick
if forked, a, b := rvFork(s.nodes); forked {
t.Fatalf("FORK: equivocator forged a competing finalize %s vs %s", a, b)
}
// Y must NEVER acquire a POL at any honest node (only 1 Byz vote for Y → <α).
for i := 0; i < 4; i++ {
if _, ok := s.node(i).view.havePOL[Y]; ok {
t.Fatalf("node %d formed a POL for the equivocator's phantom Y — false POL", i)
}
if s.node(i).view.finalized && s.node(i).view.finalizedBlock == Y {
t.Fatalf("node %d finalized the phantom Y", i)
}
}
converged = s.node(0).view.finalized && s.node(1).view.finalized &&
s.node(2).view.finalized && s.node(3).view.finalized
}
if !converged {
t.Fatalf("LIVENESS: honest nodes did not finalize X under a future-round-spamming equivocator")
}
for i := 0; i < 4; i++ {
if s.node(i).view.finalizedBlock != X {
t.Fatalf("node %d finalized %s, want X", i, s.node(i).view.finalizedBlock)
}
}
t.Logf("RED PASS: equivocator cannot forge a POL(Y) at any round; all honest finalized X")
}
// -----------------------------------------------------------------------------
// VECTOR 1/3/6 (randomized): Blue's 200-seed stress with a REAL f=1 equivocator added.
// Random partitions flip each tick then heal; the equivocator double-votes both siblings at
// all rounds throughout. Invariant every tick: no two honest nodes finalize differently.
// Then liveness: once healed, all honest converge.
// -----------------------------------------------------------------------------
func TestRoundView_ByzantineEquivocatorStress_Alpha4_NoFork(t *testing.T) {
for seed := int64(0); seed < 200; seed++ {
rng := rand.New(rand.NewSource(seed))
s := newRVSim(t, 5, 4)
s.node(4).up = false // node 4 = f=1 Byzantine equivocator
byzID := s.node(4).id
X := rvBlockID(0xA0)
Y := rvBlockID(0xB0)
sibs := []ids.ID{X, Y}
s.node(rng.Intn(4)).seen[X] = struct{}{}
s.node(rng.Intn(4)).seen[Y] = struct{}{}
healAt := 14
var side map[ids.NodeID]int
s.link = func(from, to ids.NodeID) bool {
if from == byzID || to == byzID {
return true
}
if side == nil {
return true
}
return side[from] == side[to]
}
finalizedAll := false
for tick := 0; tick < 200 && !finalizedAll; tick++ {
if tick < healAt {
side = map[ids.NodeID]int{}
for i := 0; i < 4; i++ {
side[s.node(i).id] = rng.Intn(2)
}
} else {
side = nil // permanent heal
}
redStep(s, byzID, sibs, 16)
if forked, a, b := rvFork(s.nodes); forked {
t.Fatalf("seed %d: FORK with f=1 equivocator at α=4: %s vs %s", seed, a, b)
}
finalizedAll = s.node(0).view.finalized && s.node(1).view.finalized &&
s.node(2).view.finalized && s.node(3).view.finalized
}
if !finalizedAll {
t.Fatalf("seed %d: LIVENESS — honest nodes did not converge after heal despite equivocator", seed)
}
// Cross-check: all honest agree.
if forked, a, b := rvFork(s.nodes); forked {
t.Fatalf("seed %d: post-run FORK %s vs %s", seed, a, b)
}
}
t.Logf("BYZANTINE STRESS PASS: 200 seeds, f=1 equivocator (double-prevote+double-precommit, every sibling, every round) + random partition, α=4 — no fork, always converged")
}
+98 -4
View File
@@ -69,7 +69,7 @@ func newRVSim(t *testing.T, n, alpha int) *rvSim {
id[0] = byte(i + 1)
s.nodes = append(s.nodes, &rvNode{
id: id,
view: newRoundView(1, alpha),
view: newRoundView(1, alpha, n),
seen: map[ids.ID]struct{}{},
up: true,
})
@@ -146,17 +146,37 @@ func (s *rvSim) stepOnce() bool {
bus = append(bus, rvMsg{kind: rvPrevote, from: n.id, round: act.CurRound, block: act.Prevote})
}
if act.Precommit != ids.Empty {
if n.view.recordOwnPrecommit(act.Precommit) {
n.view.observePrecommit(n.id, act.CurRound, act.Precommit) // count self
bus = append(bus, rvMsg{kind: rvPrecommit, from: n.id, round: act.CurRound, block: act.Precommit})
if n.view.recordOwnPrecommit(act.Precommit, act.PrecommitRound) {
n.view.observePrecommit(n.id, act.PrecommitRound, act.Precommit) // count self
bus = append(bus, rvMsg{kind: rvPrecommit, from: n.id, round: act.PrecommitRound, block: act.Precommit})
}
}
}
s.deliver(polCertMsgs(s.nodes)) // gossip validValue POLs so every node converges validValue
s.deliver(bus)
s.assertNoFork()
return s.allFinalized()
}
// polCertMsgs relays each up node's validValue POL (its constituent prevotes) as gossip so a
// peer that missed those prevotes can form the same POL and adopt the same validValue.
func polCertMsgs(nodes []*rvNode) []rvMsg {
var out []rvMsg
for _, n := range nodes {
if !n.up {
continue
}
block, round, voters, ok := n.view.polCert()
if !ok {
continue
}
for _, voter := range voters {
out = append(out, rvMsg{kind: rvPrevote, from: voter, round: round, block: block})
}
}
return out
}
// run steps up to maxTicks macro-ticks. Returns true if every up node finalized.
func (s *rvSim) run(maxTicks int) bool {
for tick := 0; tick < maxTicks; tick++ {
@@ -259,6 +279,80 @@ func TestRoundView_AsymmetricSplit_Converges_NoFork(t *testing.T) {
t.Logf("LIVENESS PASS: split→heal converged all 4 live nodes onto %s (no fork)", want)
}
// -----------------------------------------------------------------------------
// LIVENESS under NON-UNIFORM VISIBILITY + round drift (the residual-freeze gate). Blue's
// original stress seeded both siblings to ALL nodes at once (uniform visibility), which
// masked the phase-offset drift RED found: under asymmetric gossip an honest node lags ~1
// round permanently → its 3<α prevotes never form a POL → ~3% permanent stall. Here each
// sibling is seeded to ONE random node and propagates only via block gossip, and the
// partition flips randomly then heals — so honest nodes genuinely drift in round. The
// ROUND-SKIP rule (jump to a round with ≥f+1 senders) must re-align them: this MUST reach
// 0/200 stalls (every seed converges) with NO fork.
// -----------------------------------------------------------------------------
func TestRoundView_NonUniformVisibility_HonestLiveness_NoStall(t *testing.T) {
stalls := 0
for seed := int64(0); seed < 200; seed++ {
rng := rand.New(rand.NewSource(seed + 7))
s := newRVSim(t, 5, 4)
down := rng.Intn(5)
s.node(down).up = false // zero-margin: 4 live = α=4
up := []int{}
for i := 0; i < 5; i++ {
if i != down {
up = append(up, i)
}
}
A := rvBlockID(0xA0)
B := rvBlockID(0xB0)
// NON-UNIFORM: each sibling seeded to ONE up node; it spreads only by block gossip.
s.node(up[rng.Intn(len(up))]).seen[A] = struct{}{}
s.node(up[rng.Intn(len(up))]).seen[B] = struct{}{}
healAt := 10 + rng.Intn(10)
finalizedAll := false
for tick := 0; tick < 260 && !finalizedAll; tick++ {
if tick < healAt {
side := map[ids.NodeID]int{}
for _, i := range up {
side[s.node(i).id] = rng.Intn(2)
}
s.link = func(from, to ids.NodeID) bool { return side[from] == side[to] }
} else {
s.link = nil // permanent heal
}
finalizedAll = s.stepOnce()
}
if !finalizedAll {
stalls++
t.Errorf("seed %d: STALL — honest nodes drifted and did not converge after heal (down=%d)", seed, down)
}
if forked, a, b := func() (bool, ids.ID, ids.ID) {
var h ids.ID
set := false
for _, n := range s.nodes {
if !n.up || !n.view.finalized {
continue
}
if !set {
h, set = n.view.finalizedBlock, true
continue
}
if n.view.finalizedBlock != h {
return true, h, n.view.finalizedBlock
}
}
return false, ids.Empty, ids.Empty
}(); forked {
t.Fatalf("seed %d: FORK %s vs %s", seed, a, b)
}
}
if stalls != 0 {
t.Fatalf("NON-UNIFORM LIVENESS: %d/200 stalls — round-skip did not eliminate the freeze", stalls)
}
t.Logf("NON-UNIFORM LIVENESS PASS: 0/200 stalls under non-uniform visibility + round drift + heal (round-skip works)")
}
// -----------------------------------------------------------------------------
// SAFETY: first-quorum-wins. A gets a POL and some nodes lock on it; then a LOWER
// sibling B appears. The lock rule must keep the net on A (no POL(B) can form over the