mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
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:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user