mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
Merge branch 'fix/qcv2-canonical-finality'
This commit is contained in:
@@ -971,6 +971,14 @@ func (t *Transitive) Preference() ids.ID {
|
||||
return t.consensus.Preference()
|
||||
}
|
||||
|
||||
// PreferredBuildTip returns the deterministic build target — the deepest verified
|
||||
// block extending the finalized chain — so the VM builds on the convergent tip
|
||||
// (one block per height) instead of a competing sibling. See
|
||||
// ChainConsensus.PreferredBuildTip.
|
||||
func (t *Transitive) PreferredBuildTip() ids.ID {
|
||||
return t.consensus.PreferredBuildTip()
|
||||
}
|
||||
|
||||
// GetBlock handles a block request.
|
||||
func (t *Transitive) GetBlock(ctx context.Context, nodeID ids.NodeID, requestID uint32, blockID ids.ID) error {
|
||||
return nil
|
||||
|
||||
@@ -756,6 +756,20 @@ func (rt *Runtime) followVerifiedBlock(ctx context.Context, blk block.Block, fro
|
||||
// quorum. Drain after unlock — drainBufferedVotes takes the engine lock.
|
||||
rt.Transitive.drainBufferedVotes(blockID)
|
||||
|
||||
// CONVERGENCE (avalanchego snowman voter.go: SetPreference(Consensus.Preference())
|
||||
// after every poll): steer the inner VM to build on the engine's preferred BUILD
|
||||
// tip — the deepest verified block — now that this gossiped block is tracked. Without
|
||||
// it the VM keeps building on the last FINALIZED block, so when a proposer is down
|
||||
// every validator builds its own competing sibling at the same height, the α-of-K
|
||||
// votes split across the siblings, no cert assembles, and the chain HALTS. Steering
|
||||
// to the verified tip makes validators build H+1 on top of one verified block at H
|
||||
// and converge. Build hint only (Preference is not a finality decision); best effort.
|
||||
if rt.config.VM != nil {
|
||||
if tip := rt.Transitive.PreferredBuildTip(); tip != ids.Empty {
|
||||
_ = rt.config.VM.SetPreference(ctx, tip)
|
||||
}
|
||||
}
|
||||
|
||||
// Single-validator / no-signer engines do not gossip votes; nothing to do.
|
||||
if signer == nil || verifier == nil {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package chain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
// TestPreference_ConvergesSiblings_NoDownLeaderHalt proves the down-leader
|
||||
// liveness fix.
|
||||
//
|
||||
// THE BUG: when the slot-leader proposer is down, each healthy validator builds
|
||||
// its OWN block at the next height (its own clock → its own blkID). The α-of-K
|
||||
// finality cert needs alpha votes on ONE block, but the votes split across the
|
||||
// competing siblings, so no cert ever assembles and the chain HALTS. The root was
|
||||
// Preference() returning the FINALIZED tip — so the VM kept rebuilding the same
|
||||
// height instead of building on a verified-but-unfinalized block.
|
||||
//
|
||||
// THE FIX: Preference() returns the deterministic BUILD tip — the deepest verified
|
||||
// block extending the finalized chain, lowest-ID sibling per level — so every node
|
||||
// builds H+1 on the SAME block and the vote converges (matching avalanchego's
|
||||
// snowman preference, which steers the VM to the preferred non-finalized tip).
|
||||
func TestPreference_ConvergesSiblings_NoDownLeaderHalt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := NewChainConsensus(5, 4, 2)
|
||||
|
||||
// Finalized tip at height 36.
|
||||
fin := ids.ID{36}
|
||||
if err := c.AddBlock(ctx, &Block{id: fin, parentID: ids.Empty, height: 36}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.ledger = FinalityLedger{tip: fin, height: 36, set: true}
|
||||
|
||||
// Down-leader: two competing VERIFIED siblings at height 37.
|
||||
lo := ids.ID{37, 0x01} // lower ID
|
||||
hi := ids.ID{37, 0x02} // higher ID
|
||||
for _, b := range []*Block{
|
||||
{id: lo, parentID: fin, height: 37},
|
||||
{id: hi, parentID: fin, height: 37},
|
||||
} {
|
||||
if err := c.AddBlock(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// THE FIX: the build tip is the lowest-ID verified sibling, NOT the finalized
|
||||
// tip 36. Pre-fix this returned `fin`, so the VM rebuilt height 37 forever and
|
||||
// the chain halted on split votes.
|
||||
if got := c.PreferredBuildTip(); got != lo {
|
||||
t.Fatalf("Preference() = %s; want lowest-ID build tip %s (NOT finalized tip %s — that is the halt)", got, lo, fin)
|
||||
}
|
||||
|
||||
// Deterministic: same tree → same tip on every call, so ALL validators pick the
|
||||
// same block and their alpha votes land together.
|
||||
for i := 0; i < 8; i++ {
|
||||
if got := c.PreferredBuildTip(); got != lo {
|
||||
t.Fatalf("Preference() nondeterministic on call %d: %s != %s", i, got, lo)
|
||||
}
|
||||
}
|
||||
|
||||
// Build H+1 on the chosen sibling: preference follows the DEEPEST verified tip,
|
||||
// so the chain keeps extending while the cert for 37 is still being gathered.
|
||||
tip38 := ids.ID{38}
|
||||
if err := c.AddBlock(ctx, &Block{id: tip38, parentID: lo, height: 38}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := c.PreferredBuildTip(); got != tip38 {
|
||||
t.Fatalf("Preference() = %s; want deepest verified tip %s", got, tip38)
|
||||
}
|
||||
|
||||
// A REJECTED sibling — even with a lower ID — must never be chosen as the build
|
||||
// tip (a losing branch can't steer block production).
|
||||
rej := ids.ID{37, 0x00}
|
||||
if err := c.AddBlock(ctx, &Block{id: rej, parentID: fin, height: 37}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.blocks[rej].rejected = true
|
||||
if got := c.PreferredBuildTip(); got != tip38 {
|
||||
t.Fatalf("Preference() = %s; a rejected lower-ID sibling must be skipped (want %s)", got, tip38)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreference_NoChildren_ReturnsFinalizedTip confirms the fix is a strict
|
||||
// superset of the old behavior: with NO verified children, Preference() still
|
||||
// returns the finalized tip (the VM builds the next height on it) — so the change
|
||||
// only ADDS forward progress when a verified child exists, never regresses the
|
||||
// no-sibling case.
|
||||
func TestPreference_NoChildren_ReturnsFinalizedTip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := NewChainConsensus(5, 4, 2)
|
||||
fin := ids.ID{42}
|
||||
if err := c.AddBlock(ctx, &Block{id: fin, parentID: ids.Empty, height: 42}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.ledger = FinalityLedger{tip: fin, height: 42, set: true}
|
||||
if got := c.PreferredBuildTip(); got != fin {
|
||||
t.Fatalf("Preference() = %s; with no verified children want finalized tip %s", got, fin)
|
||||
}
|
||||
}
|
||||
@@ -232,7 +232,11 @@ func (c *ChainConsensus) IsRejected(blockID ids.ID) bool {
|
||||
return block.rejected
|
||||
}
|
||||
|
||||
// Preference returns current preferred block (avalanchego Topological.Preference).
|
||||
// Preference returns the FINALITY preference — the cert-selected/finalized tip
|
||||
// (avalanchego Topological.Preference). It stays at the finalized tip until a
|
||||
// quorum cert selects a child; a mere build-tip move does NOT advance it. This is
|
||||
// the finality-reporting concern and MUST NOT be conflated with the build target
|
||||
// (see PreferredBuildTip) — the conformance suite pins this contract.
|
||||
func (c *ChainConsensus) Preference() ids.ID {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
@@ -251,6 +255,72 @@ func (c *ChainConsensus) Preference() ids.ID {
|
||||
return ids.Empty
|
||||
}
|
||||
|
||||
// PreferredBuildTip returns the deterministic BUILD target — the deepest VERIFIED
|
||||
// block extending the finalized chain (NOT merely the finalized tip). The node
|
||||
// steers the VM to build its next block on THIS, so when a verified-but-unfinalized
|
||||
// block exists at height H every validator builds H+1 ON TOP of it and they
|
||||
// CONVERGE — instead of each building a competing sibling at height H, which splits
|
||||
// the α-of-K vote so no single block ever reaches the cert and the chain HALTS the
|
||||
// moment one proposer is down (the down-leader liveness bug). Sibling ties break on
|
||||
// lowest block ID so every node with the same block tree picks the SAME chain.
|
||||
//
|
||||
// This is the DECOMPLECTED build concern (separate from the finality Preference):
|
||||
// it is a BUILD hint only — finality stays governed exclusively by the α-of-K cert
|
||||
// folded into c.ledger (applyCertLocked), so advancing the build target past the
|
||||
// finalized tip touches no finality decision and cannot affect safety, only
|
||||
// liveness — exactly as avalanchego's snowman steers the VM to its preferred
|
||||
// non-finalized tip.
|
||||
func (c *ChainConsensus) PreferredBuildTip() ids.ID {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.buildTipLocked()
|
||||
}
|
||||
|
||||
// buildTipLocked descends from the finalized tip (or, before the first finalize,
|
||||
// the preliminary preference, else the lowest-ID tracked tip) through VERIFIED,
|
||||
// non-rejected children — choosing the lowest-ID child at each level — and returns
|
||||
// the first block with no such child: the deterministic build tip. The descent is
|
||||
// bounded by the tracked-block count so a malformed tree can never spin forever.
|
||||
// Caller holds c.mu.
|
||||
func (c *ChainConsensus) buildTipLocked() ids.ID {
|
||||
var cur ids.ID
|
||||
switch {
|
||||
case c.ledger.set:
|
||||
cur = c.ledger.tip
|
||||
case c.preference != ids.Empty:
|
||||
cur = c.preference
|
||||
default:
|
||||
// No finalized head and no preliminary preference yet: anchor on the
|
||||
// lowest-ID tracked tip so the choice is deterministic across nodes.
|
||||
for id := range c.tips {
|
||||
if cur == ids.Empty || id.Compare(cur) < 0 {
|
||||
cur = id
|
||||
}
|
||||
}
|
||||
if cur == ids.Empty {
|
||||
return ids.Empty
|
||||
}
|
||||
}
|
||||
anc := c.ancestry()
|
||||
for range c.blocks {
|
||||
best := ids.Empty
|
||||
for _, ch := range anc.Children(cur) {
|
||||
b, ok := c.blocks[ch]
|
||||
if !ok || b.rejected {
|
||||
continue
|
||||
}
|
||||
if best == ids.Empty || ch.Compare(best) < 0 {
|
||||
best = ch
|
||||
}
|
||||
}
|
||||
if best == ids.Empty {
|
||||
break // cur has no verified child — it is the build tip
|
||||
}
|
||||
cur = best
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
// GetBlock returns a block by ID
|
||||
func (c *ChainConsensus) GetBlock(blockID ids.ID) (*Block, bool) {
|
||||
c.mu.RLock()
|
||||
|
||||
@@ -12,7 +12,7 @@ require (
|
||||
github.com/luxfi/constants v1.5.8
|
||||
github.com/luxfi/crypto v1.19.26
|
||||
github.com/luxfi/database v1.19.3
|
||||
github.com/luxfi/dkg v0.3.0
|
||||
github.com/luxfi/dkg v0.3.5
|
||||
github.com/luxfi/ids v1.2.15
|
||||
github.com/luxfi/lattice/v7 v7.1.4
|
||||
github.com/luxfi/log v1.4.3
|
||||
@@ -20,7 +20,7 @@ require (
|
||||
github.com/luxfi/math v1.4.1
|
||||
github.com/luxfi/metric v1.5.8
|
||||
github.com/luxfi/p2p v1.21.1
|
||||
github.com/luxfi/pulsar v1.2.0
|
||||
github.com/luxfi/pulsar v1.8.0
|
||||
github.com/luxfi/runtime v1.1.1
|
||||
github.com/luxfi/threshold v1.10.2
|
||||
github.com/luxfi/validators v1.2.0
|
||||
@@ -104,12 +104,13 @@ require (
|
||||
github.com/luxfi/compress v0.0.5 // indirect
|
||||
github.com/luxfi/concurrent v0.0.3 // indirect
|
||||
github.com/luxfi/container v0.0.4 // indirect
|
||||
github.com/luxfi/corona v0.8.0 // indirect
|
||||
github.com/luxfi/corona v0.10.3 // indirect
|
||||
github.com/luxfi/crypto/ipa v1.2.4 // indirect
|
||||
github.com/luxfi/geth v1.17.11 // indirect
|
||||
github.com/luxfi/lens v0.1.4 // indirect
|
||||
github.com/luxfi/math/big v0.1.0 // indirect
|
||||
github.com/luxfi/mdns v0.1.1 // indirect
|
||||
github.com/luxfi/mlwe v0.2.1 // indirect
|
||||
github.com/luxfi/mock v0.1.1 // indirect
|
||||
github.com/luxfi/pq v1.0.3 // indirect
|
||||
github.com/luxfi/sampler v1.1.0 // indirect
|
||||
|
||||
@@ -198,6 +198,10 @@ github.com/luxfi/container v0.0.4 h1:BXhF82WyfqVP5mjlNcr7tP0Fcnvl0Ap1rkiu+rq5XuM
|
||||
github.com/luxfi/container v0.0.4/go.mod h1:Z3SpmMF5d4t77MM0nHYXURpn+EMVaeu1fhbd/3BGaek=
|
||||
github.com/luxfi/corona v0.8.0 h1:NwDafcJ6ySXdKmBbfch0hL83uTwu5NBedirOPnyhCMk=
|
||||
github.com/luxfi/corona v0.8.0/go.mod h1:SfS7xo/k4uoteEYwYy+QCMPzTU8EIEbLnbWKx5ENVCw=
|
||||
github.com/luxfi/corona v0.10.2 h1:pIS5uJq2lHnk6NFjoaBN8CvjPp9wwFHek91rV0u3jHk=
|
||||
github.com/luxfi/corona v0.10.2/go.mod h1:gx0nrRq4TB/scQm1kNU10lyRDQhKLMAYK1fIrfCrZ9s=
|
||||
github.com/luxfi/corona v0.10.3 h1:Yi1oAkW0HEsf5fvst/tUN0AjRVg6DoNHB/IC0qrFWZE=
|
||||
github.com/luxfi/corona v0.10.3/go.mod h1:xe5qRir0p+FA6eETpyGDv4LjYySg1zVB13kmHpy9x94=
|
||||
github.com/luxfi/crypto v1.19.26 h1:+aHn/L479ak2ih7s/DkBZojjuhcyHBLqu3nYT81vcrU=
|
||||
github.com/luxfi/crypto v1.19.26/go.mod h1:0DCU62kX8+zhYU2qeM07A4pifJyPkPujnUOfgc8TOFQ=
|
||||
github.com/luxfi/crypto/ipa v1.2.4 h1:6xfwhI9/HrcDkF3Ti5/NxsNQIWbwYDJmRSNIHRQ/xfU=
|
||||
@@ -206,6 +210,8 @@ github.com/luxfi/database v1.19.3 h1:flss7/VSGf29l1yZvPwazbtp87X5qfnZGPCU5ObqttQ
|
||||
github.com/luxfi/database v1.19.3/go.mod h1:S/LvmfzNYWVNslcEcZwDrntqUO2ksaL8ql1nRmLUA/Q=
|
||||
github.com/luxfi/dkg v0.3.0 h1:6CZS9GW/zPPSDVY6IrcYYhFBeZfjXI7cmLBI12a9ifM=
|
||||
github.com/luxfi/dkg v0.3.0/go.mod h1:M+WH7GFRN+YUD851Rlnumdp0Md98kplNN8pVx65U8I8=
|
||||
github.com/luxfi/dkg v0.3.5 h1:s2L2mMQaz+n9m0b0ghvoV5VZNxiwb2z4WrGugvK0udY=
|
||||
github.com/luxfi/dkg v0.3.5/go.mod h1:M+WH7GFRN+YUD851Rlnumdp0Md98kplNN8pVx65U8I8=
|
||||
github.com/luxfi/geth v1.17.11 h1:opyVqYiH3t4qglAdMymudxKIO09O1LWB9tKGc57o9sA=
|
||||
github.com/luxfi/geth v1.17.11/go.mod h1:3vQfQJd9JC+AVBjxNXa9PYQOqpbE2dKu8E3jqhPZ3LU=
|
||||
github.com/luxfi/ids v1.2.15 h1:omE+E4+0Poj9DzM11ejSFgteaSQ3KDHi5g54iH6jcxI=
|
||||
@@ -226,14 +232,20 @@ github.com/luxfi/mdns v0.1.1 h1:g2eRr9AXcziPkkcd24M+Qu9ApEpoKKjfI79QSNqv0rQ=
|
||||
github.com/luxfi/mdns v0.1.1/go.mod h1:dbp5f3h3aE7CGzwbaWzBM9cwdcekhmSrWhQevgYhhNA=
|
||||
github.com/luxfi/metric v1.5.8 h1:axPwfq+erOlIue7IJp5g+hMcMtVhYHja9gJAaT3+KNA=
|
||||
github.com/luxfi/metric v1.5.8/go.mod h1:fO2giazkg4NDtr72JM/QXJBYebplAMeWC1JoZyNDvKw=
|
||||
github.com/luxfi/mlwe v0.2.1 h1:pRwTjNUUtzUxRIlMbUPpeh9DE2/NdqfS17hfdogazp4=
|
||||
github.com/luxfi/mlwe v0.2.1/go.mod h1:DD9EHTeiyh/y0KGGeqL+q9S4n8raeGiGdaG/BQPAvT0=
|
||||
github.com/luxfi/mock v0.1.1 h1:0HEtIjg1J6CWz+IUyP6rsGqNWTcmxjFnSQIhaDuARwY=
|
||||
github.com/luxfi/mock v0.1.1/go.mod h1:jo35akl3Vtd8LbzDts8VJ0jmSVycrd1/eBi6g6t5hKU=
|
||||
github.com/luxfi/p2p v1.21.1 h1:gmz1JMDhzHIL3dQlhwIDvR4OlFuhNVfnWUl/ipYhAIo=
|
||||
github.com/luxfi/p2p v1.21.1/go.mod h1:SsNPR5fPGWWNem9plGWhSmRqyDoysJ3kPAN0zG0g3iw=
|
||||
github.com/luxfi/pq v1.0.3 h1:pFlQm1+5FuKTDUh2y/23bXWkN4I2Rc5iuxJypwDFFMs=
|
||||
github.com/luxfi/pq v1.0.3 h1:ksw1dmfTR0dqqNMRS7BjGcprCO2Fhc+3Iiq2/NMuONw=
|
||||
github.com/luxfi/pq v1.0.3/go.mod h1:8bppZcRElfrVt0n3nYCZW3iX1TvhvzNbdjNdK1irgIE=
|
||||
github.com/luxfi/pulsar v1.2.0 h1:cdJOoaRsCz/lLPyKQSLBFfXbJpqx2CQU/wXxqQeAXwA=
|
||||
github.com/luxfi/pulsar v1.2.0/go.mod h1:GPm+Q9ZdX604GE687vkBQhWNA1FOBvRbhYwhbbfdi2w=
|
||||
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.8.0 h1:ZWOwVQ8HcEJzWljj2blkNIRKwktngPJc05knkZtRjVM=
|
||||
github.com/luxfi/pulsar v1.8.0/go.mod h1:wW+V2mUVOqppN68+n5u9Qtlfcscd0vOz4acvubcSOKI=
|
||||
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/sampler v1.1.0 h1:u3iRDl7V06ARh0e85h3HT+aZ1saCFo2yMMsh+dCJbqk=
|
||||
|
||||
@@ -67,11 +67,26 @@ const (
|
||||
)
|
||||
|
||||
// ValidateMithrilSigningCommittee enforces 2 ≤ T ≤ N ≤ MithrilMaxCommittee for a
|
||||
// Pulsar dealerless-RSS signing committee, delegating the canonical check to
|
||||
// luxfi/dkg/rss so the bound has exactly one definition across the stack.
|
||||
// Fail-closed: an out-of-range committee is rejected, never silently resized to
|
||||
// a weaker mode.
|
||||
// Pulsar dealerless-RSS signing committee. Two layered gates, fail-closed:
|
||||
//
|
||||
// 1. The consensus POLICY cap N ≤ MithrilMaxCommittee (= rss.MaxParties = 6).
|
||||
// Lux deliberately operates SMALL signing committees (see the design note
|
||||
// above): the chain's security budget lives in Avalanche subsampling +
|
||||
// rotation + multi-committee certs, not in a large signing group. This cap
|
||||
// is the consensus operating point, not a crypto limit — luxfi/dkg/rss
|
||||
// itself admits any norm-viable committee up to MaxBitmaskParties = 63.
|
||||
// 2. The canonical crypto bound rss.ValidateCommittee (2 ≤ T ≤ N and
|
||||
// τ·C(N,N−T+1)·η < γ2) — the one definition of "stock-FIPS-204-signable",
|
||||
// never re-typed here (DRY).
|
||||
//
|
||||
// The policy cap is applied first and explicitly: rss loosened its own gate from
|
||||
// a hard N ≤ 6 to the norm bound (dkg ≥ v0.3.4), so consensus must assert its
|
||||
// small-committee operating point itself rather than free-ride on rss.
|
||||
// An out-of-range committee is rejected, never silently resized to a weaker mode.
|
||||
func ValidateMithrilSigningCommittee(t, n int) error {
|
||||
if n > MithrilMaxCommittee {
|
||||
return fmt.Errorf("quasar: Pulsar dealerless-RSS signing committee N=%d exceeds the consensus small-committee cap %d", n, MithrilMaxCommittee)
|
||||
}
|
||||
if err := rss.ValidateCommittee(t, n); err != nil {
|
||||
return fmt.Errorf("quasar: Pulsar dealerless-RSS signing committee: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user