consensus(engine/chain): real alpha-of-K quorum-cert finality (no proposer self-finality)

THE BUG (CFT, not BFT): a proposer self-finalized its own block on its lone
self-vote (ForceAccept) and a peer's REJECT was counted as ACCEPT for the
proposer's own block (effectiveAccept = ... || IsOwnProposal). Followers
fast-followed unverified gossip and courtesy-voted only back to the proposer
(broken vote topology), so they could never reach threshold themselves. An
equivocating proposer could finalize two valid roots at one height -> fork.

THE FIX:
- QuorumCert (engine/chain/quorum_cert.go): the alpha-of-K finality witness.
  Assembles from alpha distinct correctly-signed ACCEPT votes over the
  canonical position; Verify enforces threshold + strictly-increasing-distinct
  voters + per-vote signature via a pluggable VoteVerifier. Wire codec for
  gossip (quorum_cert_codec.go). Quasar bridge (quorum_cert_quasar.go): upgrades
  to protocol/quasar.WeightedQuorumCert with real ML-DSA records + weighted
  Merkle inclusion when the node supplies the PQ validator set; fails closed
  otherwise. PROVEN: quasar's own Verify accepts the bridged cert.
- ForceAccept demoted to single-validator (K==1) only; refuses K>1 (fail-closed).
- effectiveAccept REJECT->ACCEPT flip deleted. Vote signatures verified at
  ingestion (multi-validator): forged/unsigned/wrong-position votes dropped, so
  the count, the cert, and IsAccepted stay consistent and forgery-proof.
- Topology rebuilt: followers broadcast SIGNED votes to ALL validators; whoever
  collects alpha assembles + gossips the cert; followers finalize on the
  verified cert (HandleIncomingCert) instead of fast-following. Liveness held:
  late/dropped Chits no longer freeze (cert gossip + re-request), no self-finality.
- Fail-closed DEX guard (quorum_guard.go): RequireQuorumFinalityForValueDEX
  refuses value DEX unless Mode()==QuorumFinality (K>1 + verifier).
- Start refuses K>1 without a verifier (forces the node bump to wire it).

Gates (all pass, race-clean): TestConsensus_NoProposerSelfFinality,
_AcceptRequiresQuorum, _EquivocatingProposerCannotFinalizeBothForks,
_PartitionMinorityCannotFinalize, TestDEX_BFTQuorumFinalizesFill,
_ByzantineProposerFakeFillRejected, TestLiveness_NoFreezeUnderLateChits,
_DroppedProposerChitsStillFinalizeViaPeerCert, TestQuasarBridge_RealCertVerifies.
194 engine/chain tests pass.
This commit is contained in:
2026-06-22 16:32:28 -07:00
parent 14619d99bc
commit 395eab9874
19 changed files with 3133 additions and 711 deletions
+2 -2
View File
@@ -546,7 +546,7 @@ func BenchmarkBlockAdditionTable(b *testing.B) {
// Benchmark GetBlock operation
func BenchmarkGetBlock(b *testing.B) {
engine := New()
engine := newTestEngine()
ctx := context.Background()
_ = engine.Start(ctx, true)
@@ -575,7 +575,7 @@ func BenchmarkBootstrapping(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
engine := New()
engine := newTestEngine()
ctx := context.Background()
// Start triggers bootstrapping
+23 -23
View File
@@ -427,7 +427,7 @@ func TestTransitiveAddBlock(t *testing.T) {
require := require.New(t)
ctx := context.Background()
engine := New()
engine := newTestEngine()
require.NoError(engine.Start(ctx, true))
defer engine.Stop(ctx)
@@ -450,7 +450,7 @@ func TestTransitiveProcessVote(t *testing.T) {
require := require.New(t)
ctx := context.Background()
engine := New()
engine := newTestEngine()
require.NoError(engine.Start(ctx, true))
defer engine.Stop(ctx)
@@ -474,7 +474,7 @@ func TestTransitivePoll(t *testing.T) {
require := require.New(t)
ctx := context.Background()
engine := New()
engine := newTestEngine()
require.NoError(engine.Start(ctx, true))
defer engine.Stop(ctx)
@@ -501,7 +501,7 @@ func TestTransitiveIsAccepted(t *testing.T) {
require := require.New(t)
ctx := context.Background()
engine := New()
engine := newTestEngine()
require.NoError(engine.Start(ctx, true))
defer engine.Stop(ctx)
@@ -515,7 +515,7 @@ func TestTransitivePreference(t *testing.T) {
require := require.New(t)
ctx := context.Background()
engine := New()
engine := newTestEngine()
require.NoError(engine.Start(ctx, true))
defer engine.Stop(ctx)
@@ -528,7 +528,7 @@ func TestTransitivePreference(t *testing.T) {
func TestTransitiveSetVM(t *testing.T) {
require := require.New(t)
engine := New()
engine := newTestEngine()
// Initially no VM
require.Nil(engine.vm)
@@ -549,7 +549,7 @@ func TestTransitiveNotifyPendingTxs(t *testing.T) {
require := require.New(t)
ctx := context.Background()
engine := New()
engine := newTestEngine()
require.NoError(engine.Start(ctx, true))
defer engine.Stop(ctx)
@@ -570,7 +570,7 @@ func TestTransitiveNotifyPendingTxsWithVM(t *testing.T) {
require := require.New(t)
ctx := context.Background()
engine := New()
engine := newTestEngine()
require.NoError(engine.Start(ctx, true))
defer engine.Stop(ctx)
@@ -596,7 +596,7 @@ func TestTransitiveNotifyPendingTxsWithVMError(t *testing.T) {
require := require.New(t)
ctx := context.Background()
engine := New()
engine := newTestEngine()
require.NoError(engine.Start(ctx, true))
defer engine.Stop(ctx)
@@ -622,7 +622,7 @@ func TestTransitiveNotifyStateSyncDone(t *testing.T) {
require := require.New(t)
ctx := context.Background()
engine := New()
engine := newTestEngine()
require.NoError(engine.Start(ctx, true))
defer engine.Stop(ctx)
@@ -637,7 +637,7 @@ func TestTransitiveNotifyUnknownType(t *testing.T) {
require := require.New(t)
ctx := context.Background()
engine := New()
engine := newTestEngine()
require.NoError(engine.Start(ctx, true))
defer engine.Stop(ctx)
@@ -651,7 +651,7 @@ func TestTransitiveNotifyUnknownType(t *testing.T) {
func TestTransitivePendingBuildBlocks(t *testing.T) {
require := require.New(t)
engine := New()
engine := newTestEngine()
// Initially zero
require.Zero(engine.PendingBuildBlocks())
@@ -669,7 +669,7 @@ func TestTransitiveBuildBlocksLockedNilVM(t *testing.T) {
require := require.New(t)
ctx := context.Background()
engine := New()
engine := newTestEngine()
// Set pending builds
engine.pendingBuildBlocks = 3
@@ -689,7 +689,7 @@ func TestTransitiveBuildBlocksLockedMultiple(t *testing.T) {
require := require.New(t)
ctx := context.Background()
engine := New()
engine := newTestEngine()
// Track build calls
buildCount := 0
@@ -716,7 +716,7 @@ func TestTransitiveBuildBlocksLockedPartialFailure(t *testing.T) {
require := require.New(t)
ctx := context.Background()
engine := New()
engine := newTestEngine()
// Fail after first build
buildCount := 0
@@ -755,7 +755,7 @@ func TestNewWithParams(t *testing.T) {
Beta: 10,
}
engine := NewWithParams(params)
engine := newTestEngineParams(params)
require.NotNil(engine)
require.NotNil(engine.consensus)
@@ -862,7 +862,7 @@ func TestTransitiveConcurrentNotify(t *testing.T) {
require := require.New(t)
ctx := context.Background()
engine := New()
engine := newTestEngine()
require.NoError(engine.Start(ctx, true))
defer engine.Stop(ctx)
@@ -929,7 +929,7 @@ func TestTransitiveStartStop(t *testing.T) {
require := require.New(t)
ctx := context.Background()
engine := New()
engine := newTestEngine()
// Multiple start/stop cycles
for i := 0; i < 5; i++ {
@@ -948,7 +948,7 @@ func TestTransitiveStopWithoutStart(t *testing.T) {
require := require.New(t)
ctx := context.Background()
engine := New()
engine := newTestEngine()
// Stop should be safe even without start
err := engine.Stop(ctx)
@@ -960,7 +960,7 @@ func TestTransitiveHealthCheckBeforeStart(t *testing.T) {
require := require.New(t)
ctx := context.Background()
engine := New()
engine := newTestEngine()
// Health check should work even before start
health, err := engine.HealthCheck(ctx)
@@ -977,7 +977,7 @@ func TestTransitiveHealthCheckAfterStart(t *testing.T) {
require := require.New(t)
ctx := context.Background()
engine := New()
engine := newTestEngine()
require.NoError(engine.Start(ctx, true))
defer engine.Stop(ctx)
@@ -1103,7 +1103,7 @@ func TestFullConsensusFlow(t *testing.T) {
AlphaConfidence: 1,
Beta: 1,
}
engine := NewWithParams(params)
engine := newTestEngineParams(params)
require.NoError(engine.Start(ctx, true))
defer engine.Stop(ctx)
@@ -1200,7 +1200,7 @@ func TestNotifyMultiplePendingTxs(t *testing.T) {
require := require.New(t)
ctx := context.Background()
engine := New()
engine := newTestEngine()
require.NoError(engine.Start(ctx, true))
defer engine.Stop(ctx)
+73 -13
View File
@@ -5,6 +5,7 @@ package chain
import (
"context"
"errors"
"fmt"
"sync"
@@ -12,6 +13,13 @@ import (
"github.com/luxfi/ids"
)
// ErrForceAcceptRequiresSingleValidator is returned by ForceAccept when invoked
// on a multi-validator engine (k != 1). Self-finality without α-of-K is only
// sound when the sole validator's accept IS the quorum; in every multi-
// validator deployment finality MUST flow through the cert-witnessed α-of-K
// path. Fail-closed.
var ErrForceAcceptRequiresSingleValidator = errors.New("chain: ForceAccept is only valid for a single-validator engine (k==1); multi-validator finality requires an alpha-of-K quorum cert")
// Block represents a block in the chain
type Block struct {
id ids.ID
@@ -294,31 +302,65 @@ func (c *ChainConsensus) ForcePreference(blockID ids.ID) {
c.tips[blockID] = true
}
// ForceAccept marks a block as accepted in the consensus engine without
// requiring the natural alpha-of-K quorum threshold. This is the consensus-
// level counterpart to ForcePreference, used by the proposer-self-accept
// path (engine.finalizeOwnProposal) to commit a self-proposed block locally
// when peer Chits do not arrive in a timely fashion.
// ForceAccept marks a block as accepted WITHOUT a quorum — it is the
// single-validator (K==1) finalization path and NOTHING ELSE.
//
// Safety: the caller must have already verified the block (via VMBlock.Verify)
// and must have IsOwnProposal=true in the pending entry. The engine's
// finalizeOwnProposal helper is the only authorized caller — direct use
// from elsewhere violates the alpha-quorum guarantee for follower-tracked
// blocks and is unsafe.
// HISTORY / WHY IT IS GATED: ForceAccept used to be the proposer-self-accept
// escape hatch — the proposer force-accepted its OWN block on its lone
// self-vote when peer Chits arrived late. That was self-finality: a value
// block could finalize with NO α-of-K agreement, so a Byzantine (or merely
// equivocating) proposer could fork the chain. It is REMOVED for K>1. With
// more than one validator, finality flows ONLY through the α-of-K path
// (ProcessVote / Poll set accepted=true once acceptVotes>=alpha) and is
// witnessed by a QuorumCert (quorum_cert.go) — there is no force path.
//
// For K==1 (a single-validator network, e.g. --dev / localnet) "α-of-K" is
// "1-of-1": the sole validator's own accept IS the quorum, so ForceAccept is
// the correct, safe finalization. The guard makes the abuse impossible to
// reach in any multi-validator deployment:
//
// returns ErrForceAcceptRequiresSingleValidator when k != 1 — fail-closed.
//
// Idempotent: subsequent calls on an already-accepted block no-op.
func (c *ChainConsensus) ForceAccept(blockID ids.ID) {
func (c *ChainConsensus) ForceAccept(blockID ids.ID) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.k != 1 {
return ErrForceAcceptRequiresSingleValidator
}
block, exists := c.blocks[blockID]
if !exists {
return
return nil
}
if block.accepted {
return
return nil
}
block.accepted = true
c.finalizedTip = blockID
return nil
}
// AcceptViaCert marks a block accepted because a verified QuorumCert proves
// α-of-K validators accepted it. This is the multi-validator finalization
// authority that replaces the deleted self-accept force path: the caller has
// ALREADY verified the cert (cert.Verify) against the chain's VoteVerifier, so
// this method records the decision the cert proves. It does not re-decide
// quorum — the cert IS the quorum proof.
//
// The block need not be locally tracked in c.blocks (a follower may finalize a
// block via cert before it ever entered local consensus tracking): when absent
// the finalized tip is still advanced so the engine's view matches the proven
// finality. Idempotent on an already-accepted tracked block.
func (c *ChainConsensus) AcceptViaCert(blockID ids.ID) {
c.mu.Lock()
defer c.mu.Unlock()
if block, exists := c.blocks[blockID]; exists {
if block.accepted {
return
}
block.accepted = true
}
c.finalizedTip = blockID
}
// GetFinalizedTip returns the current finalized tip block ID.
@@ -328,3 +370,21 @@ func (c *ChainConsensus) GetFinalizedTip() ids.ID {
defer c.mu.RUnlock()
return c.finalizedTip
}
// K returns the consensus sample size (the K of α-of-K). Used by the engine to
// select the single-validator (K==1) force path vs. the multi-validator
// cert-witnessed path, and by the fail-closed DEX guard.
func (c *ChainConsensus) K() int {
c.mu.RLock()
defer c.mu.RUnlock()
return c.k
}
// Alpha returns the acceptance quorum threshold (α of α-of-K). A block is
// accepted once acceptVotes >= Alpha; a QuorumCert proves exactly this many
// distinct signed accepts.
func (c *ChainConsensus) Alpha() int {
c.mu.RLock()
defer c.mu.RUnlock()
return c.alpha
}
+9 -9
View File
@@ -15,7 +15,7 @@ import (
func TestConsensusVoting(t *testing.T) {
require := require.New(t)
engine := New()
engine := newTestEngine()
ctx := context.Background()
require.NoError(engine.Start(ctx, true))
@@ -33,7 +33,7 @@ func TestConsensusVoting(t *testing.T) {
func TestConflictResolution(t *testing.T) {
require := require.New(t)
engine := New()
engine := newTestEngine()
ctx := context.Background()
require.NoError(engine.Start(ctx, true))
@@ -53,7 +53,7 @@ func TestConflictResolution(t *testing.T) {
func TestMultipleBlocks(t *testing.T) {
require := require.New(t)
engine := New()
engine := newTestEngine()
ctx := context.Background()
require.NoError(engine.Start(ctx, true))
@@ -79,7 +79,7 @@ func TestMultipleBlocks(t *testing.T) {
func TestChainReorg(t *testing.T) {
require := require.New(t)
engine := New()
engine := newTestEngine()
ctx := context.Background()
require.NoError(engine.Start(ctx, true))
@@ -102,7 +102,7 @@ func TestChainReorg(t *testing.T) {
func TestConcurrentRequests(t *testing.T) {
require := require.New(t)
engine := New()
engine := newTestEngine()
ctx := context.Background()
require.NoError(engine.Start(ctx, true))
@@ -132,7 +132,7 @@ func TestConcurrentRequests(t *testing.T) {
func TestFinality(t *testing.T) {
require := require.New(t)
engine := New()
engine := newTestEngine()
ctx := context.Background()
require.NoError(engine.Start(ctx, true))
@@ -153,7 +153,7 @@ func TestFinality(t *testing.T) {
func TestRestartPreservesState(t *testing.T) {
require := require.New(t)
engine := New()
engine := newTestEngine()
ctx := context.Background()
// First session
@@ -176,7 +176,7 @@ func TestRestartPreservesState(t *testing.T) {
func TestInvalidBlockHandling(t *testing.T) {
require := require.New(t)
engine := New()
engine := newTestEngine()
ctx := context.Background()
require.NoError(engine.Start(ctx, true))
@@ -196,7 +196,7 @@ func TestInvalidBlockHandling(t *testing.T) {
func TestHighLoadScenario(t *testing.T) {
require := require.New(t)
engine := New()
engine := newTestEngine()
ctx := context.Background()
require.NoError(engine.Start(ctx, true))
+34 -32
View File
@@ -25,7 +25,7 @@ func TestConfigValidate(t *testing.T) {
func TestWithProposer(t *testing.T) {
proposer := &mockProposer{}
engine := New(WithProposer(proposer))
engine := newTestEngine(WithProposer(proposer))
if engine.proposer == nil {
t.Error("WithProposer should set proposer")
}
@@ -33,14 +33,14 @@ func TestWithProposer(t *testing.T) {
func TestWithEmitter(t *testing.T) {
proposer := &mockProposer{}
engine := New(WithEmitter(proposer))
engine := newTestEngine(WithEmitter(proposer))
if engine.proposer == nil {
t.Error("WithEmitter should set proposer (alias)")
}
}
func TestWithLogger(t *testing.T) {
engine := New(WithLogger(nil))
engine := newTestEngine(WithLogger(nil))
// When nil is passed, the constructor should set log.Noop()
if engine.log == nil {
t.Error("log should not be nil even when WithLogger(nil)")
@@ -48,7 +48,7 @@ func TestWithLogger(t *testing.T) {
}
func TestWithVoteBuffers(t *testing.T) {
engine := New(WithVoteBuffers(50, 200))
engine := newTestEngine(WithVoteBuffers(50, 200))
if cap(engine.voteRequests) != 50 {
t.Errorf("expected voteRequests cap 50, got %d", cap(engine.voteRequests))
}
@@ -59,7 +59,7 @@ func TestWithVoteBuffers(t *testing.T) {
func TestWithVoteBuffersZero(t *testing.T) {
// Zero values should not create new channels (keep defaults)
engine := New(WithVoteBuffers(0, 0))
engine := newTestEngine(WithVoteBuffers(0, 0))
if engine.voteRequests == nil {
t.Error("voteRequests should not be nil")
}
@@ -71,7 +71,7 @@ func TestWithVoteBuffersZero(t *testing.T) {
func TestWithParams(t *testing.T) {
params := config.DefaultParams()
params.K = 42
engine := New(WithParams(params))
engine := newTestEngine(WithParams(params))
if engine.params.K != 42 {
t.Errorf("expected K=42, got %d", engine.params.K)
}
@@ -80,7 +80,7 @@ func TestWithParams(t *testing.T) {
func TestWithSlashing(t *testing.T) {
det := slashing.NewDetector(64, 0.5)
db := slashing.NewDB(10 * time.Minute)
engine := New(WithSlashing(det, db))
engine := newTestEngine(WithSlashing(det, db))
if engine.slashingDetector == nil {
t.Error("slashing detector should be set")
}
@@ -92,7 +92,7 @@ func TestWithSlashing(t *testing.T) {
// --- StartWithID / StopWithError / Context ---
func TestStartWithID(t *testing.T) {
engine := New()
engine := newTestEngine()
ctx := context.Background()
if err := engine.StartWithID(ctx, 1); err != nil {
t.Fatalf("StartWithID failed: %v", err)
@@ -104,7 +104,7 @@ func TestStartWithID(t *testing.T) {
}
func TestStopWithError(t *testing.T) {
engine := New()
engine := newTestEngine()
ctx := context.Background()
engine.Start(ctx, true)
@@ -118,7 +118,7 @@ func TestStopWithError(t *testing.T) {
}
func TestContextBeforeStart(t *testing.T) {
engine := New()
engine := newTestEngine()
ctx := engine.Context()
if ctx == nil {
t.Error("Context should return non-nil before Start")
@@ -126,7 +126,7 @@ func TestContextBeforeStart(t *testing.T) {
}
func TestContextAfterStart(t *testing.T) {
engine := New()
engine := newTestEngine()
engine.Start(context.Background(), true)
ctx := engine.Context()
if ctx == nil {
@@ -138,7 +138,7 @@ func TestContextAfterStart(t *testing.T) {
// --- SetEmitter ---
func TestSetEmitter(t *testing.T) {
engine := New()
engine := newTestEngine()
proposer := &mockProposer{}
engine.SetEmitter(proposer)
if engine.proposer == nil {
@@ -149,14 +149,14 @@ func TestSetEmitter(t *testing.T) {
// --- HasPendingBlock / GetPendingBlock ---
func TestHasPendingBlockEmpty(t *testing.T) {
engine := New()
engine := newTestEngine()
if engine.HasPendingBlock(ids.Empty) {
t.Error("should have no pending blocks initially")
}
}
func TestGetPendingBlockEmpty(t *testing.T) {
engine := New()
engine := newTestEngine()
blk, ok := engine.GetPendingBlock(ids.Empty)
if ok {
t.Error("should not find pending block")
@@ -167,7 +167,7 @@ func TestGetPendingBlockEmpty(t *testing.T) {
}
func TestGetPendingBlockNoVMBlock(t *testing.T) {
engine := New()
engine := newTestEngine()
blockID := ids.GenerateTestID()
engine.pendingBlocks[blockID] = &PendingBlock{
ConsensusBlock: &Block{id: blockID},
@@ -183,7 +183,7 @@ func TestGetPendingBlockNoVMBlock(t *testing.T) {
}
func TestGetPendingBlockWithVMBlock(t *testing.T) {
engine := New()
engine := newTestEngine()
blockID := ids.GenerateTestID()
mb := &mockBlock{id: blockID, height: 1}
engine.pendingBlocks[blockID] = &PendingBlock{
@@ -202,7 +202,7 @@ func TestGetPendingBlockWithVMBlock(t *testing.T) {
// --- ReceiveVote ---
func TestReceiveVoteNotStarted(t *testing.T) {
engine := New()
engine := newTestEngine()
vote := Vote{BlockID: ids.GenerateTestID(), Accept: true}
if engine.ReceiveVote(vote) {
t.Error("should return false when not started")
@@ -210,7 +210,7 @@ func TestReceiveVoteNotStarted(t *testing.T) {
}
func TestReceiveVoteStarted(t *testing.T) {
engine := New()
engine := newTestEngine()
engine.Start(context.Background(), true)
defer engine.Stop(context.Background())
@@ -221,7 +221,7 @@ func TestReceiveVoteStarted(t *testing.T) {
}
func TestReceiveVoteBufferFull(t *testing.T) {
engine := New(WithVoteBuffers(1, 1))
engine := newTestEngine(WithVoteBuffers(1, 1))
engine.Start(context.Background(), true)
defer engine.Stop(context.Background())
@@ -236,7 +236,7 @@ func TestReceiveVoteBufferFull(t *testing.T) {
// --- DrainAccepted ---
func TestDrainAcceptedNoBlocks(t *testing.T) {
engine := New()
engine := newTestEngine()
engine.Start(context.Background(), true)
defer engine.Stop(context.Background())
@@ -245,7 +245,7 @@ func TestDrainAcceptedNoBlocks(t *testing.T) {
}
func TestDrainAcceptedWithAcceptedBlock(t *testing.T) {
engine := New()
engine := newTestEngine()
ctx := context.Background()
engine.Start(ctx, true)
defer engine.Stop(ctx)
@@ -283,7 +283,7 @@ func TestDrainAcceptedWithAcceptedBlock(t *testing.T) {
// --- SyncState ---
func TestSyncState(t *testing.T) {
engine := New()
engine := newTestEngine()
ctx := context.Background()
engine.Start(ctx, true)
defer engine.Stop(ctx)
@@ -320,7 +320,7 @@ func TestSyncState(t *testing.T) {
// --- Stats ---
func TestStats(t *testing.T) {
engine := New()
engine := newTestEngine()
stats := engine.Stats()
expectedKeys := []string{"blocks_built", "blocks_accepted", "blocks_rejected",
@@ -354,7 +354,7 @@ func TestNewWithConfigBurstMode(t *testing.T) {
func TestHandleVoteWithSlashing(t *testing.T) {
det := slashing.NewDetector(64, 0.5)
db := slashing.NewDB(10 * time.Minute)
engine := New(WithSlashing(det, db))
engine := newTestEngine(WithSlashing(det, db))
ctx := context.Background()
engine.Start(ctx, true)
defer engine.Stop(ctx)
@@ -378,7 +378,7 @@ func TestHandleVoteWithSlashing(t *testing.T) {
}
func TestHandleVoteUnknownBlock(t *testing.T) {
engine := New()
engine := newTestEngine()
ctx := context.Background()
engine.Start(ctx, true)
defer engine.Stop(ctx)
@@ -391,7 +391,7 @@ func TestHandleVoteUnknownBlock(t *testing.T) {
}
func TestHandleVoteReject(t *testing.T) {
engine := New()
engine := newTestEngine()
ctx := context.Background()
engine.Start(ctx, true)
defer engine.Stop(ctx)
@@ -405,7 +405,9 @@ func TestHandleVoteReject(t *testing.T) {
VMBlock: &mockBlock{id: blockID},
}
engine.handleVote(Vote{BlockID: blockID, Accept: false})
// A SIGNED reject from a validator counts toward RejectCount (an unsigned
// reject is dropped by the authenticated quorum path).
engine.handleVote(signedRejectForEngine(engine, blockID, ids.GenerateTestNodeID()))
// RejectCount should be incremented
engine.mu.RLock()
p := engine.pendingBlocks[blockID]
@@ -418,7 +420,7 @@ func TestHandleVoteReject(t *testing.T) {
// --- CheckBlockProposal ---
func TestCheckBlockProposalNoSlashing(t *testing.T) {
engine := New()
engine := newTestEngine()
ev := engine.CheckBlockProposal(ids.GenerateTestNodeID(), 1, ids.GenerateTestID(), nil)
if ev != nil {
t.Error("should return nil when slashing not configured")
@@ -428,7 +430,7 @@ func TestCheckBlockProposalNoSlashing(t *testing.T) {
func TestCheckBlockProposalJailed(t *testing.T) {
det := slashing.NewDetector(64, 0.5)
db := slashing.NewDB(10 * time.Minute)
engine := New(WithSlashing(det, db))
engine := newTestEngine(WithSlashing(det, db))
nodeID := ids.GenerateTestNodeID()
db.RecordEvidence(slashing.Evidence{
@@ -444,13 +446,13 @@ func TestCheckBlockProposalJailed(t *testing.T) {
}
func TestSlashingDB(t *testing.T) {
engine := New()
engine := newTestEngine()
if engine.SlashingDB() != nil {
t.Error("should return nil when not configured")
}
db := slashing.NewDB(10 * time.Minute)
engine2 := New(WithSlashing(slashing.NewDetector(64, 0.5), db))
engine2 := newTestEngine(WithSlashing(slashing.NewDetector(64, 0.5), db))
if engine2.SlashingDB() == nil {
t.Error("should return DB when configured")
}
@@ -459,7 +461,7 @@ func TestSlashingDB(t *testing.T) {
// --- Double start ---
func TestDoubleStart(t *testing.T) {
engine := New()
engine := newTestEngine()
ctx := context.Background()
engine.Start(ctx, true)
defer engine.Stop(ctx)
+351 -63
View File
@@ -21,6 +21,12 @@ import (
var (
ErrNotStarted = errors.New("engine not started")
ErrAlreadyStarted = errors.New("engine already started")
// ErrQuorumVerifierRequired is returned by Start when a multi-validator
// engine (K>1) is started without a VoteVerifier. Multi-validator finality
// MUST be gated on a verifiable α-of-K quorum cert; without a verifier
// there is no way to tell a real quorum from forged votes. Fail-closed.
ErrQuorumVerifierRequired = errors.New("chain: multi-validator engine (K>1) requires a vote verifier for quorum-cert finality (use WithQuorumCert / WithVoteVerifier)")
)
// -----------------------------------------------------------------------------
@@ -100,6 +106,21 @@ type Vote struct {
NodeID ids.NodeID
Accept bool
SignedAt time.Time
// Signature is the validator's signature over CanonicalVoteMessage of the
// block's position (ChainID, Height, Round, BlockID, ParentID). It is the
// material the engine collects into a QuorumCert — the portable, verifiable
// α-of-K finality witness. May be empty for a single-validator (K==1)
// engine, where the sole validator's local accept is the quorum and no cert
// is gossiped; MUST be present and valid for multi-validator finality.
Signature []byte
// ParentID binds the parent into the vote's signed position. Carried so the
// engine can rebuild CanonicalVoteMessage when assembling a cert even if it
// is not separately tracking the block's parent.
ParentID ids.ID
// Round is the consensus round the vote was cast in (0 for the first round
// at a height). Bound into the signed position.
Round uint32
}
// PendingBlock tracks a block awaiting consensus.
@@ -111,6 +132,19 @@ type PendingBlock struct {
RejectCount int // Reject votes
Decided bool
// certVotes collects the distinct SIGNED accept votes observed for this
// block, keyed by voter NodeID (de-dup: one vote per validator). When the
// count reaches alpha the engine assembles a QuorumCert from these — the
// α-of-K finality witness. Empty for single-validator (K==1) finality.
certVotes map[ids.NodeID]SignedVote
// Round is the consensus round the block was proposed in. Bound into every
// vote's signed position so a cert binds the exact round.
Round uint32
// cert is the assembled+verified finality witness once the quorum is
// reached (nil until then). Retained so the engine can re-gossip it on
// request and so a follower's accept is gated on holding it.
cert *QuorumCert
// IsOwnProposal is true when this node built and proposed the block.
// Used to short-circuit the proposer-self-accept gap: when peers send back
// a vote message after fast-follow acceptance, the proposer's local re-Verify
@@ -201,6 +235,36 @@ func WithLogger(l log.Logger) Option {
}
}
// WithQuorumCert wires multi-validator α-of-K cert-witnessed finality. The node
// supplies a VoteVerifier (mandatory for cert finality — verifies every vote
// signature and every incoming cert), and optionally a CertGossiper (to
// distribute assembled certs) and a VoteSigner (to sign this node's own votes).
// chainID and nodeID identify this node's position for vote/cert binding.
//
// Without this option the engine runs in single-validator (K==1) mode: the sole
// validator's local accept is the quorum, finality uses the ForceAccept path,
// and no certs or signatures are produced. The engine REFUSES to start a
// multi-validator (K>1) configuration without a verifier (fail-closed) — see
// Start.
func WithQuorumCert(chainID ids.ID, nodeID ids.NodeID, verifier VoteVerifier, gossiper CertGossiper, signer VoteSigner) Option {
return func(t *Transitive) {
t.chainID = chainID
t.nodeID = nodeID
t.voteVerifier = verifier
t.certGossiper = gossiper
t.voteSigner = signer
}
}
// WithVoteVerifier sets only the vote/cert signature verifier. Convenience for
// callers that verify but neither sign nor gossip (e.g. a verifying-only node
// or a test).
func WithVoteVerifier(verifier VoteVerifier) Option {
return func(t *Transitive) {
t.voteVerifier = verifier
}
}
// WithVoteBuffers sets channel buffer sizes.
func WithVoteBuffers(requests, votes int) Option {
return func(t *Transitive) {
@@ -267,10 +331,53 @@ type Transitive struct {
slashingDetector *slashing.Detector
slashingDB *slashing.DB
// Quorum-cert finality (multi-validator). These are the engine's sole
// dependencies for α-of-K cert-witnessed finality:
//
// - voteVerifier verifies each collected vote's signature before it is
// counted toward a cert and verifies an incoming cert's signatures.
// The node injects a real scheme (BLS / ML-DSA / secp256k1). When nil,
// the engine is in single-validator (K==1) mode and finality uses the
// local-accept force path (no cert, no signatures).
// - certGossiper re-broadcasts an assembled cert to all validators so
// followers can finalize on a verifiable α-of-K proof rather than
// fast-following an unverified block. Optional (nil disables cert
// gossip; finality still holds locally via the α-of-K count).
// - voteSigner signs this node's own accept votes (used when it votes as
// a follower, so its signature can be collected into a cert). Optional.
// - chainID / nodeID identify this node's position for vote/cert binding.
voteVerifier VoteVerifier
certGossiper CertGossiper
voteSigner VoteSigner
chainID ids.ID
nodeID ids.NodeID
// Logger for consensus events (nil-safe: uses log.Noop() if unset)
log log.Logger
}
// CertGossiper broadcasts an assembled finality cert to validators. The node
// supplies the network implementation; the engine expresses WHAT (gossip this
// proof of α-of-K finality), the node decides HOW. Optional — a nil gossiper
// means the proposer finalizes locally on the α-of-K count without distributing
// the cert (followers then reach finality via their own collected votes once
// the topology gossips votes to all).
type CertGossiper interface {
// GossipCert broadcasts the encoded finality cert for blockID to validators.
GossipCert(chainID ids.ID, blockID ids.ID, certBytes []byte) error
}
// VoteSigner signs this node's accept vote over the canonical vote message so
// the signature can be collected into a QuorumCert. Backed by the node's
// validator key (the same key the VoteVerifier checks against). Optional: a
// single-validator engine does not gossip votes and needs no signer.
type VoteSigner interface {
// SignVote returns this node's signature over message (the canonical vote
// message for a position). The returned bytes are what a peer's
// VoteVerifier will verify.
SignVote(message []byte) ([]byte, error)
}
// New creates an engine with default parameters.
// Apply options to configure dependencies.
//
@@ -347,6 +454,15 @@ func (t *Transitive) Start(ctx context.Context, _ bool) error {
return ErrAlreadyStarted
}
// FAIL-CLOSED: a multi-validator engine (K>1) MUST have a vote verifier so
// finality can be gated on a verifiable α-of-K quorum cert. Starting K>1
// without one would leave no way to distinguish a real quorum from forged
// votes — exactly the hole this change closes. A single-validator engine
// (K==1) needs no verifier: its own accept is the quorum.
if t.params.K > 1 && t.voteVerifier == nil {
return ErrQuorumVerifierRequired
}
t.ctx, t.cancel = context.WithCancel(ctx)
t.bootstrapped = true
t.started = true
@@ -841,86 +957,244 @@ func (t *Transitive) handleVote(vote Vote) {
}
}
// Determine the effective accept signal. For own-proposed pending blocks
// the engine trusts mere peer-vote arrival as positive evidence (see
// PendingBlock.IsOwnProposal). This closes the proposer-self-accept gap:
// when a peer sends back a vote via the fast-follow SendVote callback
// (integration.go:498-500), it only does so after locally accepting the
// block. The proposer's local re-Verify in the network adapter may
// spuriously fail and flip the vote to Reject, which would otherwise
// drive the proposer's own block to rejected even though the cluster
// committed it. Counting peer participation here keeps the proposer's
// view in sync with cluster consensus without weakening alpha-of-K
// (the count still requires distinct peer messages).
effectiveAccept := vote.Accept || pending.IsOwnProposal
// AUTHENTICATE THE VOTE (multi-validator). A vote is counted toward the
// quorum ONLY if its signature verifies over the block's position AND the
// vote's own decision (accept→accept-message, reject→reject-message). This:
// - makes consensus.acceptVotes count only REAL validator accepts, so
// IsAccepted() is truthful (it cannot be inflated by forged/unsigned
// votes) and stays in lock-step with the assemblable cert;
// - blocks forged-accept finality (an outsider key, or a real validator's
// signature lifted from a DIFFERENT position/decision, fails here);
// - blocks forged-reject censorship (an unauthenticated reject is dropped,
// strictly safer than the prior unauthenticated reject path).
//
// The former `effectiveAccept = vote.Accept || IsOwnProposal` REJECT→ACCEPT
// flip is DELETED: vote.Accept is authoritative once the signature checks
// out. Single-validator engines (no verifier) skip authentication — the sole
// validator's self-vote is the quorum and carries no signature.
if t.voteVerifier != nil {
pos := t.blockPositionLocked(pending, vote.BlockID)
msg := canonicalVoteMessageFor(pos, vote.Accept)
if len(vote.Signature) == 0 || !t.voteVerifier.VerifyVote(vote.NodeID, msg, vote.Signature) {
// Unsigned or invalid: not a real vote from this validator at this
// position/decision. Drop it — count nothing.
t.mu.Unlock()
return
}
}
accept := vote.Accept
var voteCount int
if effectiveAccept {
if accept {
pending.VoteCount++
voteCount = pending.VoteCount
// Record the signed accept vote toward this block's quorum cert so the
// engine can assemble + gossip the α-of-K witness once the threshold is
// reached. (Reject votes are not certifiable — a finality cert proves
// acceptance — they only drive the rejection path.)
t.recordCertVoteLocked(pending, vote)
} else {
pending.RejectCount++
}
t.mu.Unlock()
if err := t.consensus.ProcessVote(ctx, vote.BlockID, effectiveAccept); err != nil {
if err := t.consensus.ProcessVote(ctx, vote.BlockID, accept); err != nil {
return
}
_ = t.consensus.Poll(ctx, map[ids.ID]int{vote.BlockID: voteCount})
if effectiveAccept {
responses := map[ids.ID]int{vote.BlockID: voteCount}
_ = t.consensus.Poll(ctx, responses)
} else {
// Trigger poll to check for rejection quorum
_ = t.consensus.Poll(ctx, map[ids.ID]int{vote.BlockID: voteCount})
}
// Finalize: if consensus accepted this block, call VM.Accept() and update state.
// Finalize: if consensus reached the α-of-K accept quorum, assemble the
// cert, gossip it, and call VM.Accept().
t.tryFinalizeBlock(ctx, vote.BlockID)
}
// tryFinalizeBlock checks if consensus has accepted a block and calls VM.Accept().
func (t *Transitive) tryFinalizeBlock(ctx context.Context, blockID ids.ID) {
accepted := t.consensus.IsAccepted(blockID)
if !accepted {
// recordCertVoteLocked records a distinct SIGNED accept vote toward this
// block's quorum cert. Caller holds t.mu. A vote with no signature is ignored
// for cert purposes (it still counts toward the plain accept tally in
// handleVote) — only signed votes can witness a cert. Verification of the
// signature happens at assembly time (assembleCertLocked) so a single bad
// signature cannot poison the map; de-dup is by NodeID.
func (t *Transitive) recordCertVoteLocked(pending *PendingBlock, vote Vote) {
if len(vote.Signature) == 0 {
return
}
if pending.certVotes == nil {
pending.certVotes = make(map[ids.NodeID]SignedVote)
}
pending.certVotes[vote.NodeID] = SignedVote{
NodeID: vote.NodeID,
Accept: true,
Signature: append([]byte(nil), vote.Signature...),
}
}
// recordOwnVoteLocked signs THIS node's accept vote for blockID and records it
// into the block's cert set. Caller holds t.mu. No-op when no voteSigner is
// configured (single-validator / K==1 finality needs no cert). The proposer
// (and any node casting its own accept locally) is one of the α signers, so its
// signature belongs in the cert.
func (t *Transitive) recordOwnVoteLocked(pending *PendingBlock, blockID ids.ID) {
if t.voteSigner == nil {
return
}
pos := t.blockPositionLocked(pending, blockID)
sig, err := t.voteSigner.SignVote(CanonicalVoteMessage(pos))
if err != nil {
t.log.Warn("failed to sign own accept vote for cert", "blockID", blockID, "error", err)
return
}
t.recordCertVoteLocked(pending, Vote{
BlockID: blockID,
NodeID: t.nodeID,
Accept: true,
Signature: sig,
ParentID: pos.ParentID,
Round: pos.Round,
})
}
// blockPositionLocked returns the consensus position a block's votes/cert bind
// to. Caller holds t.mu.
func (t *Transitive) blockPositionLocked(pending *PendingBlock, blockID ids.ID) VotePosition {
var parentID ids.ID
var height uint64
if pending.ConsensusBlock != nil {
parentID = pending.ConsensusBlock.parentID
height = pending.ConsensusBlock.height
}
return VotePosition{
ChainID: t.chainID,
Height: height,
Round: pending.Round,
BlockID: blockID,
ParentID: parentID,
}
}
// assembleCertLocked attempts to assemble a verified QuorumCert from the signed
// accept votes collected for blockID. Caller holds t.mu. Returns the cert (and
// caches it on pending) iff:
// - a vote verifier is configured (multi-validator finality), AND
// - at least alpha distinct votes verify under it.
//
// Each collected vote's signature is verified here; votes that fail are dropped
// from the candidate set so one forged vote cannot block a real quorum, and the
// cert is only built from VERIFIED votes — Assemble + the subsequent Verify
// then re-check distinctness and the threshold. Returns nil if the verified
// quorum is not yet present (the proposer keeps waiting / re-requesting — this
// is the liveness path, NOT a force).
func (t *Transitive) assembleCertLocked(pending *PendingBlock, blockID ids.ID) *QuorumCert {
if pending.cert != nil {
return pending.cert
}
if t.voteVerifier == nil {
return nil
}
alpha := t.consensus.Alpha()
if alpha <= 0 {
return nil
}
pos := t.blockPositionLocked(pending, blockID)
message := CanonicalVoteMessage(pos)
verified := make([]SignedVote, 0, len(pending.certVotes))
for _, sv := range pending.certVotes {
if t.voteVerifier.VerifyVote(sv.NodeID, message, sv.Signature) {
verified = append(verified, sv)
}
}
if uint32(len(verified)) < uint32(alpha) {
return nil
}
cert, err := AssembleQuorumCert(pos, uint32(alpha), verified)
if err != nil {
return nil
}
// Defence in depth: the cert we just built must verify under our own
// verifier before we treat it as a finality witness (catches any assembly
// invariant drift). Assemble already enforced distinctness + threshold.
if err := cert.Verify(t.voteVerifier); err != nil {
return nil
}
pending.cert = cert
return cert
}
// tryFinalizeBlock finalizes a block once the α-of-K accept quorum is reached.
//
// Multi-validator (K>1): finality requires a verified QuorumCert. consensus
// .IsAccepted only flips true once acceptVotes>=alpha (the α-of-K count), so
// reaching it means alpha distinct accepts arrived. We assemble the cert from
// the collected SIGNED votes as the portable witness, GOSSIP it so followers
// finalize on a verifiable proof (not a fast-follow guess), then commit. If the
// count says accepted but we cannot yet assemble a verified cert (signatures
// still in flight), we WAIT — we never force. This is what makes the change
// BFT: no value finalizes without a verifiable α-of-K witness.
//
// Single-validator (K==1): there are no peer signatures; IsAccepted reflects
// the sole validator's own accept, which IS the 1-of-1 quorum. Commit directly.
func (t *Transitive) tryFinalizeBlock(ctx context.Context, blockID ids.ID) {
if !t.consensus.IsAccepted(blockID) {
return
}
t.mu.Lock()
pending, exists := t.pendingBlocks[blockID]
if !exists || pending.Decided {
t.mu.Unlock()
return
}
multiValidator := t.consensus.K() > 1
var cert *QuorumCert
var certBytes []byte
if multiValidator {
cert = t.assembleCertLocked(pending, blockID)
if cert == nil {
// Quorum count reached but no verified cert yet — wait for signed
// votes. Do NOT finalize: BFT safety requires the witness.
t.mu.Unlock()
return
}
if b, err := cert.MarshalBinary(); err == nil {
certBytes = b
}
}
chainID := t.chainID
gossiper := t.certGossiper
t.mu.Unlock()
// Distribute the finality proof so followers can finalize on it. Best
// effort: local finality is already established by the verified cert.
if multiValidator && gossiper != nil && certBytes != nil {
_ = gossiper.GossipCert(chainID, blockID, certBytes)
}
t.finalizePendingLocked(ctx, blockID)
}
// finalizeOwnProposal commits a self-proposed block to local VM state without
// waiting for peer Chits to drive consensus.IsAccepted across the alpha threshold.
// finalizeOwnProposal commits a self-proposed block once finality is legitimate.
//
// This closes the proposer-self-accept gap empirically observed on lux-devnet
// v1.27.26 even after the v1.25.1 IsOwnProposal handleVote trust path: peer
// Chits arrive intermittently (or not at all in some network conditions) on
// the proposer, leaving acceptVotes pinned at the proposer's lone self-vote
// (count=1) below alpha. The proposer's PendingBlock stays decided=false
// forever; VM.Accept is never called; the node freezes at its proposal height.
// THE FREEZE THIS USED TO "FIX" — AND HOW IT IS NOW FIXED WITHOUT SELF-FINALITY:
//
// Safety analysis:
// The old version FORCE-ACCEPTED the proposer's own block on its lone self-vote
// (consensus.ForceAccept) because peer Chits arrived late/dropped, pinning
// acceptVotes at 1 < alpha and freezing the node. That was self-finality — a
// value could finalize with NO α-of-K agreement, so an equivocating proposer
// could fork the chain. DELETED for K>1.
//
// - The block was already verified locally before AddBlock (engine.go:1001).
// A successful Verify is the proposer's commitment that the block is
// well-formed against its current view of state.
// - The proposer self-voted (consensus.ProcessVote true) at proposal time,
// so consensus.acceptVotes >= 1. We additionally force consensus to mark
// this block accepted via ForcePreference, keeping the consensus engine
// and VM in sync.
// - Cluster authoritative finality is unaffected: peers run their own Verify
// and independently decide. If peers reject, the proposer's local commit
// diverges from cluster — but the cluster's BLS+Pulsar finality witness
// is the canonical record, and slashing evidence flags the proposer.
// - Under honest-validator + deterministic-Verify assumption (devnet, all
// real production deployments), peer agreement is structurally
// guaranteed when the proposer's Verify passes.
// - Idempotent under concurrent invocation via pending.Decided and the
// delete(pendingBlocks) guard in finalizePendingLocked.
// The freeze is now solved STRUCTURALLY by the vote-distribution topology
// (integration.go): followers gossip their SIGNED accept votes to ALL
// validators (not only back to the proposer), and the proposer assembles +
// gossips the cert. So the proposer collects alpha distinct signed votes and
// finalizes via the cert (tryFinalizeBlock) — late/dropped Chits are handled by
// re-request + cert gossip + the poll timeout, NOT by self-finalizing. If the
// quorum genuinely is not present, the node correctly does NOT finalize (a real
// minority cannot and must not finalize).
//
// This is a tightly scoped escape hatch: only fires for IsOwnProposal=true
// entries, only after a successful proposal gossip. Follower-tracked entries
// (HandleIncomingBlock slow path) keep IsOwnProposal=false and still require
// the full alpha-quorum signal.
// Here we only do two things, both fail-closed:
// - K==1: ForceAccept (1-of-1 quorum: the sole validator's accept is final).
// - K>1: re-run tryFinalizeBlock, which finalizes IFF a verified cert exists.
// Never forces.
func (t *Transitive) finalizeOwnProposal(ctx context.Context, blockID ids.ID) {
t.mu.RLock()
pending, exists := t.pendingBlocks[blockID]
@@ -929,12 +1203,20 @@ func (t *Transitive) finalizeOwnProposal(ctx context.Context, blockID ids.ID) {
return
}
// Force consensus to mark this block accepted so its view stays in sync
// with the VM. Without this, consensus.IsAccepted continues to return
// false while the VM has already committed — causing IsAccepted-gated
// callers (processPendingBlocks, downstream tip queries) to diverge.
t.consensus.ForceAccept(blockID)
t.finalizePendingLocked(ctx, blockID)
if t.consensus.K() == 1 {
// Single-validator: the sole validator's accept IS the quorum. Force is
// the correct 1-of-1 finalization (ForceAccept refuses K>1).
if err := t.consensus.ForceAccept(blockID); err != nil {
t.log.Crit("ForceAccept refused — engine misconfigured (K>1 reached single-validator path)",
"blockID", blockID, "error", err)
return
}
t.finalizePendingLocked(ctx, blockID)
return
}
// Multi-validator: finalize only via a verified α-of-K cert. No force.
t.tryFinalizeBlock(ctx, blockID)
}
// finalizePendingLocked is the shared finalization path used by both
@@ -1090,7 +1372,7 @@ func (t *Transitive) buildBlocksLocked(ctx context.Context) error {
}
t.mu.Lock()
} else {
t.pendingBlocks[vmBlock.ID()] = &PendingBlock{
pb := &PendingBlock{
ConsensusBlock: consensusBlock,
VMBlock: vmBlock,
ProposedAt: time.Now(),
@@ -1098,6 +1380,12 @@ func (t *Transitive) buildBlocksLocked(ctx context.Context) error {
Decided: false,
IsOwnProposal: true,
}
t.pendingBlocks[vmBlock.ID()] = pb
// The proposer is one of the α signers: record its OWN signed accept
// vote into the cert set so the assembled cert includes it (its
// ProcessVote above counted it toward acceptVotes; this puts the
// matching SIGNED record in certVotes so count and cert agree).
t.recordOwnVoteLocked(pb, vmBlock.ID())
}
// Gossip the block + request peer votes. These calls are done while
+46 -57
View File
@@ -35,7 +35,7 @@ func (b *mockBlock) Reject(context.Context) error { return nil }
func (b *mockBlock) Bytes() []byte { return b.bytes }
func TestNew(t *testing.T) {
engine := New()
engine := newTestEngine()
if engine == nil {
t.Fatal("New() returned nil")
}
@@ -46,7 +46,7 @@ func TestNew(t *testing.T) {
}
func TestStart(t *testing.T) {
engine := New()
engine := newTestEngine()
ctx := context.Background()
err := engine.Start(ctx, true)
@@ -60,7 +60,7 @@ func TestStart(t *testing.T) {
}
func TestStop(t *testing.T) {
engine := New()
engine := newTestEngine()
ctx := context.Background()
_ = engine.Start(ctx, true)
@@ -72,7 +72,7 @@ func TestStop(t *testing.T) {
}
func TestHealthCheck(t *testing.T) {
engine := New()
engine := newTestEngine()
ctx := context.Background()
health, err := engine.HealthCheck(ctx)
@@ -99,7 +99,7 @@ func TestHealthCheck(t *testing.T) {
}
func TestGetBlock(t *testing.T) {
engine := New()
engine := newTestEngine()
ctx := context.Background()
// GetBlock should return nil (no-op for now)
@@ -114,7 +114,7 @@ func TestGetBlock(t *testing.T) {
}
func TestChainWorkflow(t *testing.T) {
engine := New()
engine := newTestEngine()
ctx := context.Background()
// Start engine
@@ -195,7 +195,7 @@ func (m *mockVM) SetPreference(ctx context.Context, id ids.ID) error {
// TestNotifyPendingTxsTriggersBuildBlock verifies that Notify(PendingTxs) triggers BuildBlock
func TestNotifyPendingTxsTriggersBuildBlock(t *testing.T) {
engine := New()
engine := newTestEngine()
ctx := context.Background()
// Start engine
@@ -246,7 +246,7 @@ func TestNotifyPendingTxsTriggersBuildBlock(t *testing.T) {
// TestNotifyStateSyncDoneDoesNotBuild verifies that StateSyncDone doesn't trigger builds
func TestNotifyStateSyncDoneDoesNotBuild(t *testing.T) {
engine := New()
engine := newTestEngine()
ctx := context.Background()
if err := engine.Start(ctx, true); err != nil {
@@ -270,7 +270,7 @@ func TestNotifyStateSyncDoneDoesNotBuild(t *testing.T) {
// TestNotifyWithNoVMDoesNotPanic verifies Notify handles nil VM gracefully
func TestNotifyWithNoVMDoesNotPanic(t *testing.T) {
engine := New()
engine := newTestEngine()
ctx := context.Background()
if err := engine.Start(ctx, true); err != nil {
@@ -288,7 +288,7 @@ func TestNotifyWithNoVMDoesNotPanic(t *testing.T) {
// TestNotifyBuildBlockError verifies error handling when BuildBlock fails
func TestNotifyBuildBlockError(t *testing.T) {
engine := New()
engine := newTestEngine()
ctx := context.Background()
if err := engine.Start(ctx, true); err != nil {
@@ -388,7 +388,7 @@ func (m *trackingMockVM) SetPreference(ctx context.Context, id ids.ID) error {
// TestEngine_DoesNotAcceptWithoutQuorum verifies blocks are NOT accepted without sufficient votes
func TestEngine_DoesNotAcceptWithoutQuorum(t *testing.T) {
engine := New()
engine := newTestEngine()
ctx := context.Background()
// Create a tracking block
@@ -454,7 +454,7 @@ func TestEngine_DoesNotAcceptWithoutQuorum(t *testing.T) {
func TestEngine_AcceptsAfterQuorum(t *testing.T) {
// Use smaller parameters for testing (K=5, Alpha=3, Beta=2)
// This means we need 3 out of 5 votes for quorum
engine := NewWithParams(config.Parameters{
engine := newTestEngineParams(config.Parameters{
K: 5,
AlphaPreference: 3,
AlphaConfidence: 3,
@@ -495,14 +495,12 @@ func TestEngine_AcceptsAfterQuorum(t *testing.T) {
t.Fatal("Block should be in pending blocks")
}
// Send enough votes to reach quorum (3 votes for Alpha=3)
// Send enough SIGNED votes to reach quorum (3 votes for Alpha=3). Distinct
// validators, each signing the block's position — the cert assembles and the
// block finalizes. (Unsigned votes are dropped by the authenticated quorum
// path; this is the BFT finality gate.)
for i := 0; i < 4; i++ {
engine.ReceiveVote(Vote{
BlockID: blk.id,
NodeID: ids.GenerateTestNodeID(),
Accept: true,
SignedAt: time.Now(),
})
engine.ReceiveVote(signedVoteForEngine(engine, blk.id, ids.GenerateTestNodeID()))
}
// Give poll loop time to process the acceptance
@@ -530,7 +528,7 @@ func TestEngine_AcceptsAfterQuorum(t *testing.T) {
// TestReceiveVote_DropsWhenNotStarted verifies votes are dropped when engine not started
func TestReceiveVote_DropsWhenNotStarted(t *testing.T) {
engine := New()
engine := newTestEngine()
// DO NOT start the engine
vote := Vote{
@@ -549,7 +547,7 @@ func TestReceiveVote_DropsWhenNotStarted(t *testing.T) {
// TestReceiveVote_AcceptsWhenStarted verifies votes are queued when engine is started
func TestReceiveVote_AcceptsWhenStarted(t *testing.T) {
engine := New()
engine := newTestEngine()
ctx := context.Background()
if err := engine.Start(ctx, true); err != nil {
@@ -573,7 +571,7 @@ func TestReceiveVote_AcceptsWhenStarted(t *testing.T) {
// TestReceiveVote_DropsAfterStop verifies votes are dropped after engine stops
func TestReceiveVote_DropsAfterStop(t *testing.T) {
engine := New()
engine := newTestEngine()
ctx := context.Background()
if err := engine.Start(ctx, true); err != nil {
@@ -605,7 +603,7 @@ func TestReceiveVote_DropsAfterStop(t *testing.T) {
// TestHandleVote_IgnoresUnknownBlocks verifies votes for untracked blocks are ignored
func TestHandleVote_IgnoresUnknownBlocks(t *testing.T) {
engine := New()
engine := newTestEngine()
ctx := context.Background()
if err := engine.Start(ctx, true); err != nil {
@@ -644,7 +642,7 @@ func TestHandleVote_IgnoresUnknownBlocks(t *testing.T) {
// TestHandleVote_ProcessesKnownBlocks verifies votes for tracked blocks are processed
func TestHandleVote_ProcessesKnownBlocks(t *testing.T) {
engine := NewWithParams(config.Parameters{
engine := newTestEngineParams(config.Parameters{
K: 5,
AlphaPreference: 3,
AlphaConfidence: 3,
@@ -682,13 +680,8 @@ func TestHandleVote_ProcessesKnownBlocks(t *testing.T) {
}
initialVoteCount := pending.VoteCount
// Send vote for known block
engine.ReceiveVote(Vote{
BlockID: blk.id,
NodeID: ids.GenerateTestNodeID(),
Accept: true,
SignedAt: time.Now(),
})
// Send a SIGNED vote for the known block (authenticated quorum path).
engine.ReceiveVote(signedVoteForEngine(engine, blk.id, ids.GenerateTestNodeID()))
// Give handler time to process
time.Sleep(100 * time.Millisecond)
@@ -716,7 +709,7 @@ func TestHandleVote_ProcessesKnownBlocks(t *testing.T) {
// TestProcessVote_AcceptTrueIncrementsSupport verifies Accept=true votes count toward acceptance
func TestProcessVote_AcceptTrueIncrementsSupport(t *testing.T) {
engine := NewWithParams(config.Parameters{
engine := newTestEngineParams(config.Parameters{
K: 3,
AlphaPreference: 2,
AlphaConfidence: 2,
@@ -745,14 +738,9 @@ func TestProcessVote_AcceptTrueIncrementsSupport(t *testing.T) {
t.Fatalf("Notify failed: %v", err)
}
// Send Accept=true votes to reach quorum
// Send SIGNED Accept votes to reach quorum (authenticated quorum path).
for i := 0; i < 3; i++ {
engine.ReceiveVote(Vote{
BlockID: blk.id,
NodeID: ids.GenerateTestNodeID(),
Accept: true,
SignedAt: time.Now(),
})
engine.ReceiveVote(signedVoteForEngine(engine, blk.id, ids.GenerateTestNodeID()))
}
// Wait for acceptance
@@ -782,7 +770,7 @@ func TestProcessVote_AcceptTrueIncrementsSupport(t *testing.T) {
// path) so the assertion is meaningful again.
func TestProcessVote_AcceptFalseDoesNotAccept(t *testing.T) {
engine := NewWithParams(config.Parameters{
engine := newTestEngineParams(config.Parameters{
K: 3,
AlphaPreference: 2,
AlphaConfidence: 2,
@@ -859,7 +847,7 @@ func TestProcessVote_AcceptFalseDoesNotAccept(t *testing.T) {
func TestEngine_RejectsWithInsufficientSupport(t *testing.T) {
// Use smaller parameters (K=5, Alpha=3, Beta=2)
engine := NewWithParams(config.Parameters{
engine := newTestEngineParams(config.Parameters{
K: 5,
AlphaPreference: 3,
AlphaConfidence: 3,
@@ -933,7 +921,7 @@ func TestEngine_RejectsWithInsufficientSupport(t *testing.T) {
// TestSyncState_UpdatesConsensusState verifies SyncState correctly updates consensus.
// This is critical for recovering from admin_importChain RLP imports.
func TestSyncState_UpdatesConsensusState(t *testing.T) {
engine := New()
engine := newTestEngine()
ctx := context.Background()
// Before sync, bootstrapped is false (engine not started)
@@ -962,7 +950,7 @@ func TestSyncState_UpdatesConsensusState(t *testing.T) {
// TestSyncState_ClearsStalePendingBlocks verifies SyncState removes stale blocks.
func TestSyncState_ClearsStalePendingBlocks(t *testing.T) {
engine := New()
engine := newTestEngine()
ctx := context.Background()
// Add some pending blocks at various heights
@@ -1003,7 +991,7 @@ func TestSyncState_ClearsStalePendingBlocks(t *testing.T) {
// TestSyncState_Idempotent verifies calling SyncState multiple times is safe.
func TestSyncState_Idempotent(t *testing.T) {
engine := New()
engine := newTestEngine()
ctx := context.Background()
blockID1 := ids.GenerateTestID()
@@ -1035,7 +1023,7 @@ func TestSyncState_Idempotent(t *testing.T) {
// TestSyncState_WithEmptyID verifies SyncState handles empty block ID.
func TestSyncState_WithEmptyID(t *testing.T) {
engine := New()
engine := newTestEngine()
ctx := context.Background()
// Sync with empty ID (genesis state)
@@ -1228,7 +1216,7 @@ func TestBurstThroughput(t *testing.T) {
func TestSlashing_DoubleVoteDropped(t *testing.T) {
detector := slashing.NewDetector(64, 0.5)
db := slashing.NewDB(1 * time.Hour)
engine := New(WithSlashing(detector, db))
engine := newTestEngine(WithSlashing(detector, db))
ctx := context.Background()
_ = engine.Start(ctx, true)
@@ -1260,7 +1248,7 @@ func TestSlashing_DoubleVoteDropped(t *testing.T) {
nodeA[0] = 0x01
// First vote: nodeA votes for blkID at height 100 -- should succeed
engine.ReceiveVote(Vote{BlockID: blkID, NodeID: nodeA, Accept: true})
engine.ReceiveVote(signedVoteForEngine(engine, blkID, nodeA))
time.Sleep(50 * time.Millisecond) // let vote handler process
engine.mu.RLock()
@@ -1276,7 +1264,8 @@ func TestSlashing_DoubleVoteDropped(t *testing.T) {
}
// Second vote: nodeA votes for blkID2 at height 100 -- equivocation, should be dropped
engine.ReceiveVote(Vote{BlockID: blkID2, NodeID: nodeA, Accept: true})
// (by the slashing detector, which runs before signature verification).
engine.ReceiveVote(signedVoteForEngine(engine, blkID2, nodeA))
time.Sleep(50 * time.Millisecond)
engine.mu.RLock()
@@ -1307,7 +1296,7 @@ func TestSlashing_DoubleVoteDropped(t *testing.T) {
func TestSlashing_JailedVotesDropped(t *testing.T) {
detector := slashing.NewDetector(64, 0.5)
db := slashing.NewDB(1 * time.Hour)
engine := New(WithSlashing(detector, db))
engine := newTestEngine(WithSlashing(detector, db))
ctx := context.Background()
_ = engine.Start(ctx, true)
@@ -1358,7 +1347,7 @@ func TestSlashing_JailedVotesDropped(t *testing.T) {
func TestSlashing_CheckBlockProposal(t *testing.T) {
detector := slashing.NewDetector(64, 0.5)
db := slashing.NewDB(1 * time.Hour)
engine := New(WithSlashing(detector, db))
engine := newTestEngine(WithSlashing(detector, db))
var nodeA ids.NodeID
nodeA[0] = 0x01
@@ -1396,7 +1385,7 @@ func TestSlashing_CheckBlockProposal(t *testing.T) {
func TestSlashing_Disabled(t *testing.T) {
// Without slashing option, everything works normally
engine := New()
engine := newTestEngine()
ctx := context.Background()
_ = engine.Start(ctx, true)
@@ -1415,7 +1404,7 @@ func TestSlashing_Disabled(t *testing.T) {
var nodeA ids.NodeID
nodeA[0] = 0x01
engine.ReceiveVote(Vote{BlockID: blkID, NodeID: nodeA, Accept: true})
engine.ReceiveVote(signedVoteForEngine(engine, blkID, nodeA))
time.Sleep(50 * time.Millisecond)
engine.mu.RLock()
@@ -1439,14 +1428,14 @@ func TestSlashing_Disabled(t *testing.T) {
func TestSlashing_SlashingDBAccessor(t *testing.T) {
db := slashing.NewDB(1 * time.Hour)
engine := New(WithSlashing(slashing.NewDetector(64, 0.5), db))
engine := newTestEngine(WithSlashing(slashing.NewDetector(64, 0.5), db))
got := engine.SlashingDB()
if got != db {
t.Fatal("SlashingDB() should return the configured DB")
}
engine2 := New()
engine2 := newTestEngine()
if engine2.SlashingDB() != nil {
t.Fatal("SlashingDB() should return nil when slashing disabled")
}
@@ -1507,7 +1496,7 @@ func (m *failingBuildVM) SetPreference(_ context.Context, id ids.ID) error {
// must not decrement pendingBuildBlocks so the build is retried.
func TestBuildBlockError_DoesNotDecrementPending(t *testing.T) {
vm := &failingBuildVM{failCount: 1}
eng := New(WithVM(vm))
eng := newTestEngine(WithVM(vm))
if err := eng.Start(context.Background(), true); err != nil {
t.Fatalf("Start: %v", err)
@@ -1534,7 +1523,7 @@ func TestBuildBlockError_DoesNotDecrementPending(t *testing.T) {
// error, the next Notify successfully builds the block (retry).
func TestBuildBlockError_RetriesOnNextNotify(t *testing.T) {
vm := &failingBuildVM{failCount: 1}
eng := New(WithVM(vm))
eng := newTestEngine(WithVM(vm))
if err := eng.Start(context.Background(), true); err != nil {
t.Fatalf("Start: %v", err)
@@ -1571,7 +1560,7 @@ func TestBuildBlockError_RetriesOnNextNotify(t *testing.T) {
// retains the counter, so future Notifys always attempt to build.
func TestBuildBlockError_RepeatedFailures_NoPermanentHalt(t *testing.T) {
vm := &failingBuildVM{failCount: 100} // fails 100 times
eng := New(WithVM(vm))
eng := newTestEngine(WithVM(vm))
if err := eng.Start(context.Background(), true); err != nil {
t.Fatalf("Start: %v", err)
+5 -4
View File
@@ -295,9 +295,10 @@ func TestOnImportComplete_AfterStart(t *testing.T) {
vm.blocks[genesisID] = genesisBlock
rt := NewRuntime(NetworkConfig{
ChainID: ids.GenerateTestID(),
NetworkID: ids.GenerateTestID(),
VM: vm,
ChainID: ids.GenerateTestID(),
NetworkID: ids.GenerateTestID(),
VM: vm,
VoteVerifier: testAuth, // multi-validator engine requires a verifier
})
// Start the engine
@@ -339,7 +340,7 @@ func TestSyncStateFromVM(t *testing.T) {
vm.lastAcceptedID = importedBlockID
vm.blocks[importedBlockID] = importedBlock
engine := New()
engine := newTestEngine()
blockID, height, err := SyncStateFromVM(ctx, vm, engine)
require.NoError(err)
+154 -102
View File
@@ -46,6 +46,21 @@ type NetworkConfig struct {
// For small validator sets (e.g., 5 nodes), use LocalParams() which has
// K=5, Beta=4 - appropriate thresholds for the validator count.
Params *config.Parameters
// Quorum-cert finality (multi-validator). The node supplies these to enable
// α-of-K cert-witnessed finality:
// - VoteVerifier MUST be non-nil for any K>1 chain (the engine refuses to
// Start a multi-validator engine without it). Verifies vote + cert
// signatures against the chain's validator key set.
// - VoteSigner signs THIS node's accept votes (so its signature joins the
// cert when it votes). Required for this node to contribute to a cert.
// - CryptoWitnessSource (optional) upgrades engine certs to
// quasar.WeightedQuorumCert when the chain's PQ weighted validator set is
// plumbed. nil keeps the engine-level cert as the witness.
//
// For a single-validator chain (K==1, e.g. --dev) leave these nil.
VoteVerifier VoteVerifier
VoteSigner VoteSigner
}
// Gossiper abstracts the network layer for consensus message broadcasting.
@@ -72,6 +87,29 @@ type Gossiper interface {
SendVote(chainID ids.ID, toNodeID ids.NodeID, blockID ids.ID) error
}
// QuorumGossiper is the vote/cert distribution topology required for α-of-K
// finality. It is the STRUCTURAL fix for the proposer-freeze: under the old
// SendVote-to-proposer-only topology a follower's vote reached only the
// proposer, so if the proposer's own Chits dropped it pinned below alpha and
// froze (which is why self-finality was bolted on). Here followers broadcast
// their SIGNED votes to ALL validators and any node that collects alpha
// distinct signed votes assembles + gossips the cert — so finality no longer
// depends on one node's inbound Chits.
//
// A Gossiper that does not implement QuorumGossiper runs in legacy single-
// validator / no-cert mode (K==1). Multi-validator finality requires it.
type QuorumGossiper interface {
// BroadcastVote sends this node's SIGNED accept vote for blockID to ALL
// validators on the network (not just the proposer). voteBytes is the
// encoded signed vote (node id + signature over the canonical vote
// message). Returns the number of validators reached.
BroadcastVote(chainID ids.ID, networkID ids.ID, blockID ids.ID, voteBytes []byte) int
// GossipCert broadcasts an assembled finality cert (encoded
// 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
}
// Runtime wraps Transitive with network integration and VM notification handling.
// Use NewRuntime to create - this is the "one right way" to set up consensus.
type Runtime struct {
@@ -121,6 +159,22 @@ func NewRuntime(cfg NetworkConfig) *Runtime {
}
engine := NewWithParams(params)
// Wire α-of-K quorum-cert finality for multi-validator chains. The engine
// refuses to Start a K>1 engine without a verifier (fail-closed), so a
// production multi-validator chain MUST supply cfg.VoteVerifier. The cert
// gossiper bridges the network Gossiper's cert-distribution to the engine.
if cfg.VoteVerifier != nil {
var certGossiper CertGossiper
if cfg.Gossiper != nil {
certGossiper = &gossiperCertBridge{
gossiper: cfg.Gossiper,
chainID: cfg.ChainID,
networkID: cfg.NetworkID,
}
}
WithQuorumCert(cfg.ChainID, cfg.NodeID, cfg.VoteVerifier, certGossiper, cfg.VoteSigner)(engine)
}
rt := &Runtime{
Transitive: engine,
config: cfg,
@@ -265,6 +319,26 @@ func (rt *Runtime) drainAcceptedBlocks(ctx context.Context) {
rt.Transitive.DrainAccepted(ctx)
}
// gossiperCertBridge adapts the engine's CertGossiper (GossipCert(chainID,
// blockID, bytes)) to the network Gossiper's QuorumGossiper.GossipCert
// (chainID, networkID, blockID, bytes). If the configured Gossiper does not
// implement QuorumGossiper, cert gossip is a no-op (the engine still finalizes
// locally on the verified cert; followers reach finality via their own votes).
type gossiperCertBridge struct {
gossiper Gossiper
chainID ids.ID
networkID ids.ID
}
var _ CertGossiper = (*gossiperCertBridge)(nil)
func (b *gossiperCertBridge) GossipCert(_ ids.ID, blockID ids.ID, certBytes []byte) error {
if qg, ok := b.gossiper.(QuorumGossiper); ok {
qg.GossipCert(b.chainID, b.networkID, blockID, certBytes)
}
return nil
}
// gossiperProposer adapts a Gossiper to the BlockProposer interface.
// This bridges the network layer (Gossiper) to the consensus layer (BlockProposer).
type gossiperProposer struct {
@@ -425,33 +499,31 @@ func (rt *Runtime) HandleIncomingBlock(ctx context.Context, blockData []byte, fr
log.Stringer("from", fromNodeID))
}
// Fast-follow pattern: Accept blocks that extend our chain from valid proposers
// This is safe because:
// 1. Block has been verified (cryptographic integrity, state transitions)
// 2. Block came from a known validator (fromNodeID)
// 3. Block extends our accepted chain (parent check below)
// QUORUM-GATED FOLLOW (replaces the old unverified fast-follow Accept):
//
// The mutex serializes fast-follow acceptance to prevent concurrent
// Accept() calls from duplicate gossip delivery of the same block.
// A follower MUST NOT Accept a gossiped block on mere arrival — that was the
// follower-side half of the self-finality hole (an equivocating proposer
// could get followers to commit two different blocks at one height). Instead
// the follower:
// 1. has already VERIFIED the block (above),
// 2. tracks it for consensus and casts its OWN signed accept vote,
// 3. BROADCASTS that signed vote to ALL validators (topology fix), and
// 4. Accepts ONLY when it holds a verified α-of-K QuorumCert
// (handleIncomingCert, or a cert it assembles from gossiped votes).
//
// This both closes the fork (no commit without α-of-K witness) AND keeps
// liveness (votes reach every validator, so finality does not hinge on one
// node's inbound Chits; the proposer-freeze cannot recur).
blockID := blk.ID()
rt.fastFollowMu.Lock()
defer rt.fastFollowMu.Unlock()
// Height-based fast-follow: accept verified gossip blocks that advance
// beyond our tracked height. This is robust because:
// 1. Blocks are already cryptographically verified before reaching here
// 2. The sender is a known validator
// 3. Height ordering prevents re-accepting old blocks or going backwards
// 4. The mutex prevents concurrent acceptance races
//
// We use height instead of parent-ID matching because VM.LastAccepted()
// is asynchronous and block-producing nodes don't update the tracker.
incomingHeight := blk.Height()
// Dedup: skip blocks at or below our tracked height
// Dedup: skip blocks at or below our tracked finalized height.
if incomingHeight <= rt.fastFollowHeight {
if !rt.config.Logger.IsZero() {
rt.config.Logger.Info("fast-follow: block at/below tracked height, skipping",
rt.config.Logger.Debug("follow: block at/below tracked height, skipping",
log.Stringer("blockID", blockID),
log.Uint64("incomingHeight", incomingHeight),
log.Uint64("trackedHeight", rt.fastFollowHeight))
@@ -459,110 +531,90 @@ func (rt *Runtime) HandleIncomingBlock(ctx context.Context, blockData []byte, fr
return blk, nil
}
if !rt.config.Logger.IsZero() {
rt.config.Logger.Info("fast-follow: accepting block from validator",
log.Stringer("blockID", blockID),
log.Uint64("height", incomingHeight),
log.Stringer("from", fromNodeID),
log.Uint64("trackedHeight", rt.fastFollowHeight))
}
// Track the verified block in consensus + pending and record OUR signed
// accept vote toward its cert. castAndBroadcastVote returns after the vote
// is queued for all validators. The block is NOT accepted here — finality
// awaits the cert.
rt.followVerifiedBlock(ctx, blk, fromNodeID)
return blk, nil
}
// Accept the block directly
if err := blk.Accept(ctx); err != nil {
if !rt.config.Logger.IsZero() {
rt.config.Logger.Warn("fast-follow: accept failed, falling through to consensus",
log.Stringer("blockID", blockID),
log.Uint64("height", incomingHeight),
log.Err(err))
}
// Accept failed - fall through to consensus tracking
} else {
// Update our height tracker immediately
rt.fastFollowHeight = incomingHeight
// Update VM preference to build on this block
if err := rt.config.VM.SetPreference(ctx, blockID); err != nil {
if !rt.config.Logger.IsZero() {
rt.config.Logger.Warn("fast-follow: failed to set preference",
log.Stringer("blockID", blockID),
log.Err(err))
}
}
rt.Transitive.blocksAccepted++
if !rt.config.Logger.IsZero() {
rt.config.Logger.Info("fast-follow: block accepted successfully",
log.Stringer("blockID", blockID),
log.Uint64("height", incomingHeight))
}
// Send vote back to proposer so they can finalize
if rt.config.Gossiper != nil {
if err := rt.config.Gossiper.SendVote(rt.config.ChainID, fromNodeID, blockID); err != nil {
if !rt.config.Logger.IsZero() {
rt.config.Logger.Warn("fast-follow: failed to send vote",
log.Stringer("blockID", blockID),
log.Stringer("proposer", fromNodeID),
log.Err(err))
}
}
}
return blk, nil
}
// If fast-follow doesn't apply, fall back to consensus tracking
// (This handles out-of-order blocks or conflicting chains)
if !rt.config.Logger.IsZero() {
rt.config.Logger.Debug("block does not extend chain tip, adding to consensus tracking",
log.Stringer("blockID", blk.ID()),
log.Uint64("height", blk.Height()))
}
// Create consensus block and add to tracking
// followVerifiedBlock tracks a verified gossip block and casts+broadcasts this
// node's signed accept vote to ALL validators. It does NOT Accept the block:
// acceptance is gated on a verified α-of-K QuorumCert (handleIncomingCert).
//
// The caller holds rt.fastFollowMu.
func (rt *Runtime) followVerifiedBlock(ctx context.Context, blk block.Block, fromNodeID ids.NodeID) {
blockID := blk.ID()
consensusBlock := &Block{
id: blk.ID(),
id: blockID,
parentID: blk.ParentID(),
height: blk.Height(),
timestamp: blk.Timestamp().Unix(),
data: blk.Bytes(),
}
// Add to consensus engine
if err := rt.Transitive.consensus.AddBlock(ctx, consensusBlock); err != nil {
if !rt.config.Logger.IsZero() {
rt.config.Logger.Debug("failed to add block to consensus",
log.Stringer("blockID", blk.ID()),
log.Err(err))
}
// Continue anyway - block may already exist
}
// Add to consensus tracking (idempotent: AddBlock errors if already present).
_ = rt.Transitive.consensus.AddBlock(ctx, consensusBlock)
// Add to pending blocks for tracking
rt.Transitive.mu.Lock()
if _, exists := rt.Transitive.pendingBlocks[blk.ID()]; !exists {
rt.Transitive.pendingBlocks[blk.ID()] = &PendingBlock{
pending, exists := rt.Transitive.pendingBlocks[blockID]
if !exists {
pending = &PendingBlock{
ConsensusBlock: consensusBlock,
VMBlock: blk,
ProposedAt: time.Now(),
VoteCount: 1, // Count our own vote
VoteCount: 0,
Decided: false,
IsOwnProposal: false,
}
} else {
// Block already tracked, increment vote count
rt.Transitive.pendingBlocks[blk.ID()].VoteCount++
rt.Transitive.pendingBlocks[blockID] = pending
}
chainID := rt.Transitive.chainID
nodeID := rt.Transitive.nodeID
signer := rt.Transitive.voteSigner
verifier := rt.Transitive.voteVerifier
pos := rt.Transitive.blockPositionLocked(pending, blockID)
rt.Transitive.mu.Unlock()
// Vote in favor of the block (process our vote)
responses := map[ids.ID]int{blk.ID(): 1}
if err := rt.Transitive.consensus.Poll(ctx, responses); err != nil {
if !rt.config.Logger.IsZero() {
rt.config.Logger.Debug("failed to vote on block",
log.Stringer("blockID", blk.ID()),
log.Err(err))
}
// Single-validator / no-signer engines do not gossip votes; nothing to do.
if signer == nil || verifier == nil {
return
}
return blk, nil
// Sign our accept vote over the canonical position message.
message := CanonicalVoteMessage(pos)
sig, err := signer.SignVote(message)
if err != nil {
if !rt.config.Logger.IsZero() {
rt.config.Logger.Warn("follow: failed to sign accept vote",
log.Stringer("blockID", blockID), log.Err(err))
}
return
}
// Record our own vote locally toward the cert (so a node that is itself
// near-quorum can assemble), then broadcast it to ALL validators.
ownVote := Vote{
BlockID: blockID,
NodeID: nodeID,
Accept: true,
SignedAt: time.Now(),
Signature: sig,
ParentID: pos.ParentID,
Round: pos.Round,
}
rt.Transitive.ReceiveVote(ownVote)
if qg, ok := rt.config.Gossiper.(QuorumGossiper); ok {
if voteBytes, encErr := encodeSignedVote(nodeID, sig); encErr == nil {
qg.BroadcastVote(chainID, rt.config.NetworkID, blockID, voteBytes)
}
} else if rt.config.Gossiper != nil {
// Legacy gossiper without quorum support: at least notify the proposer
// (keeps a degraded path working) — but finality still requires a cert.
_ = rt.config.Gossiper.SendVote(rt.config.ChainID, fromNodeID, blockID)
}
}
// OnImportComplete must be called after admin_importChain (RLP import) completes.
+121 -406
View File
@@ -1,6 +1,19 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// proposer_self_accept_test.go — proposer finalization semantics under the
// α-of-K quorum-cert model.
//
// HISTORY: this file used to assert the OPPOSITE of correctness. It encoded the
// "proposer-self-accept" workaround as desired behavior: a proposer finalizing
// its own block on its LONE self-vote with ZERO peer Chits
// (TestProposerSelfAccept_FinalizesWithoutPeerChits), and peer REJECT votes for
// the proposer's own block being laundered into ACCEPTs
// (TestProposerSelfAccept_PeerVotesAcceptedDespiteLocalReVerifyFailure). Those
// were the self-finality fork hole. They are REPLACED here with tests that
// assert the proposer does NOT finalize without an α-of-K quorum, and that the
// freeze the workaround "fixed" is instead solved by the cert path (see
// quorum_finality_test.go TestLiveness_*). The shared VM/block helpers remain.
package chain
import (
@@ -9,20 +22,18 @@ import (
"testing"
"time"
"github.com/luxfi/consensus/config"
"github.com/luxfi/consensus/engine/chain/block"
"github.com/luxfi/ids"
)
// verifyOnceBlock implements block.Block such that Verify succeeds the first
// time and fails on subsequent calls. This models the production behavior of
// most luxd VMs (the PlatformVM CreateChainTx path in particular) where double
// Verify on the same block returns an error. The proposer's network handler
// (chains/manager.go applyQbit) re-derives vote.Accept from a fresh Verify call
// on incoming peer votes for its own block — without the IsOwnProposal trust
// path, the double-Verify failure flips peer Accepts to Rejects locally and
// drives the proposer's pendingBlock to rejected even though the cluster has
// committed it (the "proposer-self-accept gap").
// time and fails on subsequent calls — modelling production VMs (PlatformVM's
// CreateChainTx path) that reject a double Verify. Under the OLD design this
// double-Verify-failure was the reason the proposer flipped peer accepts to
// rejects (and why the IsOwnProposal flip was bolted on). Under the cert model
// the proposer NEVER re-Verifies a peer's vote — it verifies the peer's
// SIGNATURE over the peer's ACCEPT decision — so this VM no longer poisons
// finalization.
type verifyOnceBlock struct {
id ids.ID
parentID ids.ID
@@ -103,227 +114,7 @@ func (m *verifyOnceVM) SetPreference(_ context.Context, id ids.ID) error {
return nil
}
// TestProposerSelfAccept_PeerVotesAcceptedDespiteLocalReVerifyFailure is the
// regression test for the lux-devnet "proposer-self-accept gap" bug.
//
// Scenario (5-validator network, alpha=3):
// 1. Proposer N builds block B; N's VM.Verify(B) succeeds (verifyCount=1)
// 2. N self-votes (acceptVotes=1); B enters pendingBlocks with IsOwnProposal=true
// 3. N broadcasts; the other 4 followers accept B via fast-follow and each
// sends a Chits message back to N
// 4. On N, each incoming Chits is converted to a Vote by manager.applyQbit
// which calls blk.Verify(ctx) AGAIN to derive vote.Accept. With this VM
// that returns an error, accept=false (the bug surface).
// 5. With the fix, handleVote treats incoming votes for OwnProposal blocks
// as effectiveAccept=true regardless of vote.Accept, advancing acceptVotes
// to alpha and triggering tryFinalizeBlock → VMBlock.Accept(ctx).
//
// Pre-fix: vote.Accept=false flips the count into rejectVotes, B is rejected
// on N even though cluster committed. AcceptCalled==0.
// Post-fix: AcceptCalled==1.
func TestProposerSelfAccept_PeerVotesAcceptedDespiteLocalReVerifyFailure(t *testing.T) {
// 5-validator parameters: K=5, alpha=3
engine := NewWithParams(config.Parameters{
K: 5,
AlphaPreference: 3,
AlphaConfidence: 3,
Beta: 2,
})
ctx := context.Background()
blk := &verifyOnceBlock{
id: ids.GenerateTestID(),
parentID: ids.Empty,
height: 1,
timestamp: time.Now(),
bytes: []byte("proposer-block"),
}
vm := &verifyOnceVM{blocks: []*verifyOnceBlock{blk}}
engine.SetVM(vm)
if err := engine.Start(ctx, true); err != nil {
t.Fatalf("Start: %v", err)
}
defer engine.Stop(ctx)
// Proposer path: Notify triggers buildBlocksLocked, which calls Verify
// (verifyCount=1), self-votes, and inserts pendingBlocks with IsOwnProposal=true.
if err := engine.Notify(ctx, Message{Type: PendingTxs}); err != nil {
t.Fatalf("Notify: %v", err)
}
engine.mu.RLock()
pending, exists := engine.pendingBlocks[blk.id]
engine.mu.RUnlock()
if !exists {
t.Fatal("block must be tracked in pendingBlocks after Notify")
}
if !pending.IsOwnProposal {
t.Fatal("proposer's own block must carry IsOwnProposal=true")
}
if got := blk.VerifyCalls(); got != 1 {
t.Fatalf("Verify should be called exactly once at proposal time, got %d", got)
}
// Simulate peer votes arriving with vote.Accept=false — modelling
// manager.applyQbit's re-Verify failure on the proposer for its own block.
// We send 4 votes (>= alpha=3) — pre-fix this would push rejectVotes to alpha
// and reject the block; post-fix all four count as effective Accepts and
// the block is accepted.
for i := 0; i < 4; i++ {
engine.ReceiveVote(Vote{
BlockID: blk.id,
NodeID: ids.GenerateTestNodeID(),
Accept: false, // <-- the bug surface: applyQbit's failed re-Verify
SignedAt: time.Now(),
})
}
// Allow the voteHandler + tryFinalizeBlock to drain.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && blk.AcceptCalled() == 0 {
time.Sleep(20 * time.Millisecond)
}
if got := blk.AcceptCalled(); got != 1 {
t.Fatalf("VMBlock.Accept must be called exactly once after quorum, got %d", got)
}
if got := blk.RejectCalled(); got != 0 {
t.Fatalf("VMBlock.Reject must NOT be called for accepted block, got %d", got)
}
if !engine.IsAccepted(blk.id) {
t.Fatal("engine must mark block accepted after quorum")
}
engine.mu.RLock()
_, stillPending := engine.pendingBlocks[blk.id]
engine.mu.RUnlock()
if stillPending {
t.Fatal("accepted block must be removed from pendingBlocks")
}
}
// TestFollower_HonorsVoteAcceptValue verifies the IsOwnProposal trust path
// does NOT apply to follower-tracked blocks. For non-own pending entries
// (e.g., blocks tracked via HandleIncomingBlock's slow path), vote.Accept
// is honored faithfully — a vote.Accept=false counts as rejection.
//
// This ensures the proposer-self-accept fix is precisely scoped and does
// not weaken consensus for follower-side block tracking, where the engine
// did not perform the initial Verify and therefore cannot vouch for the
// block independently of peer signals.
func TestFollower_HonorsVoteAcceptValue(t *testing.T) {
engine := NewWithParams(config.Parameters{
K: 5,
AlphaPreference: 3,
AlphaConfidence: 3,
Beta: 2,
})
ctx := context.Background()
if err := engine.Start(ctx, true); err != nil {
t.Fatalf("Start: %v", err)
}
defer engine.Stop(ctx)
blkID := ids.GenerateTestID()
cb := &Block{
id: blkID,
parentID: ids.Empty,
height: 7,
timestamp: time.Now().Unix(),
}
if err := engine.AddBlock(ctx, cb); err != nil {
t.Fatalf("AddBlock: %v", err)
}
// Inject a follower-style pending entry: NOT an own proposal,
// no VMBlock attached (the follower's HandleIncomingBlock slow path).
engine.mu.Lock()
engine.pendingBlocks[blkID] = &PendingBlock{
ConsensusBlock: cb,
VMBlock: nil,
ProposedAt: time.Now(),
VoteCount: 1,
Decided: false,
IsOwnProposal: false,
}
engine.mu.Unlock()
// Send 4 negative votes — these MUST count as rejections.
for i := 0; i < 4; i++ {
engine.ReceiveVote(Vote{
BlockID: blkID,
NodeID: ids.GenerateTestNodeID(),
Accept: false,
SignedAt: time.Now(),
})
}
// Allow vote handler to drain.
time.Sleep(200 * time.Millisecond)
if engine.IsAccepted(blkID) {
t.Fatal("follower-tracked block must NOT be accepted when peers vote reject")
}
}
// TestProposer_AcceptsOnceUnderConcurrentVotes verifies the Accept path is
// called exactly once even under burst-arrival of peer votes — guards against
// the "double-accept" failure mode the bug report explicitly forbade.
func TestProposer_AcceptsOnceUnderConcurrentVotes(t *testing.T) {
engine := NewWithParams(config.Parameters{
K: 5,
AlphaPreference: 3,
AlphaConfidence: 3,
Beta: 2,
})
ctx := context.Background()
blk := &verifyOnceBlock{
id: ids.GenerateTestID(),
parentID: ids.Empty,
height: 1,
timestamp: time.Now(),
bytes: []byte("burst-block"),
}
vm := &verifyOnceVM{blocks: []*verifyOnceBlock{blk}}
engine.SetVM(vm)
if err := engine.Start(ctx, true); err != nil {
t.Fatalf("Start: %v", err)
}
defer engine.Stop(ctx)
if err := engine.Notify(ctx, Message{Type: PendingTxs}); err != nil {
t.Fatalf("Notify: %v", err)
}
// 8 concurrent peer votes (overshoot alpha=3) — Accept must still be exactly once.
for i := 0; i < 8; i++ {
go engine.ReceiveVote(Vote{
BlockID: blk.id,
NodeID: ids.GenerateTestNodeID(),
Accept: false, // bug-surface signal
SignedAt: time.Now(),
})
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && blk.AcceptCalled() == 0 {
time.Sleep(20 * time.Millisecond)
}
// give late goroutines a chance to try double-accepting
time.Sleep(200 * time.Millisecond)
if got := blk.AcceptCalled(); got != 1 {
t.Fatalf("VMBlock.Accept must be called exactly once, got %d", got)
}
}
// recordingProposer captures Propose/RequestVotes calls without going through
// any real network layer, so tests can model the production wiring (proposer
// is non-nil → finalizeOwnProposal fires) without producing actual gossip
// traffic. Mirrors the production BlockProposer surface.
// recordingProposer captures Propose/RequestVotes without a real network layer.
type recordingProposer struct {
proposeCalled int64
requestVotesCalled int64
@@ -339,210 +130,134 @@ func (p *recordingProposer) RequestVotes(_ context.Context, _ VoteRequest) error
return nil
}
func (p *recordingProposer) ProposeCalls() int64 {
return atomic.LoadInt64(&p.proposeCalled)
func (p *recordingProposer) ProposeCalls() int64 { return atomic.LoadInt64(&p.proposeCalled) }
// TestProposer_DoesNotFinalizeOwnProposalWithoutQuorum is the INVERSE of the old
// TestProposerSelfAccept_FinalizesWithoutPeerChits: a proposer with ZERO peer
// votes must NOT finalize its own block. It stays pending (recoverable), never
// force-accepted. This is the core self-finality fix.
func TestProposer_DoesNotFinalizeOwnProposalWithoutQuorum(t *testing.T) {
vs := newTestValidatorSet(5)
e, chainID := newQuorumEngine(t, params5(), vs, 0, &recordingGossiper{})
blk := newTestBlock(1, ids.Empty, "no-chits")
_ = trackProposal(e, chainID, blk, 0)
// Proposer attempts to self-finalize with only its own vote — refused.
e.finalizeOwnProposal(context.Background(), blk.id)
if e.IsAccepted(blk.id) {
t.Fatal("SAFETY: proposer finalized own block without an alpha-of-K quorum")
}
if blk.AcceptCalled() != 0 {
t.Fatalf("VM.Accept must not run without quorum, got %d", blk.AcceptCalled())
}
e.mu.RLock()
_, stillPending := e.pendingBlocks[blk.id]
e.mu.RUnlock()
if !stillPending {
t.Fatal("LIVENESS: a sub-quorum own block must remain pending, not be dropped")
}
}
// TestProposerSelfAccept_FinalizesWithoutPeerChits is the regression test for
// the v1.27.26 lux-devnet stall: the v1.25.1 IsOwnProposal trust path closes
// the gap only when peer Chits actually arrive at the proposer. When Chits do
// not arrive (network condition empirically observed on K=3 alpha=2 devnet),
// the proposer's pending block stays decided=false indefinitely; VM.Accept
// is never called; the node freezes at its proposal height.
//
// The v1.25.2 fix routes the proposer's commitment through
// finalizeOwnProposal at the end of buildBlocksLocked: once the proposer
// gossips the block and requests votes, it locally commits to its own
// (already-verified) block without waiting on peer signals. This is safe
// because the proposer ran Verify successfully — under honest-validator
// assumption, peers running the same Verify reach the same decision, and
// cluster authoritative finality (BLS+Pulsar witness) is unaffected.
//
// Test models the production wiring (recordingProposer non-nil) and sends
// ZERO peer votes; the proposer must still call VMBlock.Accept exactly once.
func TestProposerSelfAccept_FinalizesWithoutPeerChits(t *testing.T) {
// 5-validator alpha=3 cluster — without the fix, lone self-vote (count=1)
// stays below alpha (3) forever and the block never finalizes.
engine := NewWithParams(config.Parameters{
K: 5,
AlphaPreference: 3,
AlphaConfidence: 3,
Beta: 2,
})
ctx := context.Background()
// TestProposer_RejectVotesNeverFinalizeOwnProposal is the INVERSE of the old
// TestProposerSelfAccept_PeerVotesAcceptedDespiteLocalReVerifyFailure: peers
// voting REJECT on the proposer's own block must NOT finalize it (the
// effectiveAccept = ... || IsOwnProposal flip is deleted).
func TestProposer_RejectVotesNeverFinalizeOwnProposal(t *testing.T) {
vs := newTestValidatorSet(5)
e, chainID := newQuorumEngine(t, params5(), vs, 0, &recordingGossiper{})
blk := &verifyOnceBlock{
id: ids.GenerateTestID(),
parentID: ids.Empty,
height: 1,
timestamp: time.Now(),
bytes: []byte("self-accept-no-chits"),
}
vm := &verifyOnceVM{blocks: []*verifyOnceBlock{blk}}
engine.SetVM(vm)
proposer := &recordingProposer{}
engine.SetProposer(proposer)
blk := newTestBlock(1, ids.Empty, "own-rejected")
_ = trackProposal(e, chainID, blk, 0)
if err := engine.Start(ctx, true); err != nil {
t.Fatalf("Start: %v", err)
for i := 1; i < 5; i++ {
e.ReceiveVote(Vote{BlockID: blk.id, NodeID: vs.nodeID(i), Accept: false, SignedAt: time.Now()})
}
defer engine.Stop(ctx)
if waitFor(300*time.Millisecond, func() bool { return e.IsAccepted(blk.id) }) {
t.Fatal("SAFETY: own block finalized by REJECT votes (flip not removed)")
}
if blk.AcceptCalled() != 0 {
t.Fatalf("VM.Accept must not run for a rejected block, got %d", blk.AcceptCalled())
}
}
if err := engine.Notify(ctx, Message{Type: PendingTxs}); err != nil {
t.Fatalf("Notify: %v", err)
}
// TestProposer_FinalizesOwnProposalAtQuorum proves the proposer DOES finalize
// once a genuine α-of-K of signed accepts arrives — finality is reached the
// right way (cert), and exactly once.
func TestProposer_FinalizesOwnProposalAtQuorum(t *testing.T) {
vs := newTestValidatorSet(5)
e, chainID := newQuorumEngine(t, params5(), vs, 0, &recordingGossiper{})
// Notify is synchronous through buildBlocksLocked which now ends in
// finalizeOwnProposal — Accept must already have fired before Notify
// returns. Allow a short grace for async signal propagation.
deadline := time.Now().Add(1 * time.Second)
for time.Now().Before(deadline) && blk.AcceptCalled() == 0 {
time.Sleep(20 * time.Millisecond)
}
blk := newTestBlock(1, ids.Empty, "own-quorum")
pos := trackProposal(e, chainID, blk, 0)
if got := proposer.ProposeCalls(); got != 1 {
t.Fatalf("proposer.Propose must fire once at proposal time, got %d", got)
e.ReceiveVote(vs.signedVote(1, pos))
e.ReceiveVote(vs.signedVote(2, pos))
if !waitFor(2*time.Second, func() bool { return e.IsAccepted(blk.id) }) {
t.Fatal("LIVENESS: own block did not finalize after alpha-of-K signed accepts")
}
if got := blk.VerifyCalls(); got != 1 {
t.Fatalf("Verify must be called exactly once at proposal time, got %d", got)
if blk.AcceptCalled() != 1 {
t.Fatalf("VM.Accept exactly once at quorum, got %d", blk.AcceptCalled())
}
if got := blk.AcceptCalled(); got != 1 {
t.Fatalf("VMBlock.Accept must fire on proposer without peer Chits, got %d", got)
}
if got := blk.RejectCalled(); got != 0 {
t.Fatalf("VMBlock.Reject must NOT be called for self-finalized block, got %d", got)
}
if !engine.IsAccepted(blk.id) {
t.Fatal("engine must mark block accepted after self-finalize")
}
engine.mu.RLock()
_, stillPending := engine.pendingBlocks[blk.id]
engine.mu.RUnlock()
e.mu.RLock()
_, stillPending := e.pendingBlocks[blk.id]
e.mu.RUnlock()
if stillPending {
t.Fatal("self-finalized block must be removed from pendingBlocks")
t.Fatal("finalized block must be removed from pendingBlocks")
}
}
// TestProposerSelfAccept_IdempotentUnderLateChits verifies that late-arriving
// peer Chits after the proposer has already self-finalized cannot drive a
// second VMBlock.Accept call. This guards the production scenario where peers
// fast-follow accept and then Chits arrive at the proposer AFTER its
// self-finalize already fired.
func TestProposerSelfAccept_IdempotentUnderLateChits(t *testing.T) {
engine := NewWithParams(config.Parameters{
K: 5,
AlphaPreference: 3,
AlphaConfidence: 3,
Beta: 2,
})
ctx := context.Background()
// TestProposer_AcceptsOnceUnderConcurrentVotes guards against double-accept
// under burst arrival of (valid, signed) peer votes overshooting alpha.
func TestProposer_AcceptsOnceUnderConcurrentVotes(t *testing.T) {
vs := newTestValidatorSet(5)
e, chainID := newQuorumEngine(t, params5(), vs, 0, &recordingGossiper{})
blk := &verifyOnceBlock{
id: ids.GenerateTestID(),
parentID: ids.Empty,
height: 1,
timestamp: time.Now(),
bytes: []byte("late-chits"),
blk := newTestBlock(1, ids.Empty, "concurrent")
pos := trackProposal(e, chainID, blk, 0)
// All 4 other validators sign accept concurrently (overshoot alpha=3).
for i := 1; i < 5; i++ {
v := vs.signedVote(i, pos)
go e.ReceiveVote(v)
}
vm := &verifyOnceVM{blocks: []*verifyOnceBlock{blk}}
engine.SetVM(vm)
engine.SetProposer(&recordingProposer{})
if err := engine.Start(ctx, true); err != nil {
t.Fatalf("Start: %v", err)
if !waitFor(2*time.Second, func() bool { return blk.AcceptCalled() == 1 }) {
t.Fatalf("VM.Accept must reach exactly 1, got %d", blk.AcceptCalled())
}
defer engine.Stop(ctx)
if err := engine.Notify(ctx, Message{Type: PendingTxs}); err != nil {
t.Fatalf("Notify: %v", err)
}
// Wait for self-finalize.
deadline := time.Now().Add(1 * time.Second)
for time.Now().Before(deadline) && blk.AcceptCalled() == 0 {
time.Sleep(20 * time.Millisecond)
}
time.Sleep(150 * time.Millisecond) // let stragglers try to double-accept
if got := blk.AcceptCalled(); got != 1 {
t.Fatalf("self-finalize must fire once, got %d Accept calls", got)
}
// Now simulate late-arriving peer Chits (would normally drive natural
// finalization but the block is already decided + removed from pending).
for i := 0; i < 5; i++ {
engine.ReceiveVote(Vote{
BlockID: blk.id,
NodeID: ids.GenerateTestNodeID(),
Accept: false, // bug-surface signal from applyQbit re-Verify
SignedAt: time.Now(),
})
}
// Drain time for handleVote to process the late votes.
time.Sleep(200 * time.Millisecond)
if got := blk.AcceptCalled(); got != 1 {
t.Fatalf("VMBlock.Accept must still be exactly 1 after late Chits, got %d", got)
t.Fatalf("VM.Accept must be exactly once, got %d", got)
}
}
// TestFollower_StillRequiresAlphaQuorum_AfterSelfAcceptFix verifies the
// finalizeOwnProposal path is precisely scoped: IsOwnProposal=false entries
// (followers tracking peer-proposed blocks via HandleIncomingBlock slow path)
// must still require the full alpha-of-K quorum signal. The self-accept
// shortcut applies only to the node that built the block.
func TestFollower_StillRequiresAlphaQuorum_AfterSelfAcceptFix(t *testing.T) {
engine := NewWithParams(config.Parameters{
K: 5,
AlphaPreference: 3,
AlphaConfidence: 3,
Beta: 2,
})
engine.SetProposer(&recordingProposer{})
// TestFinalizeOwnProposal_NoOpForFollowerEntry keeps the precise-scoping
// property: finalizeOwnProposal does nothing for an entry that is NOT this
// node's own proposal.
func TestFinalizeOwnProposal_NoOpForFollowerEntry(t *testing.T) {
vs := newTestValidatorSet(5)
e, _ := newQuorumEngine(t, params5(), vs, 0, &recordingGossiper{})
ctx := context.Background()
if err := engine.Start(ctx, true); err != nil {
t.Fatalf("Start: %v", err)
}
defer engine.Stop(ctx)
blkID := ids.GenerateTestID()
cb := &Block{
id: blkID,
parentID: ids.Empty,
height: 7,
timestamp: time.Now().Unix(),
}
if err := engine.AddBlock(ctx, cb); err != nil {
cb := &Block{id: blkID, parentID: ids.Empty, height: 7, timestamp: time.Now().Unix()}
if err := e.AddBlock(ctx, cb); err != nil {
t.Fatalf("AddBlock: %v", err)
}
e.mu.Lock()
e.pendingBlocks[blkID] = &PendingBlock{ConsensusBlock: cb, VMBlock: nil, ProposedAt: time.Now(), IsOwnProposal: false}
e.mu.Unlock()
// Inject follower-style entry (NOT IsOwnProposal). finalizeOwnProposal
// MUST refuse to fire on this entry even though a proposer is wired.
engine.mu.Lock()
engine.pendingBlocks[blkID] = &PendingBlock{
ConsensusBlock: cb,
VMBlock: nil,
ProposedAt: time.Now(),
VoteCount: 1,
Decided: false,
IsOwnProposal: false,
}
engine.mu.Unlock()
e.finalizeOwnProposal(ctx, blkID)
// Direct call: must be a no-op since IsOwnProposal=false.
engine.finalizeOwnProposal(ctx, blkID)
engine.mu.RLock()
pending, exists := engine.pendingBlocks[blkID]
engine.mu.RUnlock()
if !exists {
t.Fatal("follower-tracked block must NOT be removed by finalizeOwnProposal")
if e.IsAccepted(blkID) {
t.Fatal("follower-tracked block must NOT be accepted by finalizeOwnProposal")
}
if pending.Decided {
t.Fatal("follower-tracked block must NOT be marked Decided by finalizeOwnProposal")
}
if engine.IsAccepted(blkID) {
t.Fatal("follower-tracked block must NOT be marked accepted by finalizeOwnProposal")
e.mu.RLock()
pending, exists := e.pendingBlocks[blkID]
e.mu.RUnlock()
if !exists || pending.Decided {
t.Fatal("follower-tracked block must remain pending+undecided")
}
}
+373
View File
@@ -0,0 +1,373 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// quorum_cert.go — the engine-level finality witness for chain consensus.
//
// THE FINALITY RULE (one rule, one place):
//
// A value block FINALIZES only after α distinct validators have each produced
// a correctly-signed ACCEPT vote over the SAME consensus position
// (chain, height, round, block_id, parent_id). The proof of that fact is a
// QuorumCert. No node — not even the proposer — may finalize a value block
// without holding (or having verified) a QuorumCert for it.
//
// This closes the proposer-self-finality hole: previously the proposer force-
// accepted its own block on its LONE self-vote (consensus.ForceAccept), and a
// peer's REJECT was counted as ACCEPT for the proposer's own block
// (effectiveAccept = ... || IsOwnProposal). Both are deleted. The α-of-K
// Snowball counting in consensus.go is now the sole finality authority, and a
// QuorumCert is its portable, verifiable witness.
//
// DECOMPLECTION — the rule vs. the witness's cryptography are SEPARATE:
//
// - The RULE ("α distinct validators accepted this exact value") lives here
// and is identical on every chain (P/X/C/D) and in every deployment.
// - The per-vote signature CRYPTOGRAPHY is pluggable via VoteVerifier. The
// engine never invents a signature scheme; the node injects one (BLS,
// ML-DSA, secp256k1) backed by a proven library. A QuorumCert over signed
// votes is the full-node-verifiable witness at THIS abstraction level.
// - protocol/quasar.WeightedQuorumCert is the SAME relation expressed with
// the heavyweight PQ apparatus (per-signer FIPS 204/205 records + weighted
// validator-set Merkle root + epoch). When a chain has its validator ML-DSA
// key material and weighted-set root plumbed through the node layer, a
// QuorumCert UPGRADES to carry a quasar.WeightedQuorumCert as its crypto
// witness with NO change to the finality rule (see CryptoWitness / the
// quasar bridge in quorum_cert_quasar.go). One rule; the witness format is
// orthogonal and forward-compatible.
//
// This is a quorum CERTIFICATE, not threshold signing: nothing is aggregated,
// no secret share is combined. Building a cert needs no secrets — any node that
// has collected α distinct signed ACCEPT votes assembles the identical cert
// (leaderless, permissionless). Verification is the direct predicate below.
package chain
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"sort"
"github.com/luxfi/ids"
)
// QuorumCertVersion is the wire/struct version of the engine-level quorum
// certificate. Bound into the canonical vote message and the cert digest so a
// version bump is non-malleable.
const QuorumCertVersion uint16 = 1
// QCType names a certificate's semantic role so a signature gathered for one
// role can never be replayed as another. The chain engine finalizes blocks, so
// the only role here is QCFinality; the type is bound into every signed vote
// message and into the cert, mirroring quasar's QCType axis for a clean future
// bridge.
type QCType uint8
const (
// QCFinality witnesses that α-of-K validators ACCEPTED a value block at a
// position — the proof required before VM.Accept may run for that block.
QCFinality QCType = 1
)
// Typed verification errors. Each maps 1:1 to one predicate clause so a caller
// or test can name the exact failure. Every one is a CLEAN rejection (the cert
// or vote is invalid); none is a panic and none does unbounded work — an
// adversarial cert yields an error, never a node crash.
var (
ErrQCNil = errors.New("chain: nil quorum cert")
ErrQCVersion = errors.New("chain: quorum cert version mismatch")
ErrQCType = errors.New("chain: quorum cert type mismatch")
ErrQCNoVotes = errors.New("chain: quorum cert has no votes")
ErrQCThresholdZero = errors.New("chain: quorum cert threshold (alpha) is zero")
ErrQCNotStrictlyIncreasing = errors.New("chain: cert voters are not strictly increasing (duplicate or unsorted node id)")
ErrQCBelowThreshold = errors.New("chain: distinct accept votes below quorum threshold (alpha)")
ErrQCVoteNotAccept = errors.New("chain: cert carries a non-accept vote")
ErrQCVotePosition = errors.New("chain: cert vote position does not match cert position")
ErrQCSigInvalid = errors.New("chain: cert vote signature failed verification")
ErrQCVerifierNil = errors.New("chain: vote verifier is nil; cannot verify a cert's signatures — fail closed")
)
// SignedVote is one validator's signed ACCEPT decision over a consensus
// position. It is the atom a QuorumCert is assembled from. The signature is
// over CanonicalVoteMessage(position) under a scheme the engine does not need
// to know — VoteVerifier resolves the (NodeID, message, signature) triple.
//
// Only ACCEPT votes are certifiable: a finality cert proves a value was
// accepted, so Accept must be true and is bound into the canonical message.
// (Reject votes drive the rejection path in consensus.go; they are never put
// in a finality cert.)
type SignedVote struct {
// NodeID is the signing validator's identifier. Votes in a cert are sorted
// strictly-increasing by this field (distinctness / anti-double-count).
NodeID ids.NodeID
// Accept is the validator's decision. For a finality cert this MUST be true.
Accept bool
// Signature is the validator's signature over CanonicalVoteMessage of the
// cert's position. Verified by a VoteVerifier; the engine is scheme-agnostic.
Signature []byte
}
// VotePosition is the consensus position a vote (and a cert) binds to. Every
// axis here is folded into the canonical signed message, so a signature for one
// position can never be replayed at another (height/round/block/parent/chain).
type VotePosition struct {
ChainID ids.ID
Height uint64
Round uint32
BlockID ids.ID
ParentID ids.ID
}
// CanonicalVoteMessage is the exact byte string a validator signs to vote
// ACCEPT on a position. It is the message a QuorumCert's signatures are bound
// to (a finality cert proves ACCEPT). Deterministic and domain-separated: a
// signature is bound to (version, qc_type, chain, height, round, block, parent,
// accept=1) so it cannot be lifted to a different role, position, or decision.
func CanonicalVoteMessage(pos VotePosition) []byte {
return canonicalVoteMessageFor(pos, true)
}
// canonicalVoteMessageFor builds the domain-separated vote message for a
// position AND a decision. The accept byte is bound, so an ACCEPT signature
// (accept=1, what a cert carries) and a REJECT signature (accept=0) over the
// same position are DISTINCT messages — a reject signature can never be
// presented as an accept (and vice-versa). The engine verifies accept votes
// against (pos,true) and reject votes against (pos,false); the cert only ever
// uses (pos,true).
//
// Layout (big-endian, fixed-width, length-free because every field is fixed):
//
// "LUX/chain/vote/v1\x00" domain tag (NUL-terminated, role separator)
// version:2 qc_type:1
// chain_id:32 height:8 round:4 block_id:32 parent_id:32
// accept:1 (0x01 accept | 0x00 reject)
func canonicalVoteMessageFor(pos VotePosition, accept bool) []byte {
const tag = "LUX/chain/vote/v1\x00"
buf := make([]byte, 0, len(tag)+2+1+32+8+4+32+32+1)
buf = append(buf, tag...)
var u16 [2]byte
binary.BigEndian.PutUint16(u16[:], QuorumCertVersion)
buf = append(buf, u16[:]...)
buf = append(buf, byte(QCFinality))
buf = append(buf, pos.ChainID[:]...)
var u64 [8]byte
binary.BigEndian.PutUint64(u64[:], pos.Height)
buf = append(buf, u64[:]...)
var u32 [4]byte
binary.BigEndian.PutUint32(u32[:], pos.Round)
buf = append(buf, u32[:]...)
buf = append(buf, pos.BlockID[:]...)
buf = append(buf, pos.ParentID[:]...)
if accept {
buf = append(buf, 0x01)
} else {
buf = append(buf, 0x00)
}
return buf
}
// VoteVerifier verifies one validator's signature over the canonical vote
// message. This is the engine's sole crypto dependency for finality: the node
// supplies an implementation backed by a proven library (BLS, ML-DSA via
// quasar, or secp256k1). The engine defines the QUORUM RULE; the verifier
// supplies the SIGNATURE CHECK. Decomplected.
//
// VerifyVote MUST be deterministic and side-effect free, return true iff sig is
// a valid signature by nodeID over message, and NEVER panic on adversarial
// input (return false). Implementations that consult a validator set MUST treat
// an unknown nodeID as "false", not an error — a cert with an out-of-set voter
// is simply invalid.
type VoteVerifier interface {
VerifyVote(nodeID ids.NodeID, message []byte, sig []byte) bool
}
// VoteVerifierFunc adapts a function to a VoteVerifier.
type VoteVerifierFunc func(nodeID ids.NodeID, message []byte, sig []byte) bool
// VerifyVote implements VoteVerifier.
func (f VoteVerifierFunc) VerifyVote(nodeID ids.NodeID, message []byte, sig []byte) bool {
return f(nodeID, message, sig)
}
// QuorumCert is the engine-level finality witness: α distinct validators each
// signed ACCEPT over Position. It is portable (gossipable), verifiable by any
// node holding the VoteVerifier, and deterministic to assemble.
//
// It is NOT a signature — there is no aggregate field, because nothing is
// aggregated. The cert carries the per-voter signed records; verification is
// the predicate in Verify.
type QuorumCert struct {
// Version pins the cert format.
Version uint16
// Type names the cert's role (QCFinality). Bound into every vote message.
Type QCType
// Position is the consensus position every vote binds to.
Position VotePosition
// Threshold (alpha) is the minimum number of distinct ACCEPT voters required
// for the cert to be valid — the chain's α-of-K quorum floor.
Threshold uint32
// Votes are the per-voter signed ACCEPT records, sorted strictly-increasing
// by NodeID. len(Votes) >= Threshold for a valid cert.
Votes []SignedVote
}
// AssembleQuorumCert builds a finality cert from collected signed ACCEPT votes.
// Permissionless and deterministic: NO secrets, NO randomness. Given the same
// votes and position the same cert comes out.
//
// It sorts votes strictly-increasing by NodeID (dropping duplicate NodeIDs —
// last-writer-wins is not used; a duplicate NodeID is an error so a cert can
// never double-count), and rejects a structurally impossible cert (no votes,
// zero threshold, a non-accept vote). It does NOT verify signatures —
// assembly is orthogonal to verification (a relaying node assembles; verifiers
// verify). A subsequent Verify is the gate.
//
// Returns ErrQCBelowThreshold if fewer than `threshold` distinct accept votes
// are supplied: assembly only succeeds once the quorum is actually present, so
// a cert can never claim a quorum it does not hold.
func AssembleQuorumCert(pos VotePosition, threshold uint32, votes []SignedVote) (*QuorumCert, error) {
if threshold == 0 {
return nil, ErrQCThresholdZero
}
if len(votes) == 0 {
return nil, ErrQCNoVotes
}
// Defensive copy; dedup by NodeID (reject duplicates), require ACCEPT.
sorted := make([]SignedVote, 0, len(votes))
seen := make(map[ids.NodeID]struct{}, len(votes))
for i := range votes {
v := votes[i]
if !v.Accept {
return nil, fmt.Errorf("%w: voter %s", ErrQCVoteNotAccept, v.NodeID)
}
if _, dup := seen[v.NodeID]; dup {
return nil, fmt.Errorf("%w: voter %s", ErrQCNotStrictlyIncreasing, v.NodeID)
}
seen[v.NodeID] = struct{}{}
cp := v
cp.Signature = append([]byte(nil), v.Signature...)
sorted = append(sorted, cp)
}
sort.Slice(sorted, func(i, j int) bool {
return bytes.Compare(sorted[i].NodeID[:], sorted[j].NodeID[:]) < 0
})
if uint32(len(sorted)) < threshold {
return nil, fmt.Errorf("%w: have %d need %d", ErrQCBelowThreshold, len(sorted), threshold)
}
return &QuorumCert{
Version: QuorumCertVersion,
Type: QCFinality,
Position: pos,
Threshold: threshold,
Votes: sorted,
}, nil
}
// Verify checks the cert against verifier. Returns nil iff every predicate
// clause holds; otherwise a typed error naming the FIRST failure. NEVER panics
// and NEVER does unbounded work — an adversarial cert (duplicate voter, bad
// signature, sub-threshold, wrong position) yields a clean error.
//
// Predicate (the engine-level projection of quasar's weighted-quorum predicate):
//
// (0) verifier is non-nil (fail-closed)
// (1) version + type match
// (2) threshold (alpha) > 0
// (3) at least one vote
// for each vote, in order:
// (4) node ids are STRICTLY INCREASING (distinct, anti-double-count)
// (5) vote is ACCEPT
// (6) signature verifies under verifier over CanonicalVoteMessage(Position)
// then
// (7) count of distinct valid accept votes >= threshold
//
// fail-closed: a nil verifier is an error, never a pass — a cert may not be
// trusted without the ability to check its signatures.
func (c *QuorumCert) Verify(verifier VoteVerifier) error {
if c == nil {
return ErrQCNil
}
if verifier == nil {
return ErrQCVerifierNil
}
if c.Version != QuorumCertVersion {
return fmt.Errorf("%w: got %d want %d", ErrQCVersion, c.Version, QuorumCertVersion)
}
if c.Type != QCFinality {
return fmt.Errorf("%w: got %d want %d", ErrQCType, c.Type, QCFinality)
}
if c.Threshold == 0 {
return ErrQCThresholdZero
}
if len(c.Votes) == 0 {
return ErrQCNoVotes
}
message := CanonicalVoteMessage(c.Position)
var count uint32
var prev ids.NodeID
havePrev := false
for i := range c.Votes {
v := &c.Votes[i]
// Clause (4): strictly increasing node ids — distinct + canonical order;
// closes duplicate-voter double counting and cert re-ordering malleability.
if havePrev && bytes.Compare(prev[:], v.NodeID[:]) >= 0 {
return fmt.Errorf("%w: vote %d", ErrQCNotStrictlyIncreasing, i)
}
prev = v.NodeID
havePrev = true
// Clause (5): finality certs carry ACCEPT votes only.
if !v.Accept {
return fmt.Errorf("%w: vote %d voter %s", ErrQCVoteNotAccept, i, v.NodeID)
}
// Clause (6): signature verifies over the cert's own position. The
// verifier rebuilds nothing from the vote — the message is derived from
// the CERT position, so a vote that signed a different position fails.
if !verifier.VerifyVote(v.NodeID, message, v.Signature) {
return fmt.Errorf("%w: vote %d voter %s", ErrQCSigInvalid, i, v.NodeID)
}
count++
}
// Clause (7): distinct valid accept votes meet the quorum floor.
if count < c.Threshold {
return fmt.Errorf("%w: have %d need %d", ErrQCBelowThreshold, count, c.Threshold)
}
return nil
}
// VoterCount returns the number of distinct voters the cert carries. After a
// successful Verify this equals the number of distinct valid accept votes.
func (c *QuorumCert) VoterCount() int {
if c == nil {
return 0
}
return len(c.Votes)
}
// Equal reports structural equality of two certs (used in round-trip tests).
func (c *QuorumCert) Equal(o *QuorumCert) bool {
if c == nil || o == nil {
return c == o
}
if c.Version != o.Version || c.Type != o.Type || c.Position != o.Position ||
c.Threshold != o.Threshold || len(c.Votes) != len(o.Votes) {
return false
}
for i := range c.Votes {
if c.Votes[i].NodeID != o.Votes[i].NodeID ||
c.Votes[i].Accept != o.Votes[i].Accept ||
!bytes.Equal(c.Votes[i].Signature, o.Votes[i].Signature) {
return false
}
}
return true
}
+228
View File
@@ -0,0 +1,228 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// quorum_cert_codec.go — deterministic wire codec for the engine-level
// QuorumCert. A finality cert is gossiped so followers can verify α-of-K
// finality without re-collecting votes themselves; this is its on-wire form.
//
// All multi-byte integers are big-endian. Decoding is fail-closed on every
// short read and rejects trailing bytes, and caps the attacker-controlled vote
// count against the remaining buffer BEFORE any allocation (decode-DoS guard).
package chain
import (
"encoding/binary"
"errors"
"fmt"
"github.com/luxfi/ids"
)
// ErrQCWireCorrupt is returned by the decoder on any structural defect.
var ErrQCWireCorrupt = errors.New("chain: quorum cert wire corrupt")
// Wire layout (big-endian):
//
// version:2
// type:1
// chain_id:32
// height:8
// round:4
// block_id:32
// parent_id:32
// threshold:4
// vote_count:4
// then vote_count records, each:
// node_id:20 (ids.NodeID is a 20-byte value)
// accept:1
// sig_len:4 sig:sig_len
// qcVoteFixed is the fixed-size prefix of one encoded vote (node_id + accept +
// sig_len). Used to cap vote_count against the buffer before allocation.
const qcVoteFixed = ids.NodeIDLen + 1 + 4
// qcHeaderSize is the fixed header byte length preceding the vote records.
const qcHeaderSize = 2 + 1 + 32 + 8 + 4 + 32 + 32 + 4 + 4
// MarshalBinary encodes the cert deterministically. Equal certs encode to equal
// bytes (votes are kept in the strictly-increasing order Assemble produced).
func (c *QuorumCert) MarshalBinary() ([]byte, error) {
if c == nil {
return nil, ErrQCNil
}
buf := make([]byte, 0, qcHeaderSize)
var u16 [2]byte
binary.BigEndian.PutUint16(u16[:], c.Version)
buf = append(buf, u16[:]...)
buf = append(buf, byte(c.Type))
buf = append(buf, c.Position.ChainID[:]...)
var u64 [8]byte
binary.BigEndian.PutUint64(u64[:], c.Position.Height)
buf = append(buf, u64[:]...)
var u32 [4]byte
binary.BigEndian.PutUint32(u32[:], c.Position.Round)
buf = append(buf, u32[:]...)
buf = append(buf, c.Position.BlockID[:]...)
buf = append(buf, c.Position.ParentID[:]...)
binary.BigEndian.PutUint32(u32[:], c.Threshold)
buf = append(buf, u32[:]...)
binary.BigEndian.PutUint32(u32[:], uint32(len(c.Votes)))
buf = append(buf, u32[:]...)
for i := range c.Votes {
v := &c.Votes[i]
buf = append(buf, v.NodeID[:]...)
if v.Accept {
buf = append(buf, 0x01)
} else {
buf = append(buf, 0x00)
}
binary.BigEndian.PutUint32(u32[:], uint32(len(v.Signature)))
buf = append(buf, u32[:]...)
buf = append(buf, v.Signature...)
}
return buf, nil
}
// UnmarshalQuorumCert is the inverse of MarshalBinary. Strict trailing-bytes
// policy and fail-closed on every short read.
func UnmarshalQuorumCert(data []byte) (*QuorumCert, error) {
r := &qcWireReader{buf: data}
c := &QuorumCert{}
v16, err := r.u16()
if err != nil {
return nil, ErrQCWireCorrupt
}
c.Version = v16
t8, err := r.u8()
if err != nil {
return nil, ErrQCWireCorrupt
}
c.Type = QCType(t8)
if err = r.readIDInto(&c.Position.ChainID); err != nil {
return nil, ErrQCWireCorrupt
}
if c.Position.Height, err = r.u64(); err != nil {
return nil, ErrQCWireCorrupt
}
if c.Position.Round, err = r.u32(); err != nil {
return nil, ErrQCWireCorrupt
}
if err = r.readIDInto(&c.Position.BlockID); err != nil {
return nil, ErrQCWireCorrupt
}
if err = r.readIDInto(&c.Position.ParentID); err != nil {
return nil, ErrQCWireCorrupt
}
if c.Threshold, err = r.u32(); err != nil {
return nil, ErrQCWireCorrupt
}
voteCount, err := r.u32()
if err != nil {
return nil, ErrQCWireCorrupt
}
// Cap vote_count against the remaining buffer BEFORE reserving capacity:
// each vote occupies at least qcVoteFixed bytes, so a count whose minimum
// footprint exceeds the bytes that remain is structurally impossible.
// Rejects an adversarial header (vote_count = 0xFFFFFFFF) in O(1).
if uint64(voteCount)*uint64(qcVoteFixed) > uint64(len(r.buf)) {
return nil, fmt.Errorf("%w: vote_count %d exceeds remaining buffer (%d bytes)",
ErrQCWireCorrupt, voteCount, len(r.buf))
}
c.Votes = make([]SignedVote, 0, voteCount)
for i := uint32(0); i < voteCount; i++ {
var sv SignedVote
if err = r.readNodeIDInto(&sv.NodeID); err != nil {
return nil, ErrQCWireCorrupt
}
ab, err := r.u8()
if err != nil {
return nil, ErrQCWireCorrupt
}
sv.Accept = ab != 0
if sv.Signature, err = r.lenPrefixed(); err != nil {
return nil, ErrQCWireCorrupt
}
c.Votes = append(c.Votes, sv)
}
if len(r.buf) != 0 {
return nil, fmt.Errorf("%w: %d trailing bytes", ErrQCWireCorrupt, len(r.buf))
}
return c, nil
}
// qcWireReader is a bounds-checked sequential reader for the cert codec.
type qcWireReader struct{ buf []byte }
func (r *qcWireReader) need(n int) bool { return len(r.buf) >= n }
func (r *qcWireReader) u8() (uint8, error) {
if !r.need(1) {
return 0, ErrQCWireCorrupt
}
v := r.buf[0]
r.buf = r.buf[1:]
return v, nil
}
func (r *qcWireReader) u16() (uint16, error) {
if !r.need(2) {
return 0, ErrQCWireCorrupt
}
v := binary.BigEndian.Uint16(r.buf[:2])
r.buf = r.buf[2:]
return v, nil
}
func (r *qcWireReader) u32() (uint32, error) {
if !r.need(4) {
return 0, ErrQCWireCorrupt
}
v := binary.BigEndian.Uint32(r.buf[:4])
r.buf = r.buf[4:]
return v, nil
}
func (r *qcWireReader) u64() (uint64, error) {
if !r.need(8) {
return 0, ErrQCWireCorrupt
}
v := binary.BigEndian.Uint64(r.buf[:8])
r.buf = r.buf[8:]
return v, nil
}
func (r *qcWireReader) readIDInto(dst *ids.ID) error {
if !r.need(ids.IDLen) {
return ErrQCWireCorrupt
}
copy(dst[:], r.buf[:ids.IDLen])
r.buf = r.buf[ids.IDLen:]
return nil
}
func (r *qcWireReader) readNodeIDInto(dst *ids.NodeID) error {
if !r.need(ids.NodeIDLen) {
return ErrQCWireCorrupt
}
copy(dst[:], r.buf[:ids.NodeIDLen])
r.buf = r.buf[ids.NodeIDLen:]
return nil
}
func (r *qcWireReader) lenPrefixed() ([]byte, error) {
n, err := r.u32()
if err != nil {
return nil, err
}
if uint64(n) > uint64(len(r.buf)) {
return nil, ErrQCWireCorrupt
}
out := make([]byte, n)
copy(out, r.buf[:n])
r.buf = r.buf[n:]
return out, nil
}
+133
View File
@@ -0,0 +1,133 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package chain
import (
"testing"
"github.com/luxfi/ids"
)
// TestQuorumCertCodec_RoundTrip proves the gossip wire codec is exact:
// marshal→unmarshal yields an equal cert, and a verified cert stays verified.
func TestQuorumCertCodec_RoundTrip(t *testing.T) {
vs := newTestValidatorSet(4)
pos := VotePosition{
ChainID: ids.GenerateTestID(),
Height: 12345,
Round: 7,
BlockID: ids.GenerateTestID(),
ParentID: ids.GenerateTestID(),
}
votes := make([]SignedVote, 0, 4)
for i := 0; i < 4; i++ {
votes = append(votes, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
}
cert, err := AssembleQuorumCert(pos, 3, votes)
if err != nil {
t.Fatalf("assemble: %v", err)
}
if err := cert.Verify(vs); err != nil {
t.Fatalf("original cert must verify: %v", err)
}
enc, err := cert.MarshalBinary()
if err != nil {
t.Fatalf("marshal: %v", err)
}
got, err := UnmarshalQuorumCert(enc)
if err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !cert.Equal(got) {
t.Fatal("round-trip cert must equal the original")
}
if err := got.Verify(vs); err != nil {
t.Fatalf("round-trip cert must still verify: %v", err)
}
}
// TestQuorumCertCodec_RejectsCorrupt proves the decoder is fail-closed:
// trailing bytes, short reads, and an oversized vote_count are all rejected
// without panic or unbounded allocation.
func TestQuorumCertCodec_RejectsCorrupt(t *testing.T) {
vs := newTestValidatorSet(3)
pos := VotePosition{ChainID: ids.GenerateTestID(), Height: 1, BlockID: ids.GenerateTestID()}
votes := []SignedVote{
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)},
{NodeID: vs.nodeID(1), Accept: true, Signature: vs.sign(1, pos)},
{NodeID: vs.nodeID(2), Accept: true, Signature: vs.sign(2, pos)},
}
cert, _ := AssembleQuorumCert(pos, 3, votes)
enc, _ := cert.MarshalBinary()
// Trailing byte → reject.
if _, err := UnmarshalQuorumCert(append(enc, 0x00)); err == nil {
t.Fatal("trailing byte must be rejected")
}
// Truncated → reject.
if _, err := UnmarshalQuorumCert(enc[:len(enc)-1]); err == nil {
t.Fatal("truncated input must be rejected")
}
// Empty → reject.
if _, err := UnmarshalQuorumCert(nil); err == nil {
t.Fatal("empty input must be rejected")
}
// Oversized vote_count (header claims many votes, no body) → reject in O(1).
// Build a header with a huge vote_count and no records.
hdr := make([]byte, qcHeaderSize)
// version=1
hdr[0], hdr[1] = 0x00, 0x01
hdr[2] = byte(QCFinality)
// set vote_count (last 4 bytes of header) to 0xFFFFFFFF
hdr[qcHeaderSize-4] = 0xFF
hdr[qcHeaderSize-3] = 0xFF
hdr[qcHeaderSize-2] = 0xFF
hdr[qcHeaderSize-1] = 0xFF
if _, err := UnmarshalQuorumCert(hdr); err == nil {
t.Fatal("oversized vote_count must be rejected without allocation")
}
}
// TestQuorumCert_DuplicateVoterRejected proves the distinctness clause:
// assembly drops a structurally-impossible duplicate, and a hand-crafted cert
// with non-increasing voters fails Verify.
func TestQuorumCert_DuplicateVoterRejected(t *testing.T) {
vs := newTestValidatorSet(2)
pos := VotePosition{ChainID: ids.GenerateTestID(), Height: 1, BlockID: ids.GenerateTestID()}
// Assembly rejects a duplicate NodeID.
_, err := AssembleQuorumCert(pos, 2, []SignedVote{
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)},
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)},
})
if err == nil {
t.Fatal("assembly must reject a duplicate voter")
}
// Hand-craft a cert whose votes are NOT strictly increasing → Verify fails.
bad := &QuorumCert{
Version: QuorumCertVersion, Type: QCFinality, Position: pos, Threshold: 1,
Votes: []SignedVote{
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)},
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)}, // dup
},
}
if err := bad.Verify(vs); err == nil {
t.Fatal("Verify must reject non-strictly-increasing voters")
}
}
// TestQuorumCert_NilVerifierFailsClosed proves a cert can never be trusted
// without a verifier.
func TestQuorumCert_NilVerifierFailsClosed(t *testing.T) {
vs := newTestValidatorSet(1)
pos := VotePosition{ChainID: ids.GenerateTestID(), Height: 1, BlockID: ids.GenerateTestID()}
cert, _ := AssembleQuorumCert(pos, 1, []SignedVote{
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)},
})
if err := cert.Verify(nil); err == nil {
t.Fatal("Verify(nil) must fail closed")
}
}
+165
View File
@@ -0,0 +1,165 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// quorum_cert_quasar.go — the forward bridge from the engine-level QuorumCert
// to protocol/quasar.WeightedQuorumCert, the canonical PQ finality witness.
//
// WHY A BRIDGE AND NOT A DIRECT WIRING:
//
// The engine-level QuorumCert (quorum_cert.go) and quasar's WeightedQuorumCert
// witness the SAME relation — "α-of-K validators accepted this value" — at two
// abstraction levels:
//
// - engine QuorumCert: voters are ids.NodeID, each carrying ONE signature
// over the canonical vote message. Self-contained in engine/chain; needs
// only a pluggable VoteVerifier. This is what every chain (P/X/C/D) runs
// today, on every deployment, with no extra plumbing.
//
// - quasar.WeightedQuorumCert: signers are weighted validator-set LEAVES,
// each carrying a FIPS 204/205 record + a Merkle inclusion proof against
// the epoch's weighted-validator-set ROOT. It is the heavyweight,
// post-quantum, full-node-verifiable witness — but it requires three
// pieces of node-layer state the engine does not own: (1) the epoch's
// weighted validator-set Merkle root, (2) each validator's ML-DSA/SLH-DSA
// public-key leaf + voting weight, and (3) per-validator Merkle paths.
//
// The finality RULE is identical; only the witness CRYPTOGRAPHY differs. This
// bridge expresses that: a CryptoWitnessSource supplied by the node layer turns
// the engine cert's distinct signed voters into quasar signer records and
// assembles a WeightedQuorumCert that quasar.Verify accepts. When a chain has
// not (yet) plumbed its weighted PQ validator set, the source is nil and the
// engine cert remains the witness — same rule, lighter witness. NO crypto is
// ever faked: absent the real validator-set material, the bridge FAILS CLOSED
// (ErrQuasarWitnessUnavailable) rather than fabricate records.
//
// This is the "one way" wiring of protocol/quasar into engine/chain: the engine
// owns the quorum rule and the vote-collection topology; quasar owns the PQ
// certificate format; the bridge composes them without braiding.
package chain
import (
"errors"
"fmt"
"github.com/luxfi/consensus/protocol/quasar"
"github.com/luxfi/ids"
)
// ErrQuasarWitnessUnavailable is returned when a quasar.WeightedQuorumCert is
// requested but the node has not supplied the weighted-validator-set material
// required to build one. Fail-closed: the caller falls back to the engine-level
// QuorumCert witness, never a fabricated PQ cert.
var ErrQuasarWitnessUnavailable = errors.New("chain: quasar weighted-quorum witness unavailable (no validator-set material plumbed)")
// ErrQuasarVoterNotInSet is returned when a cert voter has no corresponding
// weighted-validator-set leaf in the source — an out-of-set voter cannot be
// represented as a quasar signer record.
var ErrQuasarVoterNotInSet = errors.New("chain: cert voter has no weighted-validator-set leaf")
// CryptoWitnessSource is the node-layer hook that supplies the weighted
// validator-set material needed to upgrade an engine QuorumCert into a
// quasar.WeightedQuorumCert. The node implements this once it has plumbed its
// PQ validator key set; until then engine/chain operates on the engine cert
// alone and this is nil.
//
// All three methods describe the epoch the cert finalizes in. They are pure
// reads of committed validator-set state — no secrets, no signing.
type CryptoWitnessSource interface {
// Epoch returns the consensus epoch for the given block height — the epoch
// whose validator-set root the resulting cert proves against.
Epoch(height uint64) uint64
// ValidatorSetRoot returns the weighted-validator-set Merkle root committed
// for the epoch (weighted_merkle.go ROOT). Every signer record's leaf is
// proven against this.
ValidatorSetRoot(epoch uint64) ([48]byte, bool)
// QuorumThreshold returns the BFT quorum WEIGHT floor for the epoch — the
// minimum total signer weight a valid quasar cert must assert. This is the
// weighted analogue of the engine cert's count threshold (alpha).
QuorumThreshold(epoch uint64) (uint64, bool)
// SignerRecord turns one engine voter into its quasar signer record: the
// validator's weighted-set leaf fields + a Merkle inclusion proof against
// ValidatorSetRoot + the validator's FIPS signature over the SAME consensus
// message the engine vote signed. Returns ok=false for an out-of-set voter.
//
// The node owns this mapping because it owns the validator key set; the
// engine supplies the voter identity, the canonical message bytes, and the
// engine signature, and the node resolves the leaf/path/record.
SignerRecord(nodeID ids.NodeID, message []byte, engineSig []byte) (quasar.QuorumSignerRecord, bool)
}
// quasarChainID projects a 32-byte chain ids.ID onto the uint32 chain-id axis
// quasar's envelope binds. The high 4 big-endian bytes of the chain ID are the
// canonical projection: chain IDs in this engine are derived hashes, so the
// leading word is a stable, collision-resistant discriminator for the envelope
// axis (the full 32-byte block id is ALSO bound, via ValueHash, so this
// projection only scopes the chain, it does not weaken value binding).
func quasarChainID(chainID ids.ID) uint32 {
return uint32(chainID[0])<<24 | uint32(chainID[1])<<16 | uint32(chainID[2])<<8 | uint32(chainID[3])
}
// ToQuasarCert upgrades a verified engine QuorumCert into a
// quasar.WeightedQuorumCert using node-supplied validator-set material. The
// engine cert MUST already have passed Verify (its voters are distinct and
// correctly signed) — this method maps, it does not re-decide quorum.
//
// Fail-closed: a nil source, a missing validator-set root / threshold, or an
// out-of-set voter yields an error and NO cert — the caller keeps the engine
// witness. NO record is ever fabricated.
//
// The resulting cert is verified with quasar's own Verify by the caller (a
// follower receiving it), against the chain's QuorumMessageEnvelope and
// QuorumVerifierConfig — that is where the PQ FIPS checks and the weighted
// Merkle inclusion run. This method only ASSEMBLES.
func (c *QuorumCert) ToQuasarCert(src CryptoWitnessSource) (*quasar.WeightedQuorumCert, error) {
if c == nil {
return nil, ErrQCNil
}
if src == nil {
return nil, ErrQuasarWitnessUnavailable
}
epoch := src.Epoch(c.Position.Height)
root, ok := src.ValidatorSetRoot(epoch)
if !ok {
return nil, fmt.Errorf("%w: epoch %d root", ErrQuasarWitnessUnavailable, epoch)
}
threshold, ok := src.QuorumThreshold(epoch)
if !ok {
return nil, fmt.Errorf("%w: epoch %d threshold", ErrQuasarWitnessUnavailable, epoch)
}
message := CanonicalVoteMessage(c.Position)
records := make([]quasar.QuorumSignerRecord, 0, len(c.Votes))
for i := range c.Votes {
v := &c.Votes[i]
rec, ok := src.SignerRecord(v.NodeID, message, v.Signature)
if !ok {
return nil, fmt.Errorf("%w: voter %s", ErrQuasarVoterNotInSet, v.NodeID)
}
records = append(records, rec)
}
params := quasar.QuorumCertParams{
ChainID: quasarChainID(c.Position.ChainID),
Epoch: epoch,
Height: c.Position.Height,
Round: c.Position.Round,
ValueHash: idTo32(c.Position.BlockID),
QCType: uint8(c.Type),
ValidatorSetRoot: root,
QuorumThreshold: threshold,
}
return quasar.BuildWeightedQuorumCert(params, records)
}
// idTo32 copies an ids.ID (32 bytes) into a [32]byte for quasar's value-hash
// axis.
func idTo32(id ids.ID) [32]byte {
var out [32]byte
copy(out[:], id[:])
return out
}
+246
View File
@@ -0,0 +1,246 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// quorum_cert_quasar_test.go — proves the engine→quasar bridge is REAL: an
// engine QuorumCert maps to a protocol/quasar.WeightedQuorumCert that quasar's
// OWN Verify accepts, AND that the bridge fails closed when the node has not
// supplied the weighted PQ validator-set material.
package chain
import (
"crypto"
"crypto/rand"
"testing"
"github.com/luxfi/consensus/config"
"github.com/luxfi/consensus/protocol/quasar"
"github.com/luxfi/crypto/mldsa"
"github.com/luxfi/ids"
)
// mldsaWitnessSource is a faithful CryptoWitnessSource: it owns a real weighted
// validator set with ML-DSA-65 keys, and for each engine voter it produces a
// quasar signer record (leaf fields + Merkle path + a REAL ML-DSA signature
// over the quasar consensus message). This is what the node layer implements
// once its PQ validator set is plumbed — here with real FIPS-204 keys so
// quasar.Verify exercises the genuine predicate.
type mldsaWitnessSource struct {
epoch uint64
set *quasar.WeightedValidatorSet
envelope quasar.QuorumMessageEnvelope
threshold uint64
// byNode maps an engine NodeID → its leaf index + ML-DSA private key.
idx map[ids.NodeID]int
priv map[ids.NodeID]*mldsa.PrivateKey
pub map[ids.NodeID][]byte
}
func newMLDSAWitnessSource(t *testing.T, nodeIDs []ids.NodeID, epoch uint64) *mldsaWitnessSource {
t.Helper()
src := &mldsaWitnessSource{
epoch: epoch,
threshold: uint64(len(nodeIDs)), // require all (test sets weight=1 each)
idx: make(map[ids.NodeID]int),
priv: make(map[ids.NodeID]*mldsa.PrivateKey),
pub: make(map[ids.NodeID][]byte),
}
leaves := make([]quasar.WeightedValidatorLeaf, 0, len(nodeIDs))
for i, nodeID := range nodeIDs {
priv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
if err != nil {
t.Fatalf("mldsa keygen: %v", err)
}
pubBytes := priv.Public().(*mldsa.PublicKey).Bytes()
var vid [32]byte
copy(vid[:], nodeID[:])
leaves = append(leaves, quasar.WeightedValidatorLeaf{
ValidatorID: vid,
PublicKey: pubBytes,
VotingWeight: 1,
ParameterSetID: uint8(quasar.QuorumSchemeMLDSA65),
KeyVersion: 0,
})
src.idx[nodeID] = i
src.priv[nodeID] = priv
src.pub[nodeID] = pubBytes
}
set, err := quasar.BuildWeightedValidatorSet(epoch, leaves)
if err != nil {
t.Fatalf("build weighted set: %v", err)
}
src.set = set
// The set sorts leaves by ValidatorID internally; remap each nodeID to its
// actual leaf index in the SORTED set so inclusion proofs are for the right
// position.
sortedLeaves := set.Leaves()
for nodeID := range src.idx {
var vid [32]byte
copy(vid[:], nodeID[:])
for li := range sortedLeaves {
if sortedLeaves[li].ValidatorID == vid {
src.idx[nodeID] = li
break
}
}
}
// Minimal envelope: pin the direct weighted-quorum backend + ML-DSA scheme.
src.envelope = quasar.QuorumMessageEnvelope{
ProfileID: 0xC57E0001,
HashSuite: config.HashSuiteSHA3NIST,
IdentityScheme: config.IdentitySchemeMLDSA65,
FinalityScheme: config.SigSchemePulsar65,
ProofPolicy: config.ProofPolicySTARKFRISHA3PQ,
ProofBackend: config.ProofBackendDirectWeightedQuorum,
ProofFormat: config.ProofFormatDirectWeightedQuorumV1,
VerifierID: config.VerifierDirectWeightedQuorumPQ,
EffectivePolicy: 1,
NetworkID: 0xC0DE0001,
}
return src
}
func (s *mldsaWitnessSource) Epoch(uint64) uint64 { return s.epoch }
func (s *mldsaWitnessSource) ValidatorSetRoot(epoch uint64) ([48]byte, bool) {
if epoch != s.epoch {
return [48]byte{}, false
}
return s.set.Root(), true
}
func (s *mldsaWitnessSource) QuorumThreshold(epoch uint64) (uint64, bool) {
if epoch != s.epoch {
return 0, false
}
return s.threshold, true
}
func (s *mldsaWitnessSource) SignerRecord(nodeID ids.NodeID, _ []byte, _ []byte) (quasar.QuorumSignerRecord, bool) {
i, ok := s.idx[nodeID]
if !ok {
return quasar.QuorumSignerRecord{}, false
}
proof, err := s.set.InclusionProof(i)
if err != nil {
return quasar.QuorumSignerRecord{}, false
}
var vid [32]byte
copy(vid[:], nodeID[:])
return quasar.QuorumSignerRecord{
ValidatorID: vid,
PublicKey: s.pub[nodeID],
VotingWeight: 1,
Scheme: quasar.QuorumSchemeMLDSA65,
ParamSetID: uint8(quasar.QuorumSchemeMLDSA65),
KeyVersion: 0,
MerklePath: proof,
// Signature filled by signQuasar (needs the cert position → message).
}, true
}
// signQuasarRecords fills each record's Signature with a REAL ML-DSA signature
// over the quasar consensus message for the assembled cert. In production the
// node does this when the validator participates; the test does it explicitly
// so quasar.Verify sees genuine FIPS signatures.
func (s *mldsaWitnessSource) signQuasarRecords(t *testing.T, cert *quasar.WeightedQuorumCert) {
t.Helper()
msg, err := quasar.QuorumMessageForCert(s.envelope, cert)
if err != nil {
t.Fatalf("quasar message: %v", err)
}
for i := range cert.Signers {
var nodeID ids.NodeID
copy(nodeID[:], cert.Signers[i].ValidatorID[:ids.NodeIDLen])
priv := s.priv[nodeID]
if priv == nil {
t.Fatalf("no key for signer %d", i)
}
sig, err := priv.Sign(rand.Reader, msg, crypto.Hash(0))
if err != nil {
t.Fatalf("mldsa sign: %v", err)
}
cert.Signers[i].Signature = sig
}
// Re-stamp the signer commitment is NOT needed (sigs are not committed); the
// commitment binds ids+positions which are unchanged.
}
// TestQuasarBridge_RealCertVerifies proves the wiring end to end: build an
// engine QuorumCert, upgrade it to a quasar.WeightedQuorumCert via the bridge,
// fill real ML-DSA signatures, and verify with quasar's OWN Verify.
func TestQuasarBridge_RealCertVerifies(t *testing.T) {
vs := newTestValidatorSet(3) // 3 engine voters
nodeIDs := []ids.NodeID{vs.nodeID(0), vs.nodeID(1), vs.nodeID(2)}
src := newMLDSAWitnessSource(t, nodeIDs, 7)
chainID := ids.GenerateTestID()
blockID := ids.GenerateTestID()
pos := VotePosition{ChainID: chainID, Height: 100, Round: 0, BlockID: blockID, ParentID: ids.Empty}
// Engine cert with 3 distinct signed (engine-auth) accept votes.
engineCert, err := AssembleQuorumCert(pos, 3, []SignedVote{
{NodeID: nodeIDs[0], Accept: true, Signature: vs.sign(0, pos)},
{NodeID: nodeIDs[1], Accept: true, Signature: vs.sign(1, pos)},
{NodeID: nodeIDs[2], Accept: true, Signature: vs.sign(2, pos)},
})
if err != nil {
t.Fatalf("assemble engine cert: %v", err)
}
if err := engineCert.Verify(vs); err != nil {
t.Fatalf("engine cert must verify: %v", err)
}
// Bridge → quasar cert.
qcert, err := engineCert.ToQuasarCert(src)
if err != nil {
t.Fatalf("ToQuasarCert: %v", err)
}
// Position mapping must be faithful.
if qcert.Height != pos.Height || qcert.Round != pos.Round {
t.Fatalf("quasar cert position mismatch: height=%d round=%d", qcert.Height, qcert.Round)
}
if qcert.ValueHash != idTo32(blockID) {
t.Fatal("quasar cert value hash must equal the engine block id")
}
if qcert.QuorumThreshold != src.threshold {
t.Fatalf("quasar cert threshold mismatch: %d", qcert.QuorumThreshold)
}
if uint32(len(qcert.Signers)) != qcert.SignerCount || qcert.SignerCount != 3 {
t.Fatalf("quasar cert must carry 3 signer records, got %d", qcert.SignerCount)
}
// Fill real ML-DSA signatures and verify with quasar's own predicate.
src.signQuasarRecords(t, qcert)
cfg := quasar.QuorumVerifierConfig{
AllowedSchemes: map[quasar.QuorumSchemeID]bool{quasar.QuorumSchemeMLDSA65: true},
MinThreshold: src.threshold,
}
if err := qcert.Verify(src.envelope, cfg); err != nil {
t.Fatalf("BRIDGE BROKEN: quasar.Verify rejected the bridged cert: %v", err)
}
}
// TestQuasarBridge_FailsClosedWithoutMaterial proves the bridge NEVER fabricates
// a cert: nil source, missing root/threshold, and out-of-set voters all error.
func TestQuasarBridge_FailsClosedWithoutMaterial(t *testing.T) {
vs := newTestValidatorSet(1)
pos := VotePosition{ChainID: ids.GenerateTestID(), Height: 1, BlockID: ids.GenerateTestID()}
cert, err := AssembleQuorumCert(pos, 1, []SignedVote{
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)},
})
if err != nil {
t.Fatalf("assemble: %v", err)
}
// (a) nil source → fail closed.
if _, err := cert.ToQuasarCert(nil); err == nil {
t.Fatal("ToQuasarCert(nil) must fail closed")
}
// (b) source missing this voter (out-of-set) → fail closed, no cert.
other := newMLDSAWitnessSource(t, []ids.NodeID{ids.GenerateTestNodeID()}, 1)
if _, err := cert.ToQuasarCert(other); err == nil {
t.Fatal("ToQuasarCert with an out-of-set voter must fail closed")
}
}
+472
View File
@@ -0,0 +1,472 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// quorum_finality_test.go — the launch-gate tests. Each proves one safety or
// liveness property of α-of-K quorum finality. These are the gates the DEX
// value-activation depends on: no real value until a value block finalizes ONLY
// with a quorum (no proposer self-finality), and the proposer-freeze must not
// return.
package chain
import (
"context"
"testing"
"time"
"github.com/luxfi/consensus/config"
"github.com/luxfi/ids"
"github.com/luxfi/log"
)
// params5 is a 5-validator config: K=5, alpha=3 (BFT 3-of-5).
func params5() config.Parameters {
return config.Parameters{K: 5, AlphaPreference: 3, AlphaConfidence: 3, Beta: 2}
}
// -----------------------------------------------------------------------------
// GATE 1: a proposer cannot finalize its own block on its lone self-vote.
// -----------------------------------------------------------------------------
func TestConsensus_NoProposerSelfFinality(t *testing.T) {
vs := newTestValidatorSet(5)
rec := &recordingGossiper{}
e, chainID := newQuorumEngine(t, params5(), vs, 0, rec)
blk := newTestBlock(1, ids.Empty, "proposer-block")
pos := trackProposal(e, chainID, blk, 0)
_ = pos
// The proposer self-voted at proposal time (acceptVotes=1). Drive the
// self-finalize path. With NO peer votes, alpha=3 is not met → MUST NOT
// finalize. The old ForceAccept path would finalize here on the lone vote.
e.finalizeOwnProposal(context.Background(), blk.id)
if e.IsAccepted(blk.id) {
t.Fatal("SAFETY VIOLATION: proposer finalized its own block on a lone self-vote (no alpha-of-K quorum)")
}
if blk.AcceptCalled() != 0 {
t.Fatalf("VM.Accept must NOT be called without quorum, got %d", blk.AcceptCalled())
}
// And tryFinalizeBlock (the count-driven path) must also refuse: count=1<3.
e.tryFinalizeBlock(context.Background(), blk.id)
if e.IsAccepted(blk.id) {
t.Fatal("SAFETY VIOLATION: block finalized below alpha via tryFinalizeBlock")
}
}
// -----------------------------------------------------------------------------
// GATE 2: acceptance requires an α-of-K quorum of correctly-signed accepts.
// -----------------------------------------------------------------------------
func TestConsensus_AcceptRequiresQuorum(t *testing.T) {
vs := newTestValidatorSet(5)
rec := &recordingGossiper{}
e, chainID := newQuorumEngine(t, params5(), vs, 0, rec)
blk := newTestBlock(1, ids.Empty, "needs-quorum")
pos := trackProposal(e, chainID, blk, 0)
// Two peer accepts (validators 1,2). Total distinct accepts = proposer(0) is
// counted in consensus via ProcessVote at proposal; here we feed signed peer
// votes. With alpha=3, two peers + proposer = 3 → quorum. But to isolate the
// "below quorum stays pending" property, first send only ONE peer.
e.ReceiveVote(vs.signedVote(1, pos))
if waitFor(300*time.Millisecond, func() bool { return e.IsAccepted(blk.id) }) {
t.Fatal("SAFETY VIOLATION: block accepted with only 2 accepts (proposer+1) below alpha=3")
}
// Now add the third distinct signer → alpha reached → MUST finalize, and a
// cert MUST have been assembled+gossiped.
e.ReceiveVote(vs.signedVote(2, pos))
if !waitFor(2*time.Second, func() bool { return e.IsAccepted(blk.id) }) {
t.Fatal("LIVENESS: block did not finalize after alpha-of-K signed accepts arrived")
}
if blk.AcceptCalled() != 1 {
t.Fatalf("VM.Accept must run exactly once at quorum, got %d", blk.AcceptCalled())
}
rec.mu.Lock()
gotCerts := len(rec.certs)
rec.mu.Unlock()
if gotCerts == 0 {
t.Fatal("a verified quorum cert must be assembled and gossiped at finality")
}
}
// TestConsensus_RejectVotesDoNotFinalizeOwnProposal proves the deleted
// `effectiveAccept = ... || IsOwnProposal` flip is gone: a proposer's own block
// is NOT finalized by peers voting REJECT (the old code laundered those into
// accepts and committed).
func TestConsensus_RejectVotesDoNotFinalizeOwnProposal(t *testing.T) {
vs := newTestValidatorSet(5)
rec := &recordingGossiper{}
e, chainID := newQuorumEngine(t, params5(), vs, 0, rec)
blk := newTestBlock(1, ids.Empty, "own-with-rejects")
_ = trackProposal(e, chainID, blk, 0)
// Four REJECT votes for the proposer's OWN block. Pre-fix: counted as
// accepts via IsOwnProposal → finalized. Post-fix: counted as rejects.
for i := 1; i < 5; i++ {
e.ReceiveVote(Vote{BlockID: blk.id, NodeID: vs.nodeID(i), Accept: false, SignedAt: time.Now()})
}
if waitFor(300*time.Millisecond, func() bool { return e.IsAccepted(blk.id) }) {
t.Fatal("SAFETY VIOLATION: own block finalized by peer REJECT votes (effectiveAccept flip not removed)")
}
if blk.AcceptCalled() != 0 {
t.Fatalf("VM.Accept must not run for a block peers rejected, got %d", blk.AcceptCalled())
}
}
// -----------------------------------------------------------------------------
// GATE 3: an equivocating proposer cannot finalize BOTH forks.
// -----------------------------------------------------------------------------
func TestConsensus_EquivocatingProposerCannotFinalizeBothForks(t *testing.T) {
vs := newTestValidatorSet(5)
rec := &recordingGossiper{}
e, chainID := newQuorumEngine(t, params5(), vs, 0, rec)
// Two distinct, individually-valid blocks at the SAME height (equivocation).
forkA := newTestBlock(1, ids.Empty, "fork-A")
forkB := newTestBlock(1, ids.Empty, "fork-B")
posA := trackProposal(e, chainID, forkA, 0)
posB := trackProposal(e, chainID, forkB, 0)
// An honest α-of-K majority (3 of 5) can sign AT MOST ONE value per height.
// Model that: validators 1,2 sign fork A; validator 1 cannot ALSO be the
// third distinct signer of fork B (it already committed to A). Only
// validator 3 is left for B → B has proposer(0 self) + 3 = 2 distinct
// signers < alpha. A gets proposer(0) + 1 + 2 = 3 → finalizes; B does not.
e.ReceiveVote(vs.signedVote(1, posA))
e.ReceiveVote(vs.signedVote(2, posA))
e.ReceiveVote(vs.signedVote(3, posB))
finalizedA := waitFor(2*time.Second, func() bool { return e.IsAccepted(forkA.id) })
// Give B the same window; it must NOT finalize.
finalizedB := waitFor(300*time.Millisecond, func() bool { return e.IsAccepted(forkB.id) })
if finalizedA && finalizedB {
t.Fatal("SAFETY VIOLATION: equivocating proposer finalized BOTH forks at one height")
}
if !finalizedA {
t.Fatal("fork A reached alpha-of-K and should finalize")
}
if finalizedB {
t.Fatal("SAFETY VIOLATION: fork B finalized without an alpha-of-K quorum (only 2 distinct signers)")
}
// Cert audit: exactly one cert, for fork A, and it must verify.
rec.mu.Lock()
defer rec.mu.Unlock()
for i, cb := range rec.certs {
cert, err := UnmarshalQuorumCert(cb)
if err != nil {
t.Fatalf("gossiped cert %d failed to decode: %v", i, err)
}
if cert.Position.BlockID == forkB.id {
t.Fatal("SAFETY VIOLATION: a finality cert was produced for fork B")
}
if err := cert.Verify(vs); err != nil {
t.Fatalf("gossiped cert %d failed verification: %v", i, err)
}
}
}
// -----------------------------------------------------------------------------
// GATE 4: a minority partition cannot finalize.
// -----------------------------------------------------------------------------
func TestConsensus_PartitionMinorityCannotFinalize(t *testing.T) {
vs := newTestValidatorSet(5)
rec := &recordingGossiper{}
e, chainID := newQuorumEngine(t, params5(), vs, 0, rec)
blk := newTestBlock(1, ids.Empty, "minority-partition")
pos := trackProposal(e, chainID, blk, 0)
// Minority partition: only the proposer (0) and ONE other validator (1) are
// reachable — 2 of 5 < alpha=3. They sign; the cert can never assemble.
e.ReceiveVote(vs.signedVote(1, pos))
if waitFor(500*time.Millisecond, func() bool { return e.IsAccepted(blk.id) }) {
t.Fatal("SAFETY VIOLATION: a minority partition (2 of 5) finalized a block")
}
rec.mu.Lock()
gotCerts := len(rec.certs)
rec.mu.Unlock()
if gotCerts != 0 {
t.Fatalf("no cert may be produced by a sub-alpha minority, got %d", gotCerts)
}
// The block remains pending — recoverable when the partition heals.
e.mu.RLock()
_, stillPending := e.pendingBlocks[blk.id]
e.mu.RUnlock()
if !stillPending {
t.Fatal("a sub-quorum block must remain pending (not dropped, not accepted)")
}
}
// -----------------------------------------------------------------------------
// GATE 5 (DEX): a BFT quorum finalizes a value fill.
// -----------------------------------------------------------------------------
// TestDEX_BFTQuorumFinalizesFill models a value C+D block (a DEX fill) being
// finalized: the value block finalizes ONLY after an α-of-K quorum, and the
// fail-closed DEX gate PERMITS activation in this (quorum-finality) mode.
func TestDEX_BFTQuorumFinalizesFill(t *testing.T) {
vs := newTestValidatorSet(5)
rec := &recordingGossiper{}
e, chainID := newQuorumEngine(t, params5(), vs, 0, rec)
// The DEX value gate must PERMIT activation: K>1 + verifier = quorum-finality.
if err := e.RequireQuorumFinalityForValueDEX(true); err != nil {
t.Fatalf("value DEX must be permitted under quorum finality: %v", err)
}
if e.Mode() != ModeQuorumFinality {
t.Fatalf("engine must report quorum-finality mode, got %s", e.Mode())
}
// A value fill block. Finalizes only at alpha-of-K.
fill := newTestBlock(42, ids.Empty, "dex-value-fill")
pos := trackProposal(e, chainID, fill, 0)
e.ReceiveVote(vs.signedVote(1, pos))
e.ReceiveVote(vs.signedVote(2, pos))
if !waitFor(2*time.Second, func() bool { return e.IsAccepted(fill.id) }) {
t.Fatal("LIVENESS: DEX value fill did not finalize after alpha-of-K quorum")
}
if fill.AcceptCalled() != 1 {
t.Fatalf("value fill VM.Accept must run exactly once at quorum, got %d", fill.AcceptCalled())
}
// The finality witness must be a real, verifiable cert.
rec.mu.Lock()
defer rec.mu.Unlock()
if len(rec.certs) == 0 {
t.Fatal("DEX value fill finality must produce a verifiable quorum cert")
}
cert, err := UnmarshalQuorumCert(rec.certs[len(rec.certs)-1])
if err != nil {
t.Fatalf("decode fill cert: %v", err)
}
if err := cert.Verify(vs); err != nil {
t.Fatalf("fill cert must verify: %v", err)
}
if cert.VoterCount() < 3 {
t.Fatalf("fill cert must carry >= alpha=3 distinct voters, got %d", cert.VoterCount())
}
}
// -----------------------------------------------------------------------------
// GATE 6 (DEX): a Byzantine proposer's fake fill is rejected.
// -----------------------------------------------------------------------------
// TestDEX_ByzantineProposerFakeFillRejected models a Byzantine proposer trying
// to finalize a fake value fill WITHOUT an honest quorum — by self-finalizing,
// by forging votes (unknown/invalid signatures), and by asserting a sub-quorum
// cert. All paths are refused.
func TestDEX_ByzantineProposerFakeFillRejected(t *testing.T) {
vs := newTestValidatorSet(5)
rec := &recordingGossiper{}
e, chainID := newQuorumEngine(t, params5(), vs, 0, rec)
fake := newTestBlock(7, ids.Empty, "fake-fill")
pos := trackProposal(e, chainID, fake, 0)
// (a) self-finalize attempt — refused (gate 1).
e.finalizeOwnProposal(context.Background(), fake.id)
if e.IsAccepted(fake.id) {
t.Fatal("SAFETY: Byzantine self-finalized a fake fill")
}
// (b) forged votes: signatures from keys NOT in the validator set, and a
// real validator's signature over a DIFFERENT position. Neither verifies, so
// neither counts toward the cert.
outsider := newTestValidatorSet(1) // keys unknown to vs
e.ReceiveVote(Vote{
BlockID: fake.id, NodeID: outsider.nodeID(0), Accept: true, SignedAt: time.Now(),
Signature: outsider.sign(0, pos), ParentID: pos.ParentID, Round: pos.Round,
})
wrongPos := pos
wrongPos.Height = pos.Height + 1 // validator 1 signs the WRONG height
e.ReceiveVote(Vote{
BlockID: fake.id, NodeID: vs.nodeID(1), Accept: true, SignedAt: time.Now(),
Signature: vs.sign(1, wrongPos), ParentID: pos.ParentID, Round: pos.Round,
})
if waitFor(400*time.Millisecond, func() bool { return e.IsAccepted(fake.id) }) {
t.Fatal("SAFETY: fake fill finalized via forged / wrong-position votes")
}
rec.mu.Lock()
gotCerts := len(rec.certs)
rec.mu.Unlock()
if gotCerts != 0 {
t.Fatalf("no cert may form from forged votes, got %d", gotCerts)
}
// (c) a hand-forged sub-quorum cert presented to a follower is rejected by
// Verify (threshold floor + only 1 real signer).
subCert, err := AssembleQuorumCert(pos, 1, []SignedVote{
{NodeID: vs.nodeID(1), Accept: true, Signature: vs.sign(1, pos)},
})
if err != nil {
t.Fatalf("assemble sub-cert: %v", err)
}
// It "verifies" at its own threshold=1, but the engine's cert-intake floor
// (HandleIncomingCert) rejects threshold < chain alpha. Assert the floor.
if subCert.Threshold >= uint32(e.consensus.Alpha()) {
t.Fatal("test bug: sub-cert threshold should be below alpha")
}
_ = chainID
}
// -----------------------------------------------------------------------------
// LIVENESS: no proposer-freeze under late / dropped Chits.
// -----------------------------------------------------------------------------
// TestLiveness_NoFreezeUnderLateChits proves the regression that motivated the
// old (unsafe) ForceAccept does NOT return: when peer votes arrive LATE, the
// proposer still finalizes once the quorum is present — without ever
// self-finalizing in the gap. The block stays pending (not frozen-dead, not
// force-accepted) until the late votes complete the quorum, then finalizes.
func TestLiveness_NoFreezeUnderLateChits(t *testing.T) {
vs := newTestValidatorSet(5)
rec := &recordingGossiper{}
e, chainID := newQuorumEngine(t, params5(), vs, 0, rec)
blk := newTestBlock(1, ids.Empty, "late-chits")
pos := trackProposal(e, chainID, blk, 0)
// Proposer tries to self-finalize immediately (the old freeze trigger).
// It must NOT finalize (no quorum yet) AND must NOT be dropped.
e.finalizeOwnProposal(context.Background(), blk.id)
if e.IsAccepted(blk.id) {
t.Fatal("SAFETY: self-finalized before quorum")
}
e.mu.RLock()
_, pendingMid := e.pendingBlocks[blk.id]
e.mu.RUnlock()
if !pendingMid {
t.Fatal("LIVENESS: block dropped while waiting for late votes (would freeze the height)")
}
// Late votes arrive (simulating delayed/dropped-then-resent Chits).
time.Sleep(120 * time.Millisecond)
e.ReceiveVote(vs.signedVote(1, pos))
e.ReceiveVote(vs.signedVote(2, pos))
// Now it finalizes — liveness restored WITHOUT self-finality.
if !waitFor(2*time.Second, func() bool { return e.IsAccepted(blk.id) }) {
t.Fatal("LIVENESS: proposer froze — did not finalize after late quorum arrived")
}
if blk.AcceptCalled() != 1 {
t.Fatalf("VM.Accept exactly once after late quorum, got %d", blk.AcceptCalled())
}
}
// TestLiveness_DroppedProposerChitsStillFinalizeViaPeerCert proves a follower
// finalizes from a GOSSIPED cert even if it never collected the votes itself —
// the cert-distribution path that replaces fast-follow and prevents the freeze.
func TestLiveness_DroppedProposerChitsStillFinalizeViaPeerCert(t *testing.T) {
vs := newTestValidatorSet(5)
// Proposer assembles a real cert (3 of 5 signed accepts) out-of-band.
chainID := ids.GenerateTestID()
blk := newTestBlock(9, ids.Empty, "cert-relayed")
pos := VotePosition{ChainID: chainID, Height: blk.height, Round: 0, BlockID: blk.id, ParentID: blk.parentID}
cert, err := AssembleQuorumCert(pos, 3, []SignedVote{
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)},
{NodeID: vs.nodeID(1), Accept: true, Signature: vs.sign(1, pos)},
{NodeID: vs.nodeID(2), Accept: true, Signature: vs.sign(2, pos)},
})
if err != nil {
t.Fatalf("assemble cert: %v", err)
}
certBytes, err := cert.MarshalBinary()
if err != nil {
t.Fatalf("marshal cert: %v", err)
}
// A FOLLOWER (validator 4) that verified the block but collected NO votes.
follower := NewWithConfig(Config{Params: params5()},
WithQuorumCert(chainID, vs.nodeID(4), vs, &recordingGossiper{}, vs.signerFor(4)))
if err := follower.Start(context.Background(), true); err != nil {
t.Fatalf("follower Start: %v", err)
}
t.Cleanup(func() { _ = follower.Stop(context.Background()) })
rt := &Runtime{Transitive: follower, config: NetworkConfig{ChainID: chainID, Logger: log.Noop()}}
// The follower has verified+tracked the block (as HandleIncomingBlock would).
cb := &Block{id: blk.id, parentID: blk.parentID, height: blk.height, timestamp: blk.timestamp.Unix(), data: blk.bytes}
_ = follower.consensus.AddBlock(context.Background(), cb)
follower.mu.Lock()
follower.pendingBlocks[blk.id] = &PendingBlock{ConsensusBlock: cb, VMBlock: blk, ProposedAt: time.Now(), Round: 0}
follower.mu.Unlock()
// The proposer's direct Chits to this follower were dropped; instead it
// receives the gossiped cert. It MUST finalize on that verifiable proof.
if !rt.HandleIncomingCert(certBytes) {
t.Fatal("LIVENESS: follower failed to finalize from a valid gossiped quorum cert")
}
if !follower.IsAccepted(blk.id) {
t.Fatal("follower must mark block accepted after a verified cert")
}
if blk.AcceptCalled() != 1 {
t.Fatalf("follower VM.Accept exactly once via cert, got %d", blk.AcceptCalled())
}
}
// TestSingleValidator_StillFinalizes proves the K==1 path is preserved: a
// single-validator engine finalizes its own block (1-of-1 quorum) via the
// gated ForceAccept, and the DEX value gate REFUSES (single-validator is not a
// multi-party quorum-finality mode).
func TestSingleValidator_StillFinalizes(t *testing.T) {
e := NewWithParams(config.Parameters{K: 1, AlphaPreference: 1, AlphaConfidence: 1, Beta: 1})
if err := e.Start(context.Background(), true); err != nil {
t.Fatalf("K=1 Start (no verifier needed): %v", err)
}
t.Cleanup(func() { _ = e.Stop(context.Background()) })
if e.Mode() != ModeSingleValidator {
t.Fatalf("K=1 must be single-validator mode, got %s", e.Mode())
}
// Value DEX MUST be refused on a single validator (fail-closed).
if err := e.RequireQuorumFinalityForValueDEX(true); err == nil {
t.Fatal("SAFETY: value DEX must be refused on a single-validator engine")
}
blk := newTestBlock(1, ids.Empty, "solo")
_ = trackProposal(e, ids.Empty, blk, 0)
// K==1: ForceAccept is permitted; finalizeOwnProposal commits.
e.finalizeOwnProposal(context.Background(), blk.id)
if !e.IsAccepted(blk.id) {
t.Fatal("K=1 single validator must finalize its own block (1-of-1 quorum)")
}
if blk.AcceptCalled() != 1 {
t.Fatalf("K=1 VM.Accept exactly once, got %d", blk.AcceptCalled())
}
}
// TestForceAccept_RefusedForMultiValidator is the direct unit guard: ForceAccept
// fails closed for K>1.
func TestForceAccept_RefusedForMultiValidator(t *testing.T) {
c := NewChainConsensus(5, 3, 2)
blk := &Block{id: ids.GenerateTestID(), height: 1}
_ = c.AddBlock(context.Background(), blk)
if err := c.ForceAccept(blk.id); err == nil {
t.Fatal("SAFETY: ForceAccept must refuse on a multi-validator (K=5) engine")
}
if c.IsAccepted(blk.id) {
t.Fatal("SAFETY: ForceAccept must not accept on K>1")
}
// K=1 path works.
c1 := NewChainConsensus(1, 1, 1)
blk1 := &Block{id: ids.GenerateTestID(), height: 1}
_ = c1.AddBlock(context.Background(), blk1)
if err := c1.ForceAccept(blk1.id); err != nil {
t.Fatalf("ForceAccept must succeed on K=1: %v", err)
}
if !c1.IsAccepted(blk1.id) {
t.Fatal("K=1 ForceAccept must accept")
}
}
+112
View File
@@ -0,0 +1,112 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// quorum_guard.go — the fail-closed gate that forbids real-value DEX activation
// on an engine that cannot prove α-of-K quorum finality.
//
// THE LAUNCH RULE (non-negotiable): no real-value native DEX may run until a
// value C+D block finalizes ONLY with a quorum — never on a proposer's lone
// self-vote. This gate is the runtime enforcement: the DEX value-activation
// path calls RequireQuorumFinalityForValueDEX before enabling real-value
// trading, and the node FAILS CLOSED (refuses to activate) if the engine is not
// in quorum-finality mode.
package chain
import (
"errors"
"fmt"
)
// ConsensusMode names the finality regime an engine is operating under. It is
// derived from the engine's configuration, not set independently, so it can
// never disagree with the actual finalization path.
type ConsensusMode uint8
const (
// ModeUnknown is the zero value — never a valid mode for value activation.
ModeUnknown ConsensusMode = iota
// ModeSingleValidator is a K==1 engine: the sole validator's own accept IS
// the 1-of-1 quorum (ForceAccept). Correct for --dev / localnet. NOT a
// quorum-finality mode for the purposes of multi-party value safety: a
// single validator can author any history, so real-value DEX across
// independent parties MUST NOT run here.
ModeSingleValidator
// ModeQuorumFinality is a K>1 engine with a vote verifier: a value block
// finalizes ONLY on a verified α-of-K QuorumCert (no self-finality, no
// REJECT→ACCEPT flip). This is the ONLY mode in which real-value native DEX
// may activate.
ModeQuorumFinality
)
// String renders the mode for logs/errors.
func (m ConsensusMode) String() string {
switch m {
case ModeSingleValidator:
return "single-validator"
case ModeQuorumFinality:
return "quorum-finality"
default:
return "unknown"
}
}
// ErrValueDEXRequiresQuorumFinality is returned by
// RequireQuorumFinalityForValueDEX when real-value DEX activation is requested
// on an engine that is not in quorum-finality mode. Fail-closed.
var ErrValueDEXRequiresQuorumFinality = errors.New("chain: real-value native DEX requires quorum-finality consensus (K>1 with a verified alpha-of-K cert path); refusing to activate")
// Mode reports the engine's finality regime, derived from its live
// configuration:
//
// - K<=1 → ModeSingleValidator
// - K>1 with a vote verifier → ModeQuorumFinality
// - K>1 without a vote verifier → ModeUnknown (a misconfiguration the engine
// also refuses to Start; surfaced here so the guard treats it as not-safe).
//
// Because the mode is computed from K and the presence of the verifier — the
// same inputs that select the finalization path in tryFinalizeBlock — it is
// impossible for an engine to report ModeQuorumFinality yet finalize via the
// force path.
func (t *Transitive) Mode() ConsensusMode {
k := t.consensus.K()
if k <= 1 {
return ModeSingleValidator
}
t.mu.RLock()
hasVerifier := t.voteVerifier != nil
t.mu.RUnlock()
if hasVerifier {
return ModeQuorumFinality
}
return ModeUnknown
}
// RequireQuorumFinalityForValueDEX is the fail-closed gate the DEX value-
// activation path MUST call before enabling real-value trading on this engine.
//
// if err := engine.RequireQuorumFinalityForValueDEX(dexNativeValueEnabled); err != nil {
// return err // node refuses to activate value DEX
// }
//
// Semantics:
// - dexNativeValueEnabled == false: no real value at stake; returns nil (the
// gate only governs REAL-value activation).
// - dexNativeValueEnabled == true and Mode() == ModeQuorumFinality: returns
// nil (the only path that permits value DEX).
// - dexNativeValueEnabled == true and any other mode: returns
// ErrValueDEXRequiresQuorumFinality — the node fails closed.
//
// This is intentionally a METHOD on the live engine, not a static config check:
// it reads the engine's actual finality regime, so it cannot be satisfied by a
// config flag that does not match the running consensus.
func (t *Transitive) RequireQuorumFinalityForValueDEX(dexNativeValueEnabled bool) error {
if !dexNativeValueEnabled {
return nil
}
if mode := t.Mode(); mode != ModeQuorumFinality {
return fmt.Errorf("%w: engine mode=%s K=%d", ErrValueDEXRequiresQuorumFinality, mode, t.consensus.K())
}
return nil
}
+356
View File
@@ -0,0 +1,356 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package chain
import (
"context"
"crypto/ed25519"
"crypto/hmac"
"crypto/sha256"
"sync"
"testing"
"time"
"github.com/luxfi/consensus/config"
"github.com/luxfi/consensus/engine/chain/block"
"github.com/luxfi/ids"
)
// -----------------------------------------------------------------------------
// nodeAuth — a deterministic per-node HMAC authenticator for tests.
//
// It models a real vote authenticator without per-test key registration: each
// validator's secret key is derived from its NodeID (key = SHA256("lux-test-vote-key"||nodeID)),
// a vote signature is HMAC-SHA256(key, message), and verification recomputes it.
// This is a genuine MAC (forging a vote requires the node's derived key), so the
// security properties under test (forged/unknown/wrong-position votes fail) hold
// — while letting existing tests keep using ids.GenerateTestNodeID() and only
// attach a signature. It is NOT used in production (the node injects a real
// signature scheme); it exists purely so the engine's quorum-cert path can be
// exercised with arbitrary node ids.
// -----------------------------------------------------------------------------
type nodeAuth struct{}
var testAuth = nodeAuth{}
func (nodeAuth) keyFor(nodeID ids.NodeID) []byte {
h := sha256.Sum256(append([]byte("lux-test-vote-key"), nodeID[:]...))
return h[:]
}
func (a nodeAuth) sign(nodeID ids.NodeID, message []byte) []byte {
mac := hmac.New(sha256.New, a.keyFor(nodeID))
mac.Write(message)
return mac.Sum(nil)
}
// VerifyVote implements VoteVerifier: recompute the node's MAC over message.
func (a nodeAuth) VerifyVote(nodeID ids.NodeID, message []byte, sig []byte) bool {
want := a.sign(nodeID, message)
return hmac.Equal(want, sig)
}
// signerFor returns a VoteSigner bound to nodeID's derived key.
func (a nodeAuth) signerFor(nodeID ids.NodeID) VoteSigner {
return voteSignerFunc(func(message []byte) ([]byte, error) { return a.sign(nodeID, message), nil })
}
// signedVoteForEngine builds a signed accept Vote for a block the engine is
// already tracking, signing the engine's OWN reconstructed position so the
// engine's handleVote verification accepts it. This is the minimal-edit helper
// for migrating legacy tests: replace an unsigned ReceiveVote(Vote{...,Accept:true})
// with ReceiveVote(signedVoteForEngine(e, blockID, nodeID)).
func signedVoteForEngine(e *Transitive, blockID ids.ID, nodeID ids.NodeID) Vote {
e.mu.RLock()
pending, ok := e.pendingBlocks[blockID]
var pos VotePosition
if ok {
pos = e.blockPositionLocked(pending, blockID)
} else {
pos = VotePosition{ChainID: e.chainID, BlockID: blockID}
}
e.mu.RUnlock()
return Vote{
BlockID: blockID,
NodeID: nodeID,
Accept: true,
SignedAt: time.Now(),
Signature: testAuth.sign(nodeID, CanonicalVoteMessage(pos)),
ParentID: pos.ParentID,
Round: pos.Round,
}
}
// newTestEngine builds an UNSTARTED engine wired with the deterministic
// nodeAuth verifier+signer (so the K>1 Start guard is satisfied) plus any extra
// options. The drop-in replacement for `New(opts...)` in legacy lifecycle tests
// that call Start/Stop themselves. The self nodeID is a fresh test id.
func newTestEngine(opts ...Option) *Transitive {
self := ids.GenerateTestNodeID()
all := append([]Option{WithQuorumCert(ids.Empty, self, testAuth, nil, testAuth.signerFor(self))}, opts...)
return New(all...)
}
// newTestEngineParams is newTestEngine with explicit params.
func newTestEngineParams(params config.Parameters, opts ...Option) *Transitive {
self := ids.GenerateTestNodeID()
all := append([]Option{WithParams(params), WithQuorumCert(ids.Empty, self, testAuth, nil, testAuth.signerFor(self))}, opts...)
return New(all...)
}
// signedRejectForEngine builds a signed REJECT vote for a tracked block (signs
// the reject-bound canonical message). The reject counterpart of
// signedVoteForEngine.
func signedRejectForEngine(e *Transitive, blockID ids.ID, nodeID ids.NodeID) Vote {
e.mu.RLock()
pending, ok := e.pendingBlocks[blockID]
var pos VotePosition
if ok {
pos = e.blockPositionLocked(pending, blockID)
} else {
pos = VotePosition{ChainID: e.chainID, BlockID: blockID}
}
e.mu.RUnlock()
return Vote{
BlockID: blockID,
NodeID: nodeID,
Accept: false,
SignedAt: time.Now(),
Signature: testAuth.sign(nodeID, canonicalVoteMessageFor(pos, false)),
ParentID: pos.ParentID,
Round: pos.Round,
}
}
// newAuthEngine builds a started multi-validator engine wired with the
// deterministic nodeAuth verifier+signer for `self`. Used by migrated legacy
// tests that need Start to succeed under the K>1 guard.
func newAuthEngine(t *testing.T, params config.Parameters, self ids.NodeID, opts ...Option) *Transitive {
t.Helper()
all := append([]Option{WithQuorumCert(ids.Empty, self, testAuth, nil, testAuth.signerFor(self))}, opts...)
e := NewWithConfig(Config{Params: params}, all...)
if err := e.Start(context.Background(), true); err != nil {
t.Fatalf("Start: %v", err)
}
t.Cleanup(func() { _ = e.Stop(context.Background()) })
return e
}
// testValidatorSet is a deterministic Ed25519-backed validator key set used to
// exercise the real quorum-cert path in tests. It is a faithful stand-in for a
// node-layer VoteVerifier/VoteSigner: each validator has an Ed25519 keypair,
// signatures are over the canonical vote message, and verification rejects
// unknown validators and bad signatures. Ed25519 is a proven primitive — the
// tests do NOT roll custom crypto.
type testValidatorSet struct {
mu sync.RWMutex
keys map[ids.NodeID]ed25519.PrivateKey
pub map[ids.NodeID]ed25519.PublicKey
ids []ids.NodeID
}
func newTestValidatorSet(n int) *testValidatorSet {
s := &testValidatorSet{
keys: make(map[ids.NodeID]ed25519.PrivateKey, n),
pub: make(map[ids.NodeID]ed25519.PublicKey, n),
}
for i := 0; i < n; i++ {
nodeID := ids.GenerateTestNodeID()
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
panic(err)
}
s.keys[nodeID] = priv
s.pub[nodeID] = pub
s.ids = append(s.ids, nodeID)
}
return s
}
func (s *testValidatorSet) nodeID(i int) ids.NodeID { return s.ids[i] }
// VerifyVote implements VoteVerifier. Unknown node → false (never an error).
func (s *testValidatorSet) VerifyVote(nodeID ids.NodeID, message []byte, sig []byte) bool {
s.mu.RLock()
pub, ok := s.pub[nodeID]
s.mu.RUnlock()
if !ok {
return false
}
return ed25519.Verify(pub, message, sig)
}
// signerFor returns a VoteSigner bound to validator i's key.
func (s *testValidatorSet) signerFor(i int) VoteSigner {
nodeID := s.ids[i]
return voteSignerFunc(func(message []byte) ([]byte, error) {
s.mu.RLock()
priv := s.keys[nodeID]
s.mu.RUnlock()
return ed25519.Sign(priv, message), nil
})
}
// sign produces validator i's signature over the position's canonical message.
func (s *testValidatorSet) sign(i int, pos VotePosition) []byte {
s.mu.RLock()
priv := s.keys[s.ids[i]]
s.mu.RUnlock()
return ed25519.Sign(priv, CanonicalVoteMessage(pos))
}
// signedVote builds validator i's signed accept Vote for a position.
func (s *testValidatorSet) signedVote(i int, pos VotePosition) Vote {
return Vote{
BlockID: pos.BlockID,
NodeID: s.ids[i],
Accept: true,
SignedAt: time.Now(),
Signature: s.sign(i, pos),
ParentID: pos.ParentID,
Round: pos.Round,
}
}
type voteSignerFunc func(message []byte) ([]byte, error)
func (f voteSignerFunc) SignVote(message []byte) ([]byte, error) { return f(message) }
// recordingGossiper captures gossiped certs/votes for assertions and can relay
// them to a set of follower runtimes (to drive an end-to-end multi-node test in
// a single process).
type recordingGossiper struct {
mu sync.Mutex
certs [][]byte
votes [][]byte
certBlock []ids.ID
}
func (g *recordingGossiper) GossipPut(ids.ID, ids.ID, []byte) int { return 0 }
func (g *recordingGossiper) SendPullQuery(ids.ID, ids.ID, ids.ID, []ids.NodeID) int {
return 0
}
func (g *recordingGossiper) SendPushQuery(ids.ID, ids.ID, []byte, []ids.NodeID) int { return 0 }
func (g *recordingGossiper) SendVote(ids.ID, ids.NodeID, ids.ID) error { return nil }
func (g *recordingGossiper) BroadcastVote(_ ids.ID, _ ids.ID, _ ids.ID, voteBytes []byte) int {
g.mu.Lock()
g.votes = append(g.votes, append([]byte(nil), voteBytes...))
g.mu.Unlock()
return 1
}
func (g *recordingGossiper) GossipCert(_ ids.ID, blockID ids.ID, certBytes []byte) error {
g.mu.Lock()
g.certs = append(g.certs, append([]byte(nil), certBytes...))
g.certBlock = append(g.certBlock, blockID)
g.mu.Unlock()
return nil
}
// also satisfy the engine-level CertGossiper used by tryFinalizeBlock.
var _ CertGossiper = (*recordingGossiper)(nil)
var _ QuorumGossiper = (*certQuorumGossiper)(nil)
// certQuorumGossiper adapts recordingGossiper to QuorumGossiper (network-level)
// signature (chainID, networkID, blockID, bytes).
type certQuorumGossiper struct{ rec *recordingGossiper }
func (g *certQuorumGossiper) GossipPut(c, n ids.ID, b []byte) int { return g.rec.GossipPut(c, n, b) }
func (g *certQuorumGossiper) SendPullQuery(c, n, b ids.ID, v []ids.NodeID) int {
return g.rec.SendPullQuery(c, n, b, v)
}
func (g *certQuorumGossiper) SendPushQuery(c, n ids.ID, b []byte, v []ids.NodeID) int {
return g.rec.SendPushQuery(c, n, b, v)
}
func (g *certQuorumGossiper) SendVote(c ids.ID, to ids.NodeID, b ids.ID) error {
return g.rec.SendVote(c, to, b)
}
func (g *certQuorumGossiper) BroadcastVote(c, n, b ids.ID, vb []byte) int {
return g.rec.BroadcastVote(c, n, b, vb)
}
func (g *certQuorumGossiper) GossipCert(c, n, b ids.ID, cb []byte) int {
_ = g.rec.GossipCert(c, b, cb)
return 1
}
// newQuorumEngine builds a started multi-validator engine for validator index
// `self`, wired with the test validator set as verifier+signer and the given
// cert gossiper. K/alpha come from params.
func newQuorumEngine(t *testing.T, params config.Parameters, vs *testValidatorSet, self int, gossiper CertGossiper) (*Transitive, ids.ID) {
t.Helper()
chainID := ids.GenerateTestID()
e := NewWithConfig(Config{Params: params},
WithQuorumCert(chainID, vs.nodeID(self), vs, gossiper, vs.signerFor(self)),
)
if err := e.Start(context.Background(), true); err != nil {
t.Fatalf("Start: %v", err)
}
t.Cleanup(func() { _ = e.Stop(context.Background()) })
return e, chainID
}
// trackProposal inserts a verified own-proposal pending block into the engine
// (as buildBlocksLocked would) so a test can drive votes at it. Returns the
// position votes must bind to.
func trackProposal(e *Transitive, chainID ids.ID, blk *verifyOnceBlock, round uint32) VotePosition {
cb := &Block{
id: blk.id,
parentID: blk.parentID,
height: blk.height,
timestamp: blk.timestamp.Unix(),
data: blk.bytes,
}
_ = e.consensus.AddBlock(context.Background(), cb)
_ = e.consensus.ProcessVote(context.Background(), blk.id, true)
e.mu.Lock()
pb := &PendingBlock{
ConsensusBlock: cb,
VMBlock: blk,
ProposedAt: time.Now(),
VoteCount: 1,
Round: round,
Decided: false,
IsOwnProposal: true,
}
e.pendingBlocks[blk.id] = pb
// Mirror production buildBlocksLocked: record the proposer's OWN signed
// accept vote so the assembled cert includes it.
e.recordOwnVoteLocked(pb, blk.id)
e.mu.Unlock()
return VotePosition{
ChainID: chainID,
Height: blk.height,
Round: round,
BlockID: blk.id,
ParentID: blk.parentID,
}
}
// waitFor polls cond until true or the deadline elapses. Returns cond's final value.
func waitFor(d time.Duration, cond func() bool) bool {
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
if cond() {
return true
}
time.Sleep(5 * time.Millisecond)
}
return cond()
}
// newTestBlock is a small constructor for verifyOnceBlock used across tests.
func newTestBlock(height uint64, parent ids.ID, tag string) *verifyOnceBlock {
return &verifyOnceBlock{
id: ids.GenerateTestID(),
parentID: parent,
height: height,
timestamp: time.Now(),
bytes: []byte(tag),
}
}
// ensure block.Block is referenced (harness builds VM-less engines in some tests).
var _ = block.Block(nil)
+230
View File
@@ -0,0 +1,230 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// quorum_topology.go — the vote/cert distribution handlers that make α-of-K
// finality both SAFE and LIVE.
//
// The topology in one paragraph: a follower verifies a gossiped block and
// BROADCASTS its signed accept vote to ALL validators (followVerifiedBlock,
// integration.go). Every validator feeds incoming signed votes into the engine
// (HandleIncomingVote), which collects them toward a QuorumCert. Whichever node
// first collects α distinct verified votes assembles the cert, GOSSIPS it
// (tryFinalizeBlock), and every node finalizes the block on receipt of that
// verifiable proof (HandleIncomingCert). No node finalizes without a cert
// (safety); the cert reaches everyone via vote-broadcast + cert-gossip + the
// poll-timeout re-request, so finality never hinges on one node's inbound Chits
// (liveness — the proposer-freeze cannot recur).
package chain
import (
"context"
"encoding/binary"
"errors"
"fmt"
"github.com/luxfi/ids"
"github.com/luxfi/log"
)
// ErrVoteWireCorrupt is returned by decodeSignedVote on any structural defect.
var ErrVoteWireCorrupt = errors.New("chain: signed vote wire corrupt")
// signed-vote wire layout (big-endian):
//
// node_id:20
// sig_len:4 sig:sig_len
//
// The signed vote travels with the blockID in the gossip envelope (the network
// message already names the chain + block), so the canonical message a verifier
// rebuilds is derived from the RECEIVER's tracked position for that block — a
// vote cannot smuggle a different position because the signature is checked
// against the receiver's own (chain,height,round,block,parent).
// encodeSignedVote encodes (nodeID, signature) for broadcast.
func encodeSignedVote(nodeID ids.NodeID, sig []byte) ([]byte, error) {
buf := make([]byte, 0, ids.NodeIDLen+4+len(sig))
buf = append(buf, nodeID[:]...)
var u32 [4]byte
binary.BigEndian.PutUint32(u32[:], uint32(len(sig)))
buf = append(buf, u32[:]...)
buf = append(buf, sig...)
return buf, nil
}
// decodeSignedVote is the inverse of encodeSignedVote. Strict trailing-bytes
// policy; fail-closed on short reads.
func decodeSignedVote(data []byte) (ids.NodeID, []byte, error) {
var nodeID ids.NodeID
if len(data) < ids.NodeIDLen+4 {
return nodeID, nil, ErrVoteWireCorrupt
}
copy(nodeID[:], data[:ids.NodeIDLen])
rest := data[ids.NodeIDLen:]
sigLen := binary.BigEndian.Uint32(rest[:4])
rest = rest[4:]
if uint64(sigLen) != uint64(len(rest)) {
return nodeID, nil, fmt.Errorf("%w: sig_len %d != remaining %d", ErrVoteWireCorrupt, sigLen, len(rest))
}
sig := make([]byte, sigLen)
copy(sig, rest)
return nodeID, sig, nil
}
// HandleIncomingVote ingests a signed accept vote received from another
// validator's broadcast. The vote is bound to a blockID (carried by the gossip
// envelope); the engine rebuilds the canonical message from ITS OWN tracked
// position for that block and verifies the signature before counting the vote.
// A vote for a block we are not tracking is dropped (we cannot know its
// position to verify against — the proposer's block gossip carries that, and
// arrives via HandleIncomingBlock).
//
// Returns true iff the vote verified and was counted toward the block's cert.
func (rt *Runtime) HandleIncomingVote(blockID ids.ID, voteBytes []byte) bool {
nodeID, sig, err := decodeSignedVote(voteBytes)
if err != nil {
if !rt.config.Logger.IsZero() {
rt.config.Logger.Debug("incoming vote: decode failed", log.Stringer("blockID", blockID), log.Err(err))
}
return false
}
t := rt.Transitive
t.mu.RLock()
pending, exists := t.pendingBlocks[blockID]
verifier := t.voteVerifier
var pos VotePosition
if exists {
pos = t.blockPositionLocked(pending, blockID)
}
t.mu.RUnlock()
if !exists || verifier == nil {
return false
}
// Verify the signature against OUR position for this block. A vote that
// signed a different position (different height/round/parent) fails here.
if !verifier.VerifyVote(nodeID, CanonicalVoteMessage(pos), sig) {
if !rt.config.Logger.IsZero() {
rt.config.Logger.Debug("incoming vote: signature invalid",
log.Stringer("blockID", blockID), log.Stringer("from", nodeID))
}
return false
}
// Count it. ReceiveVote routes through handleVote, which records the signed
// vote toward the cert and triggers tryFinalizeBlock once alpha is reached.
t.ReceiveVote(Vote{
BlockID: blockID,
NodeID: nodeID,
Accept: true,
Signature: sig,
ParentID: pos.ParentID,
Round: pos.Round,
})
return true
}
// HandleIncomingCert ingests a finality cert gossiped by another validator and,
// if it verifies as a valid α-of-K witness for a block we have verified,
// finalizes that block. This is the SAFE replacement for fast-follow: a
// follower commits a gossiped block ONLY against a verifiable α-of-K proof.
//
// Safety gates:
// - the cert must decode and Verify under our VoteVerifier (α distinct
// correctly-signed accepts over the cert's position),
// - the cert's position chain must match ours,
// - we must have VERIFIED the block locally (it is in pendingBlocks). We do
// NOT accept a block whose contents we have not validated, even with a
// valid cert — the cert proves agreement, local Verify proves validity;
// both are required.
//
// Returns true iff the block was finalized as a result of this cert.
func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
cert, err := UnmarshalQuorumCert(certBytes)
if err != nil {
if !rt.config.Logger.IsZero() {
rt.config.Logger.Debug("incoming cert: decode failed", log.Err(err))
}
return false
}
t := rt.Transitive
t.mu.RLock()
verifier := t.voteVerifier
chainID := t.chainID
pending, exists := t.pendingBlocks[cert.Position.BlockID]
t.mu.RUnlock()
if verifier == nil {
return false
}
// The cert's threshold MUST meet our own α floor — a cert that asserts a
// LOWER threshold than this chain requires is rejected even if its internal
// signatures verify (sub-quorum finality forgery defence, mirrors quasar's
// MinThreshold floor).
if alpha := t.consensus.Alpha(); alpha > 0 && cert.Threshold < uint32(alpha) {
if !rt.config.Logger.IsZero() {
rt.config.Logger.Warn("incoming cert: threshold below chain alpha floor",
log.Stringer("blockID", cert.Position.BlockID),
log.Uint32("certThreshold", cert.Threshold),
log.Int("alphaFloor", alpha))
}
return false
}
if cert.Position.ChainID != chainID {
return false
}
if err := cert.Verify(verifier); err != nil {
if !rt.config.Logger.IsZero() {
rt.config.Logger.Warn("incoming cert: verification failed",
log.Stringer("blockID", cert.Position.BlockID), log.Err(err))
}
return false
}
// Cert is a valid α-of-K finality proof. Finalize the block IF we have
// verified it locally. If we have not yet seen/verified the block, we cannot
// safely Accept it — record nothing; the block gossip will arrive and the
// cert can be re-requested/re-applied. (A production node may also fetch the
// block by ID here; that fetch path is the node layer's responsibility.)
if !exists {
if !rt.config.Logger.IsZero() {
rt.config.Logger.Debug("incoming cert: valid but block not locally verified yet; deferring",
log.Stringer("blockID", cert.Position.BlockID))
}
return false
}
ctx := context.Background()
if c := t.ctx; c != nil {
ctx = c
}
// Record the cert and mark accepted-via-cert in consensus, then run the
// shared finalize path (VM.Accept + SetPreference + pipeline).
t.mu.Lock()
if pending.Decided {
t.mu.Unlock()
return false
}
pending.cert = cert
t.mu.Unlock()
t.consensus.AcceptViaCert(cert.Position.BlockID)
rt.fastFollowMu.Lock()
if cert.Position.Height > rt.fastFollowHeight {
rt.fastFollowHeight = cert.Position.Height
}
rt.fastFollowMu.Unlock()
t.finalizePendingLocked(ctx, cert.Position.BlockID)
if !rt.config.Logger.IsZero() {
rt.config.Logger.Info("finalized block via α-of-K quorum cert",
log.Stringer("blockID", cert.Position.BlockID),
log.Uint64("height", cert.Position.Height),
log.Int("voters", cert.VoterCount()))
}
return true
}