Merge branch 'fix/qcv2-canonical-finality'

This commit is contained in:
2026-06-30 13:36:59 -07:00
7 changed files with 232 additions and 9 deletions
+8
View File
@@ -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
+14
View File
@@ -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
+103
View File
@@ -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)
}
}
+71 -1
View File
@@ -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()
+4 -3
View File
@@ -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
+13 -1
View File
@@ -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=
+19 -4
View File
@@ -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)
}