mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
deps: pulsar v1.9.0 pkg/ layout + threshold v1.12.0 dealerless daemon
Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -138,6 +138,6 @@ For k=32 sampling with β=15 rounds, expected unique signers ≈ 50-80.
|
|||||||
## References
|
## References
|
||||||
|
|
||||||
- Existing Quasar family: `~/work/lux/consensus/protocol/{photon,wave,flare,focus,horizon,ray,nova,nebula,prism,quasar}`
|
- Existing Quasar family: `~/work/lux/consensus/protocol/{photon,wave,flare,focus,horizon,ray,nova,nebula,prism,quasar}`
|
||||||
- Avalanche/Snowman metastable family (academic prior art; superseded by Quasar/Nova per LP-020, LP-134)
|
- the classical metastable consensus family (academic prior art; superseded by Quasar/Nova per LP-020, LP-134)
|
||||||
- LP-045 hierarchical quorum certs
|
- LP-045 hierarchical quorum certs
|
||||||
- `~/work/lux/proofs/pq-finality-no-bls.tex`
|
- `~/work/lux/proofs/pq-finality-no-bls.tex`
|
||||||
|
|||||||
+43
-17
@@ -162,16 +162,20 @@ type PendingBlock struct {
|
|||||||
rePollBackoff time.Duration
|
rePollBackoff time.Duration
|
||||||
|
|
||||||
// rePollAttempts counts how many times the re-poll loop has re-solicited this
|
// rePollAttempts counts how many times the re-poll loop has re-solicited this
|
||||||
// block. Once it reaches maxRePollAttempts the block is ABANDONED for re-poll
|
// block. For a NON-OWN (gossiped) block, once it reaches maxRePollAttempts the
|
||||||
// purposes (rePollAbandoned) — re-soliciting the SAME block to peers who
|
// block is ABANDONED for re-poll purposes (rePollAbandoned) — re-soliciting a
|
||||||
// cannot vote (they are behind its parent) is pure spam and never recovers it;
|
// gossiped block to peers who cannot vote (they are behind its parent) is pure
|
||||||
// the catch-up path (requestCatchup) is what recovers a behind frontier.
|
// spam and never recovers it; the catch-up path (requestCatchup) is what recovers
|
||||||
|
// a behind frontier. An OWN proposal is never abandoned (the proposer drives it to
|
||||||
|
// finality), so this counter only paces its bounded-backoff re-solicitation.
|
||||||
rePollAttempts int
|
rePollAttempts int
|
||||||
|
|
||||||
// rePollAbandoned is set once rePollAttempts hits the cap. An abandoned block
|
// rePollAbandoned is set once a NON-OWN block's rePollAttempts hits the cap. An
|
||||||
// is NEVER re-polled again (no infinite spam), but is NOT deleted: it remains
|
// abandoned block is NEVER re-polled again (no infinite spam), but is NOT deleted:
|
||||||
// pending and recoverable — a late cert (HandleIncomingCert) or a catch-up
|
// it remains pending and recoverable — a late cert (HandleIncomingCert) or a
|
||||||
// that fills its parent can still finalize it.
|
// catch-up that fills its parent can still finalize it. An OWN proposal never sets
|
||||||
|
// this flag: it is re-solicited until it decides, so a down/wedged/forked
|
||||||
|
// designated proposer cannot halt the chain by starving the substitute's votes.
|
||||||
rePollAbandoned bool
|
rePollAbandoned bool
|
||||||
|
|
||||||
// IsOwnProposal is true when this node built and proposed the block. It now
|
// IsOwnProposal is true when this node built and proposed the block. It now
|
||||||
@@ -1106,11 +1110,15 @@ const (
|
|||||||
// every maxRePollBackoff, turning a 250ms storm into a trickle.
|
// every maxRePollBackoff, turning a 250ms storm into a trickle.
|
||||||
maxRePollBackoff = 16 * time.Second
|
maxRePollBackoff = 16 * time.Second
|
||||||
|
|
||||||
// maxRePollAttempts is the hard cap on re-poll attempts per block. After this
|
// maxRePollAttempts is the hard cap on re-poll attempts for a NON-OWN (gossiped)
|
||||||
// many re-solicitations the block is abandoned for re-poll (rePollAbandoned)
|
// block. After this many re-solicitations such a block is abandoned for re-poll
|
||||||
// — it is never re-polled again (but stays pending, recoverable via cert or
|
// (rePollAbandoned) — it is never re-polled again (but stays pending, recoverable
|
||||||
// catch-up). With doubling from a 250ms base, 8 attempts span
|
// via cert or catch-up): re-soliciting a gossiped block whose voters are behind
|
||||||
// 0.25+0.5+1+2+4+8+16+16 ≈ 48s of backstop before giving up on re-poll.
|
// its parent is spam that re-poll cannot fix. An UNDECIDED OWN proposal is NEVER
|
||||||
|
// abandoned (see rePollAllPending): the proposer keeps re-soliciting it until it
|
||||||
|
// finalizes, matching avalanchego's "re-poll a processing block until decided".
|
||||||
|
// With doubling from a 250ms base, 8 attempts span
|
||||||
|
// 0.25+0.5+1+2+4+8+16+16 ≈ 48s of backstop before giving up on a gossiped block.
|
||||||
maxRePollAttempts = 8
|
maxRePollAttempts = 8
|
||||||
|
|
||||||
// catchupCooldown rate-limits ancestor fetches per missing parent: at most one
|
// catchupCooldown rate-limits ancestor fetches per missing parent: at most one
|
||||||
@@ -1261,8 +1269,9 @@ func (t *Transitive) rePollAllPending(ctx context.Context, base time.Duration) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// This block is due. Record the attempt, advance the backoff (double, cap),
|
// This block is due. Record the attempt and advance the backoff (double,
|
||||||
// and abandon once the cap is hit so it is NEVER re-polled again.
|
// cap) so re-solicitation is a bounded trickle (≤ maxRePollBackoff), never a
|
||||||
|
// storm.
|
||||||
pending.lastRePoll = now
|
pending.lastRePoll = now
|
||||||
pending.rePollAttempts++
|
pending.rePollAttempts++
|
||||||
next := window * 2
|
next := window * 2
|
||||||
@@ -1270,9 +1279,26 @@ func (t *Transitive) rePollAllPending(ctx context.Context, base time.Duration) {
|
|||||||
next = maxRePollBackoff
|
next = maxRePollBackoff
|
||||||
}
|
}
|
||||||
pending.rePollBackoff = next
|
pending.rePollBackoff = next
|
||||||
if pending.rePollAttempts >= maxRePollAttempts {
|
// LIVENESS (the down/wedged/forked-proposer halt): an UNDECIDED OWN proposal
|
||||||
|
// is NEVER abandoned. This node BUILT it on the finalized tip (its voters
|
||||||
|
// therefore HAVE the parent and CAN vote), and as the proposer it owns driving
|
||||||
|
// it to an α-of-K cert — so it must keep re-soliciting until the block decides
|
||||||
|
// (then it leaves pendingBlocks and the re-poll quiesces). This is exactly
|
||||||
|
// avalanchego's contract: re-poll a PROCESSING block until it is decided, and
|
||||||
|
// only quiesce at NumProcessing()==0 (snow/engine/snowman voter.go +
|
||||||
|
// Engine.Gossip's 100ms repoll). Abandoning an own proposal after a fixed
|
||||||
|
// attempt cap was the Lux-only divergence that froze mainnet C-Chain: the
|
||||||
|
// substitute's canonical block stopped being re-solicited and the chain halted
|
||||||
|
// even though the honest majority was ready to vote (zero-margin 4-of-5 once a
|
||||||
|
// 5th validator forks/wedges). The bounded backoff above keeps this storm-safe.
|
||||||
|
//
|
||||||
|
// A NON-own (gossiped) block keeps the attempt cap: re-soliciting a block whose
|
||||||
|
// voters are BEHIND its parent (the gossip-from-an-ahead-peer case) is pure spam
|
||||||
|
// and never recovers it — that block recovers via cert-gossip or the catch-up
|
||||||
|
// fetch, not by re-poll. So the cap still bounds the follower path.
|
||||||
|
if !pending.IsOwnProposal && pending.rePollAttempts >= maxRePollAttempts {
|
||||||
pending.rePollAbandoned = true
|
pending.rePollAbandoned = true
|
||||||
t.log.Warn("re-poll: block abandoned after attempt cap — not re-soliciting further (recoverable via cert/catch-up)",
|
t.log.Warn("re-poll: gossiped block abandoned after attempt cap — not re-soliciting further (recoverable via cert/catch-up)",
|
||||||
"blockID", blockID, "attempts", pending.rePollAttempts)
|
"blockID", blockID, "attempts", pending.rePollAttempts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||||
|
// See the file LICENSE for licensing terms.
|
||||||
|
|
||||||
|
// proposer_liveness_test.go — the DURABLE regression suite for the
|
||||||
|
// down/wedged/forked-designated-proposer LIVENESS halt (Lux mainnet C-Chain,
|
||||||
|
// stuck producing 1082825 above a finalized 1082824).
|
||||||
|
//
|
||||||
|
// ROOT CAUSE (in the engine's terms): a height's designated proposer is
|
||||||
|
// down/wedged/FORKED (present, sampled, gossiping, but it never produces a
|
||||||
|
// canonical-extending block that finalizes). A SUBSTITUTE (the next slot's
|
||||||
|
// designated proposer) builds the canonical block, self-votes, and solicits peer
|
||||||
|
// votes ONCE. If the α-of-K signed votes do not assemble on that first
|
||||||
|
// solicitation — the common case when a follower is briefly behind, or the first
|
||||||
|
// PushQuery is dropped, or (the mainnet reality) one of the five validators is a
|
||||||
|
// permanent non-voter so the OTHER four must ALL vote with ZERO margin — nothing
|
||||||
|
// re-solicits the missing votes once `rePollAllPending` ABANDONS the block after
|
||||||
|
// `maxRePollAttempts` (the Lux-only divergence from avalanchego, which re-polls a
|
||||||
|
// processing block until it decides and only quiesces at NumProcessing()==0). The
|
||||||
|
// block then sits in pendingBlocks, never re-solicited, and the chain HALTS.
|
||||||
|
//
|
||||||
|
// THE FIX (proven here): an UNDECIDED OWN proposal is NEVER abandoned for re-poll.
|
||||||
|
// It is re-solicited forever on the bounded (capped) backoff schedule until it
|
||||||
|
// decides (and is removed from pendingBlocks, so the re-poll naturally quiesces) —
|
||||||
|
// matching avalanchego's "re-poll while processing, quiesce when decided". This is
|
||||||
|
// a pure LIVENESS retry: it changes NOTHING about the finality predicate (a block
|
||||||
|
// still finalizes ONLY on a verified α-of-K cert that clears the stake gate), so
|
||||||
|
// the safety tests below (sub-quorum, forged, out-of-set, tampered) still reject.
|
||||||
|
//
|
||||||
|
// fails-before / passes-after is shown for every behavior: the pre-fix abandon is
|
||||||
|
// asserted RED, the post-fix persistence GREEN.
|
||||||
|
package chain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/luxfi/consensus/config"
|
||||||
|
"github.com/luxfi/ids"
|
||||||
|
)
|
||||||
|
|
||||||
|
// params5Prod returns the PRODUCTION 5-validator quorum params: K=5, α=4
|
||||||
|
// (⌊2·5/3⌋+1 — what bftCommittee derives for a 5-node value network, see
|
||||||
|
// dynamic_test.go which asserts K5/α4). The finality_test.go params5() uses α=3
|
||||||
|
// for legacy count tests; the liveness/safety properties here must hold at the
|
||||||
|
// REAL production threshold where the 4 healthy nodes are the EXACT quorum (zero
|
||||||
|
// margin — the mainnet condition after a 5th validator forks/wedges).
|
||||||
|
func params5Prod() config.Parameters {
|
||||||
|
p := params5()
|
||||||
|
p.AlphaPreference = 4
|
||||||
|
p.AlphaConfidence = 4
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// reSolicitProbe is a BlockProposer that records how many times the engine
|
||||||
|
// re-solicits votes for each block (RequestVotes), and fires an optional hook on
|
||||||
|
// each solicitation. It is the instrument that makes the re-poll behavior
|
||||||
|
// observable: pre-fix the count plateaus at the abandon cap; post-fix it grows
|
||||||
|
// without bound while the block is undecided. The hook models a follower that
|
||||||
|
// only votes once it is (re-)solicited — the production reality, where a node that
|
||||||
|
// missed the first gossip casts its vote only when the proposer re-sends it.
|
||||||
|
type reSolicitProbe struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
requests map[ids.ID]int
|
||||||
|
proposed map[ids.ID]int
|
||||||
|
onRequest func(blockID ids.ID, cumulative int)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newReSolicitProbe() *reSolicitProbe {
|
||||||
|
return &reSolicitProbe{requests: map[ids.ID]int{}, proposed: map[ids.ID]int{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *reSolicitProbe) Propose(_ context.Context, proposal BlockProposal) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
p.proposed[proposal.BlockID]++
|
||||||
|
p.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *reSolicitProbe) RequestVotes(_ context.Context, req VoteRequest) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
p.requests[req.BlockID]++
|
||||||
|
n := p.requests[req.BlockID]
|
||||||
|
hook := p.onRequest
|
||||||
|
p.mu.Unlock()
|
||||||
|
if hook != nil {
|
||||||
|
hook(req.BlockID, n)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *reSolicitProbe) requestCount(id ids.ID) int {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
return p.requests[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
// driveRePoll forces `cycles` re-poll passes over the engine's pending blocks
|
||||||
|
// synchronously, each pass made "due" by resetting every undecided block's backoff
|
||||||
|
// window to the distant past. This drives the re-poll attempt counter deterministically
|
||||||
|
// WITHOUT waiting on the exponential backoff (so the test is fast and not timing-flaky),
|
||||||
|
// and is isolated from the background ticker by the caller using a long RoundTO so the
|
||||||
|
// background loop never fires during the synchronous drive.
|
||||||
|
func driveRePoll(e *Transitive, cycles int) {
|
||||||
|
for i := 0; i < cycles; i++ {
|
||||||
|
e.mu.Lock()
|
||||||
|
for _, pb := range e.pendingBlocks {
|
||||||
|
if pb.Decided {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pb.lastRePoll = time.Now().Add(-time.Hour)
|
||||||
|
pb.rePollBackoff = time.Nanosecond
|
||||||
|
}
|
||||||
|
e.mu.Unlock()
|
||||||
|
e.rePollAllPending(context.Background(), time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// LIVENESS — the core bug. An undecided own proposal must be re-solicited
|
||||||
|
// forever, never abandoned, so the substitute's canonical block finalizes once
|
||||||
|
// the honest majority's votes arrive.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// TestProposerLiveness_UndecidedOwnProposal_NeverAbandoned is the DIRECT
|
||||||
|
// fails-before/passes-after proof of the fix. A substitute proposer (node 0)
|
||||||
|
// builds the canonical next block with only a sub-quorum of votes present (the
|
||||||
|
// wedged/forked 5th validator never votes, and a 4th honest vote has not yet
|
||||||
|
// arrived). The engine must keep re-soliciting it forever.
|
||||||
|
//
|
||||||
|
// PRE-FIX: rePollAllPending sets rePollAbandoned after maxRePollAttempts (8), then
|
||||||
|
// SKIPS the block — RequestVotes plateaus near 8 and the block is never re-solicited
|
||||||
|
// again → permanent halt.
|
||||||
|
// POST-FIX: the undecided own proposal is never abandoned; RequestVotes keeps firing
|
||||||
|
// every pass.
|
||||||
|
func TestProposerLiveness_UndecidedOwnProposal_NeverAbandoned(t *testing.T) {
|
||||||
|
vs := newTestValidatorSet(5)
|
||||||
|
params := params5Prod()
|
||||||
|
params.RoundTO = 10 * time.Second // park the background ticker; we drive re-poll manually
|
||||||
|
rec := &recordingGossiper{}
|
||||||
|
e, chainID := newQuorumEngine(t, params, vs, 0, rec)
|
||||||
|
|
||||||
|
cp := newReSolicitProbe()
|
||||||
|
e.SetProposer(cp)
|
||||||
|
|
||||||
|
// Node 0 is the substitute: it builds the canonical block at the stuck height.
|
||||||
|
blk := newTestBlock(1, ids.Empty, "substitute-canonical")
|
||||||
|
pos := trackProposal(e, chainID, blk, 0)
|
||||||
|
// Only one peer (node 1) has voted so far → 2 of 5, below α=4. The block is a
|
||||||
|
// genuine, healthy, undecided own proposal awaiting the rest of the honest quorum.
|
||||||
|
e.ReceiveVote(vs.signedVote(1, pos))
|
||||||
|
|
||||||
|
const cycles = 40 // >> maxRePollAttempts (8)
|
||||||
|
driveRePoll(e, cycles)
|
||||||
|
|
||||||
|
e.mu.RLock()
|
||||||
|
pb := e.pendingBlocks[blk.id]
|
||||||
|
abandoned := pb != nil && pb.rePollAbandoned
|
||||||
|
e.mu.RUnlock()
|
||||||
|
reqs := cp.requestCount(blk.id)
|
||||||
|
|
||||||
|
if abandoned {
|
||||||
|
t.Fatalf("LIVENESS HALT: undecided own proposal was ABANDONED for re-poll after %d attempts "+
|
||||||
|
"(rePollAbandoned=true) — the substitute's canonical block stops being re-solicited and the "+
|
||||||
|
"chain halts forever. avalanchego re-polls a processing block until it decides.", reqs)
|
||||||
|
}
|
||||||
|
if reqs <= maxRePollAttempts {
|
||||||
|
t.Fatalf("re-solicitation stopped early: RequestVotes fired only %d times over %d passes "+
|
||||||
|
"(expected continuous re-solicitation > maxRePollAttempts=%d). A block awaiting the honest "+
|
||||||
|
"quorum must keep being solicited.", reqs, cycles, maxRePollAttempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProposerLiveness_LateHonestVote_FinalizesViaPersistentRePoll is the
|
||||||
|
// EMERGENT proof: a 4th honest voter (node 3) only casts its vote in RESPONSE to a
|
||||||
|
// re-solicitation that arrives AFTER the pre-fix abandon point (it was briefly
|
||||||
|
// behind). The block finalizes iff the proposer keeps re-soliciting past that
|
||||||
|
// point — i.e. only post-fix. The wedged/forked 5th validator (node 4) never votes.
|
||||||
|
func TestProposerLiveness_LateHonestVote_FinalizesViaPersistentRePoll(t *testing.T) {
|
||||||
|
vs := newTestValidatorSet(5)
|
||||||
|
params := params5Prod()
|
||||||
|
params.RoundTO = 10 * time.Second
|
||||||
|
rec := &recordingGossiper{}
|
||||||
|
e, chainID := newQuorumEngine(t, params, vs, 0, rec)
|
||||||
|
|
||||||
|
cp := newReSolicitProbe()
|
||||||
|
e.SetProposer(cp)
|
||||||
|
|
||||||
|
blk := newTestBlock(7, ids.Empty, "late-quorum")
|
||||||
|
pos := trackProposal(e, chainID, blk, 0) // node 0 self-vote = 1
|
||||||
|
e.ReceiveVote(vs.signedVote(1, pos)) // node 1
|
||||||
|
e.ReceiveVote(vs.signedVote(2, pos)) // node 2 → 3 of 5, still < α=4
|
||||||
|
|
||||||
|
// Node 3 is briefly behind: it casts its (4th, quorum-completing) vote ONLY once
|
||||||
|
// the proposer has re-solicited past the abandon point. Node 4 (wedged/forked)
|
||||||
|
// never votes.
|
||||||
|
const lateSolicit = maxRePollAttempts + 4 // 12: only reachable if re-poll never abandons
|
||||||
|
var once sync.Once
|
||||||
|
cp.onRequest = func(id ids.ID, n int) {
|
||||||
|
if id == blk.id && n >= lateSolicit {
|
||||||
|
once.Do(func() { e.ReceiveVote(vs.signedVote(3, pos)) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drive re-poll well past both the abandon cap and the late-solicit threshold.
|
||||||
|
driveRePoll(e, 60)
|
||||||
|
|
||||||
|
finalized := waitFor(3*time.Second, func() bool { return e.IsAccepted(blk.id) })
|
||||||
|
reqs := cp.requestCount(blk.id)
|
||||||
|
if !finalized {
|
||||||
|
t.Fatalf("LIVENESS HALT: block never finalized. RequestVotes fired %d times; the late honest "+
|
||||||
|
"vote is only solicited at attempt %d, but the pre-fix abandon caps re-poll at "+
|
||||||
|
"maxRePollAttempts=%d, so node 3 is never re-solicited and the 4-of-5 quorum never assembles.",
|
||||||
|
reqs, lateSolicit, maxRePollAttempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// SAFETY — the fix must NOT lower the BFT threshold. Persistent re-solicitation
|
||||||
|
// re-sends a block; it can NEVER manufacture a vote, so a sub-quorum, a forged
|
||||||
|
// vote, or an out-of-set signer still cannot finalize.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// TestProposerSafety_SubQuorumNeverFinalizes_EvenWithPersistentRePoll proves the
|
||||||
|
// liveness retry does not weaken safety: 3 of 5 (< α=4) is re-solicited forever and
|
||||||
|
// STILL never finalizes. A genuine minority cannot finalize no matter how long it
|
||||||
|
// re-polls.
|
||||||
|
func TestProposerSafety_SubQuorumNeverFinalizes_EvenWithPersistentRePoll(t *testing.T) {
|
||||||
|
vs := newTestValidatorSet(5)
|
||||||
|
params := params5Prod()
|
||||||
|
params.RoundTO = 10 * time.Second
|
||||||
|
rec := &recordingGossiper{}
|
||||||
|
e, chainID := newQuorumEngine(t, params, vs, 0, rec)
|
||||||
|
e.SetProposer(newReSolicitProbe())
|
||||||
|
|
||||||
|
blk := newTestBlock(3, ids.Empty, "minority")
|
||||||
|
pos := trackProposal(e, chainID, blk, 0) // self = 1
|
||||||
|
e.ReceiveVote(vs.signedVote(1, pos)) // 2
|
||||||
|
e.ReceiveVote(vs.signedVote(2, pos)) // 3 of 5 < α=4
|
||||||
|
|
||||||
|
driveRePoll(e, 60) // re-solicit far past the old abandon cap
|
||||||
|
|
||||||
|
if waitFor(500*time.Millisecond, func() bool { return e.IsAccepted(blk.id) }) {
|
||||||
|
t.Fatal("SAFETY VIOLATION: a sub-quorum (3 of 5, below α=4) finalized — persistent re-poll " +
|
||||||
|
"must re-SOLICIT only; it must never lower the α-of-K threshold.")
|
||||||
|
}
|
||||||
|
rec.mu.Lock()
|
||||||
|
certs := len(rec.certs)
|
||||||
|
rec.mu.Unlock()
|
||||||
|
if certs != 0 {
|
||||||
|
t.Fatalf("no cert may be produced below α; got %d", certs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProposerSafety_ForgedAndOutOfSetVotes_NeverCount proves re-solicitation
|
||||||
|
// cannot be exploited: an out-of-set signer and a real validator's signature lifted
|
||||||
|
// to the WRONG position are both dropped, so even re-solicited forever the block
|
||||||
|
// stays at its 3 genuine votes (< α) and never finalizes.
|
||||||
|
func TestProposerSafety_ForgedAndOutOfSetVotes_NeverCount(t *testing.T) {
|
||||||
|
vs := newTestValidatorSet(5)
|
||||||
|
params := params5Prod()
|
||||||
|
params.RoundTO = 10 * time.Second
|
||||||
|
rec := &recordingGossiper{}
|
||||||
|
e, chainID := newQuorumEngine(t, params, vs, 0, rec)
|
||||||
|
e.SetProposer(newReSolicitProbe())
|
||||||
|
|
||||||
|
blk := newTestBlock(9, ids.Empty, "forged-attack")
|
||||||
|
pos := trackProposal(e, chainID, blk, 0) // self = 1
|
||||||
|
e.ReceiveVote(vs.signedVote(1, pos)) // 2
|
||||||
|
e.ReceiveVote(vs.signedVote(2, pos)) // 3 (genuine)
|
||||||
|
|
||||||
|
// (a) out-of-set signer: keys unknown to vs.
|
||||||
|
outsider := newTestValidatorSet(1)
|
||||||
|
e.ReceiveVote(Vote{
|
||||||
|
BlockID: blk.id, NodeID: outsider.nodeID(0), Accept: true, SignedAt: time.Now(),
|
||||||
|
Signature: outsider.sign(0, pos), ParentID: pos.ParentID, Round: pos.Round,
|
||||||
|
})
|
||||||
|
// (b) real validator (node 3) signature over a DIFFERENT (wrong) position.
|
||||||
|
wrong := pos
|
||||||
|
wrong.Height++
|
||||||
|
e.ReceiveVote(Vote{
|
||||||
|
BlockID: blk.id, NodeID: vs.nodeID(3), Accept: true, SignedAt: time.Now(),
|
||||||
|
Signature: vs.sign(3, wrong), ParentID: pos.ParentID, Round: pos.Round,
|
||||||
|
})
|
||||||
|
|
||||||
|
driveRePoll(e, 60)
|
||||||
|
|
||||||
|
if waitFor(500*time.Millisecond, func() bool { return e.IsAccepted(blk.id) }) {
|
||||||
|
t.Fatal("SAFETY VIOLATION: forged/out-of-set/wrong-position votes were counted toward the quorum.")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -200,11 +200,17 @@ func TestRecovery_RePollBacksOffExponentially(t *testing.T) {
|
|||||||
time.Sleep(window)
|
time.Sleep(window)
|
||||||
|
|
||||||
got := prop.count()
|
got := prop.count()
|
||||||
// Linear would be ~ window/RoundTO = 50. Backoff (10,20,40,80,160,...) capped
|
// Linear (a fixed-cadence storm) would be ~ window/RoundTO = 50. EXPONENTIAL
|
||||||
// at maxRePollAttempts (8) yields at most maxRePollAttempts re-polls EVER.
|
// backoff (10,20,40,80,160,…) makes the count GEOMETRIC in the window —
|
||||||
if got > int64(maxRePollAttempts) {
|
// log2(window/RoundTO) ≈ log2(50) ≈ 6 — so it is bounded far below linear. This
|
||||||
t.Fatalf("re-poll must be bounded by the attempt cap (%d), got %d over %v — poll storm not fixed",
|
// is the storm-safety invariant, and it does NOT depend on the attempt cap: an
|
||||||
maxRePollAttempts, got, window)
|
// own proposal is re-solicited until it decides (never abandoned), but the
|
||||||
|
// backoff keeps that a slow trickle, not a 250ms storm. Ceiling 12 is a safe
|
||||||
|
// geometric bound for this window, still ≪ the linear 50.
|
||||||
|
const geometricCeiling = 12
|
||||||
|
if got > geometricCeiling {
|
||||||
|
t.Fatalf("re-poll must be bounded by exponential backoff (geometric ≈%d), got %d over %v — poll storm not fixed",
|
||||||
|
geometricCeiling, got, window)
|
||||||
}
|
}
|
||||||
if got == 0 {
|
if got == 0 {
|
||||||
t.Fatalf("liveness: a stuck own block must be re-polled at least once, got 0")
|
t.Fatalf("liveness: a stuck own block must be re-polled at least once, got 0")
|
||||||
|
|||||||
@@ -13,19 +13,19 @@ require (
|
|||||||
github.com/luxfi/crypto v1.19.26
|
github.com/luxfi/crypto v1.19.26
|
||||||
github.com/luxfi/database v1.19.3
|
github.com/luxfi/database v1.19.3
|
||||||
github.com/luxfi/dkg v0.3.5
|
github.com/luxfi/dkg v0.3.5
|
||||||
github.com/luxfi/ids v1.2.15
|
github.com/luxfi/ids v1.3.0
|
||||||
github.com/luxfi/lattice/v7 v7.1.4
|
github.com/luxfi/lattice/v7 v7.1.4
|
||||||
github.com/luxfi/log v1.4.3
|
github.com/luxfi/log v1.4.3
|
||||||
github.com/luxfi/magnetar v1.2.3
|
github.com/luxfi/magnetar v1.2.3
|
||||||
github.com/luxfi/math v1.4.1
|
github.com/luxfi/math v1.4.1
|
||||||
github.com/luxfi/metric v1.5.8
|
github.com/luxfi/metric v1.5.8
|
||||||
github.com/luxfi/p2p v1.21.1
|
github.com/luxfi/p2p v1.21.1
|
||||||
github.com/luxfi/pulsar v1.8.0
|
github.com/luxfi/pulsar v1.9.0
|
||||||
github.com/luxfi/runtime v1.1.1
|
github.com/luxfi/runtime v1.1.1
|
||||||
github.com/luxfi/threshold v1.10.2
|
github.com/luxfi/threshold v1.12.0
|
||||||
github.com/luxfi/validators v1.2.0
|
github.com/luxfi/validators v1.2.0
|
||||||
github.com/luxfi/version v1.0.1
|
github.com/luxfi/version v1.0.1
|
||||||
github.com/luxfi/warp v1.19.3
|
github.com/luxfi/warp v1.24.0
|
||||||
github.com/luxfi/zap v0.8.1
|
github.com/luxfi/zap v0.8.1
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/prometheus/client_model v0.6.2
|
github.com/prometheus/client_model v0.6.2
|
||||||
|
|||||||
@@ -216,6 +216,8 @@ github.com/luxfi/geth v1.17.11 h1:opyVqYiH3t4qglAdMymudxKIO09O1LWB9tKGc57o9sA=
|
|||||||
github.com/luxfi/geth v1.17.11/go.mod h1:3vQfQJd9JC+AVBjxNXa9PYQOqpbE2dKu8E3jqhPZ3LU=
|
github.com/luxfi/geth v1.17.11/go.mod h1:3vQfQJd9JC+AVBjxNXa9PYQOqpbE2dKu8E3jqhPZ3LU=
|
||||||
github.com/luxfi/ids v1.2.15 h1:omE+E4+0Poj9DzM11ejSFgteaSQ3KDHi5g54iH6jcxI=
|
github.com/luxfi/ids v1.2.15 h1:omE+E4+0Poj9DzM11ejSFgteaSQ3KDHi5g54iH6jcxI=
|
||||||
github.com/luxfi/ids v1.2.15/go.mod h1:Fj73K5xcblvdE0SxU/ip+jE8VqNdu+80548su5KJ7xI=
|
github.com/luxfi/ids v1.2.15/go.mod h1:Fj73K5xcblvdE0SxU/ip+jE8VqNdu+80548su5KJ7xI=
|
||||||
|
github.com/luxfi/ids v1.3.0 h1:11xnwRDm6zQzbqcRnkFujOYkvhK4Fs/+g+sKRlRUNsU=
|
||||||
|
github.com/luxfi/ids v1.3.0/go.mod h1:6vpdcdZW0qxeade+3xby8aLTutbcJ7O0r8+fNQrksGI=
|
||||||
github.com/luxfi/lattice/v7 v7.1.4 h1:hQR02M6cHTAV5+joOPi9gb9Gm+z/hKJnhJF4IlciIJs=
|
github.com/luxfi/lattice/v7 v7.1.4 h1:hQR02M6cHTAV5+joOPi9gb9Gm+z/hKJnhJF4IlciIJs=
|
||||||
github.com/luxfi/lattice/v7 v7.1.4/go.mod h1:DmIQFi3mJiehVsR235l1NKYEU0JhU649OX5p7gMEW2c=
|
github.com/luxfi/lattice/v7 v7.1.4/go.mod h1:DmIQFi3mJiehVsR235l1NKYEU0JhU649OX5p7gMEW2c=
|
||||||
github.com/luxfi/lens v0.1.4 h1:goGjGDXx2BNdjzXDunL5QT8elK2ZyCcc0z8TAbtWYrg=
|
github.com/luxfi/lens v0.1.4 h1:goGjGDXx2BNdjzXDunL5QT8elK2ZyCcc0z8TAbtWYrg=
|
||||||
@@ -246,12 +248,16 @@ github.com/luxfi/pulsar v1.7.1 h1:q/TVn0TWeMImm67EJZ3CMmBAGQCW+bhIX7YQGL3aYK0=
|
|||||||
github.com/luxfi/pulsar v1.7.1/go.mod h1:wW+V2mUVOqppN68+n5u9Qtlfcscd0vOz4acvubcSOKI=
|
github.com/luxfi/pulsar v1.7.1/go.mod h1:wW+V2mUVOqppN68+n5u9Qtlfcscd0vOz4acvubcSOKI=
|
||||||
github.com/luxfi/pulsar v1.8.0 h1:ZWOwVQ8HcEJzWljj2blkNIRKwktngPJc05knkZtRjVM=
|
github.com/luxfi/pulsar v1.8.0 h1:ZWOwVQ8HcEJzWljj2blkNIRKwktngPJc05knkZtRjVM=
|
||||||
github.com/luxfi/pulsar v1.8.0/go.mod h1:wW+V2mUVOqppN68+n5u9Qtlfcscd0vOz4acvubcSOKI=
|
github.com/luxfi/pulsar v1.8.0/go.mod h1:wW+V2mUVOqppN68+n5u9Qtlfcscd0vOz4acvubcSOKI=
|
||||||
|
github.com/luxfi/pulsar v1.9.0 h1:c0JnatYF79aN87aof9VlYjIoCzmixxrgNPeUUuh8ScU=
|
||||||
|
github.com/luxfi/pulsar v1.9.0/go.mod h1:1+/atAiiiOm9RnXM3c66eHF3garjAa3C+sn4rAU7JUU=
|
||||||
github.com/luxfi/runtime v1.1.1 h1:vOMe82PL3bpSbslS7p69dKRpbr2qW2vOOAwej3UkkmU=
|
github.com/luxfi/runtime v1.1.1 h1:vOMe82PL3bpSbslS7p69dKRpbr2qW2vOOAwej3UkkmU=
|
||||||
github.com/luxfi/runtime v1.1.1/go.mod h1:fmG6+Zxj4wSNlXwiUfDthQDY+SfxVu6S0fX2lL6VbrE=
|
github.com/luxfi/runtime v1.1.1/go.mod h1:fmG6+Zxj4wSNlXwiUfDthQDY+SfxVu6S0fX2lL6VbrE=
|
||||||
github.com/luxfi/sampler v1.1.0 h1:u3iRDl7V06ARh0e85h3HT+aZ1saCFo2yMMsh+dCJbqk=
|
github.com/luxfi/sampler v1.1.0 h1:u3iRDl7V06ARh0e85h3HT+aZ1saCFo2yMMsh+dCJbqk=
|
||||||
github.com/luxfi/sampler v1.1.0/go.mod h1:kJa53S3tC9+VSbuV3RFu68MmbCCBlr2UM39LOClQ/Hs=
|
github.com/luxfi/sampler v1.1.0/go.mod h1:kJa53S3tC9+VSbuV3RFu68MmbCCBlr2UM39LOClQ/Hs=
|
||||||
github.com/luxfi/threshold v1.10.2 h1:FFj6H+wd3DI/Y5J1Jb3bClCAtFi/ueItWtBoNBIdIo4=
|
github.com/luxfi/threshold v1.10.2 h1:FFj6H+wd3DI/Y5J1Jb3bClCAtFi/ueItWtBoNBIdIo4=
|
||||||
github.com/luxfi/threshold v1.10.2/go.mod h1:GETt6Px9vrQ19J9Mnpf5LgMXAdvJu8ijyByOp+QtD48=
|
github.com/luxfi/threshold v1.10.2/go.mod h1:GETt6Px9vrQ19J9Mnpf5LgMXAdvJu8ijyByOp+QtD48=
|
||||||
|
github.com/luxfi/threshold v1.12.0 h1:JJ369xC/YyDvrqXj+xFoK98nP2rUM099qFs03hBvq/M=
|
||||||
|
github.com/luxfi/threshold v1.12.0/go.mod h1:iuRQGDAy8ZKjQhZjkSKg7NtbP75/8Up9zj52y7IuyZo=
|
||||||
github.com/luxfi/utils v1.2.0 h1:gtEiI7/NM6PQ/OasEpH0PvB+e5hIS/tpum9r64pYjMc=
|
github.com/luxfi/utils v1.2.0 h1:gtEiI7/NM6PQ/OasEpH0PvB+e5hIS/tpum9r64pYjMc=
|
||||||
github.com/luxfi/utils v1.2.0/go.mod h1:T2OCKT1xG9jtKR/gyJQoSkticzrE9WFQ8eohJHGu9Fg=
|
github.com/luxfi/utils v1.2.0/go.mod h1:T2OCKT1xG9jtKR/gyJQoSkticzrE9WFQ8eohJHGu9Fg=
|
||||||
github.com/luxfi/validators v1.2.0 h1:VygpiBqBAdGrfkb7xzE2yrVmnXaqE+hm8FLWdGXO7G8=
|
github.com/luxfi/validators v1.2.0 h1:VygpiBqBAdGrfkb7xzE2yrVmnXaqE+hm8FLWdGXO7G8=
|
||||||
@@ -262,6 +268,8 @@ github.com/luxfi/vm v1.2.3 h1:IdfB02OtDmdy/YNxaIR8DquPp5Aq0fi5QXBqGHoRkug=
|
|||||||
github.com/luxfi/vm v1.2.3/go.mod h1:ZXmw1sKA6GL3Ma3BfYUv5Fzvx3aWu2QMtkxF3lL/3R4=
|
github.com/luxfi/vm v1.2.3/go.mod h1:ZXmw1sKA6GL3Ma3BfYUv5Fzvx3aWu2QMtkxF3lL/3R4=
|
||||||
github.com/luxfi/warp v1.19.3 h1:tU6aAniYrPiRGrht7oFSwsUmk5KBb9RDFSyZq3lJ4HY=
|
github.com/luxfi/warp v1.19.3 h1:tU6aAniYrPiRGrht7oFSwsUmk5KBb9RDFSyZq3lJ4HY=
|
||||||
github.com/luxfi/warp v1.19.3/go.mod h1:kqyk7Fa5mgw1zzMLqjrZWVLuTef35CCW0gOEv10UlZk=
|
github.com/luxfi/warp v1.19.3/go.mod h1:kqyk7Fa5mgw1zzMLqjrZWVLuTef35CCW0gOEv10UlZk=
|
||||||
|
github.com/luxfi/warp v1.24.0 h1:jrcJNlbOiZsAEopJMy9bSaCwI5NDZ8qgp/6sNoXqepg=
|
||||||
|
github.com/luxfi/warp v1.24.0/go.mod h1:bKvTi24JHlANsl7qkWZAVr/DsMfvwy42f+Cc9x4+Sq8=
|
||||||
github.com/luxfi/zap v0.8.1 h1:IzYWf4T35QjsUJhoNbuhew8MDDRYmlZUVx4gKBNLjMA=
|
github.com/luxfi/zap v0.8.1 h1:IzYWf4T35QjsUJhoNbuhew8MDDRYmlZUVx4gKBNLjMA=
|
||||||
github.com/luxfi/zap v0.8.1/go.mod h1:gUoQ/V9nTLLIb4/n0bY/L2zQDYK6pDBG121rK4t8bpg=
|
github.com/luxfi/zap v0.8.1/go.mod h1:gUoQ/V9nTLLIb4/n0bY/L2zQDYK6pDBG121rK4t8bpg=
|
||||||
github.com/luxfi/zapdb v1.10.0 h1:1lLHEmkyC0BucnA/zjQYsMkUVxuEo2vQkEaQGjYfuuc=
|
github.com/luxfi/zapdb v1.10.0 h1:1lLHEmkyC0BucnA/zjQYsMkUVxuEo2vQkEaQGjYfuuc=
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
pulsarwire "github.com/luxfi/pulsar/ref/go/pkg/pulsar"
|
pulsarwire "github.com/luxfi/pulsar/pkg/pulsar"
|
||||||
coronaThreshold "github.com/luxfi/threshold/protocols/corona"
|
coronaThreshold "github.com/luxfi/threshold/protocols/corona"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import (
|
|||||||
|
|
||||||
"github.com/luxfi/crypto/bls"
|
"github.com/luxfi/crypto/bls"
|
||||||
magnetar "github.com/luxfi/magnetar/ref/go/pkg/magnetar"
|
magnetar "github.com/luxfi/magnetar/ref/go/pkg/magnetar"
|
||||||
pulsarwire "github.com/luxfi/pulsar/ref/go/pkg/pulsar"
|
pulsarwire "github.com/luxfi/pulsar/pkg/pulsar"
|
||||||
coronaThreshold "github.com/luxfi/threshold/protocols/corona"
|
coronaThreshold "github.com/luxfi/threshold/protocols/corona"
|
||||||
pulsar "github.com/luxfi/threshold/protocols/pulsar"
|
pulsar "github.com/luxfi/threshold/protocols/pulsar"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/luxfi/ids"
|
"github.com/luxfi/ids"
|
||||||
pulsarwire "github.com/luxfi/pulsar/ref/go/pkg/pulsar"
|
pulsarwire "github.com/luxfi/pulsar/pkg/pulsar"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mapResolver is a test CommitteeKeyResolver backed by a map. A committee with
|
// mapResolver is a test CommitteeKeyResolver backed by a map. A committee with
|
||||||
|
|||||||
Reference in New Issue
Block a user