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