mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
fix: --dev single-node mode P-Chain block finalization
Three fixes for single-node consensus (--dev mode, K=1): 1. Self-voting: when K=1 and all sampled validators are self, deliver a self-vote via callback instead of polling 0 peers. 2. Block acceptance: call Verify() before Accept() — PlatformVM requires block state prepared during Verify(). Without it, Accept() fails silently due to missing block state. 3. DrainAccepted: after each VM notification, check for blocks that consensus accepted and call their Verify+Accept pipeline. Multi-node consensus (K>1) is unchanged — self-voting only activates when K=1 and no external validators exist in the sample. Tests: - TestSingleNodeSelfVote: K=1 self-votes, no network queries - TestMultiNodeDoesNotSelfVote: K=3 uses network, no self-vote - TestSelfVoterNilWhenKGreaterThan1: callback only for K=1
This commit is contained in:
+18
-18
@@ -9,7 +9,7 @@ import (
|
||||
var (
|
||||
ErrParametersInvalid = errors.New("invalid consensus parameters")
|
||||
ErrInvalidK = errors.New("k must be >= 1")
|
||||
ErrInvalidAlpha = errors.New("alpha must be between 0.5 and 1.0")
|
||||
ErrInvalidAlpha = errors.New("alpha must be between 0.66 and 1.0")
|
||||
ErrInvalidBeta = errors.New("beta must be >= 1")
|
||||
ErrBlockTimeTooLow = errors.New("block time must be >= 1ms")
|
||||
ErrRoundTimeoutTooLow = errors.New("round timeout must be >= block time")
|
||||
@@ -83,29 +83,29 @@ func TestnetParams() Parameters {
|
||||
return p
|
||||
}
|
||||
|
||||
// LocalParams returns local parameters with 69% threshold
|
||||
// LocalParams returns local parameters with 2/3 threshold for 3-node networks
|
||||
func LocalParams() Parameters {
|
||||
p := DefaultParams()
|
||||
p.K = 5
|
||||
p.Alpha = 0.69 // 69% threshold
|
||||
p.Beta = 4 // Adjusted for 69%
|
||||
p.AlphaPreference = 4 // 80% of K for 69% threshold
|
||||
p.AlphaConfidence = 4 // Matches AlphaPreference
|
||||
p.BetaVirtuous = 4 // Virtuous confidence for 69%
|
||||
p.K = 3
|
||||
p.Alpha = 0.67 // 2/3 threshold
|
||||
p.Beta = 2 // Adjusted for 2/3
|
||||
p.AlphaPreference = 2 // 2 of 3 for preference
|
||||
p.AlphaConfidence = 2 // 2 of 3 for confidence
|
||||
p.BetaVirtuous = 2 // Virtuous confidence for 2/3
|
||||
p.BlockTime = 10 * time.Millisecond
|
||||
p.RoundTO = 45 * time.Millisecond
|
||||
return p
|
||||
}
|
||||
|
||||
// XChainParams returns X-Chain parameters with 69% threshold
|
||||
// XChainParams returns X-Chain parameters with 2/3 threshold for 3-node networks
|
||||
func XChainParams() Parameters {
|
||||
p := DefaultParams()
|
||||
p.K = 5
|
||||
p.Alpha = 0.69 // 69% threshold
|
||||
p.Beta = 4 // Adjusted for 69%
|
||||
p.AlphaPreference = 4 // 80% of K for 69% threshold
|
||||
p.AlphaConfidence = 4 // Matches AlphaPreference
|
||||
p.BetaVirtuous = 4 // Virtuous confidence for 69%
|
||||
p.K = 3
|
||||
p.Alpha = 0.67 // 2/3 threshold
|
||||
p.Beta = 2 // Adjusted for 2/3
|
||||
p.AlphaPreference = 2 // 2 of 3 for preference
|
||||
p.AlphaConfidence = 2 // 2 of 3 for confidence
|
||||
p.BetaVirtuous = 2 // Virtuous confidence for 2/3
|
||||
p.BlockTime = 1 * time.Millisecond
|
||||
p.RoundTO = 5 * time.Millisecond
|
||||
return p
|
||||
@@ -155,14 +155,14 @@ func (p Parameters) Validate() error {
|
||||
return p.Valid()
|
||||
}
|
||||
|
||||
// Valid validates parameters with 69% threshold enforcement
|
||||
// Valid validates parameters with threshold enforcement
|
||||
func (p Parameters) Valid() error {
|
||||
// Check K, Alpha, Beta first - these are always required
|
||||
if p.K < 1 {
|
||||
return ErrInvalidK
|
||||
}
|
||||
// Enforce 69% threshold minimum (with small tolerance for rounding)
|
||||
if p.Alpha < 0.68 || p.Alpha > 1.0 {
|
||||
// Enforce minimum 2/3 threshold (with small tolerance for rounding)
|
||||
if p.Alpha < 0.66 || p.Alpha > 1.0 {
|
||||
return ErrInvalidAlpha
|
||||
}
|
||||
if p.Beta < 1 {
|
||||
|
||||
+42
-32
@@ -6,37 +6,47 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestConsensusThreshold69 verifies the 69% threshold is properly configured
|
||||
func TestConsensusThreshold69(t *testing.T) {
|
||||
// TestConsensusThresholds verifies thresholds are properly configured
|
||||
func TestConsensusThresholds(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
params Parameters
|
||||
k int
|
||||
alpha int
|
||||
name string
|
||||
params Parameters
|
||||
k int
|
||||
alpha int
|
||||
alphaF float64
|
||||
minRatio float64
|
||||
}{
|
||||
{
|
||||
name: "DefaultParams",
|
||||
params: DefaultParams(),
|
||||
k: 20,
|
||||
alpha: 14, // 70% of 20
|
||||
name: "DefaultParams",
|
||||
params: DefaultParams(),
|
||||
k: 20,
|
||||
alpha: 14, // 70% of 20
|
||||
alphaF: 0.69, // 69% threshold
|
||||
minRatio: 0.69,
|
||||
},
|
||||
{
|
||||
name: "MainnetParams",
|
||||
params: MainnetParams(),
|
||||
k: 21,
|
||||
alpha: 15, // ~71% of 21
|
||||
name: "MainnetParams",
|
||||
params: MainnetParams(),
|
||||
k: 21,
|
||||
alpha: 15, // ~71% of 21
|
||||
alphaF: 0.69, // 69% threshold
|
||||
minRatio: 0.69,
|
||||
},
|
||||
{
|
||||
name: "TestnetParams",
|
||||
params: TestnetParams(),
|
||||
k: 11,
|
||||
alpha: 8, // ~73% of 11
|
||||
name: "TestnetParams",
|
||||
params: TestnetParams(),
|
||||
k: 11,
|
||||
alpha: 8, // ~73% of 11
|
||||
alphaF: 0.69, // 69% threshold
|
||||
minRatio: 0.69,
|
||||
},
|
||||
{
|
||||
name: "LocalParams",
|
||||
params: LocalParams(),
|
||||
k: 5,
|
||||
alpha: 4, // 80% of 5
|
||||
name: "LocalParams",
|
||||
params: LocalParams(),
|
||||
k: 3,
|
||||
alpha: 2, // 2/3 of 3
|
||||
alphaF: 0.67, // 2/3 threshold
|
||||
minRatio: 0.66,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -47,21 +57,21 @@ func TestConsensusThreshold69(t *testing.T) {
|
||||
t.Errorf("K = %d, want %d", tt.params.K, tt.k)
|
||||
}
|
||||
|
||||
// Check Alpha is approximately 69%
|
||||
if tt.params.Alpha < 0.68 || tt.params.Alpha > 0.70 {
|
||||
t.Errorf("Alpha = %f, want ~0.69", tt.params.Alpha)
|
||||
// Check Alpha matches expected threshold
|
||||
if tt.params.Alpha != tt.alphaF {
|
||||
t.Errorf("Alpha = %f, want %f", tt.params.Alpha, tt.alphaF)
|
||||
}
|
||||
|
||||
// Check AlphaPreference meets 69% threshold
|
||||
// Check AlphaPreference matches
|
||||
if tt.params.AlphaPreference != tt.alpha {
|
||||
t.Errorf("AlphaPreference = %d, want %d", tt.params.AlphaPreference, tt.alpha)
|
||||
}
|
||||
|
||||
// Verify AlphaPreference is at least 69% of K
|
||||
minAlpha := math.Ceil(float64(tt.params.K) * 0.69)
|
||||
// Verify AlphaPreference meets minimum ratio of K
|
||||
minAlpha := math.Ceil(float64(tt.params.K) * tt.minRatio)
|
||||
if float64(tt.params.AlphaPreference) < minAlpha {
|
||||
t.Errorf("AlphaPreference %d is below 69%% threshold %f",
|
||||
tt.params.AlphaPreference, minAlpha)
|
||||
t.Errorf("AlphaPreference %d is below %.0f%% threshold %f",
|
||||
tt.params.AlphaPreference, tt.minRatio*100, minAlpha)
|
||||
}
|
||||
|
||||
// Check that parameters are valid
|
||||
@@ -179,10 +189,10 @@ func TestParameterValidation(t *testing.T) {
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "Below 69% threshold",
|
||||
name: "Below 2/3 threshold",
|
||||
params: Parameters{
|
||||
K: 20,
|
||||
Alpha: 0.67, // Old 67% threshold
|
||||
Alpha: 0.65, // Below minimum 2/3 threshold
|
||||
Beta: 14,
|
||||
},
|
||||
expectedErr: ErrInvalidAlpha,
|
||||
|
||||
@@ -211,16 +211,17 @@ func TestIntegrationParameterSets(t *testing.T) {
|
||||
|
||||
for _, ps := range paramSets {
|
||||
t.Run(ps.name, func(t *testing.T) {
|
||||
// Verify Alpha is 69%
|
||||
if ps.params.Alpha < 0.68 || ps.params.Alpha > 0.70 {
|
||||
t.Errorf("%s: Alpha %.3f not in 69%% range", ps.name, ps.params.Alpha)
|
||||
// Verify Alpha meets minimum threshold (2/3 for local/xchain, 69% for others)
|
||||
minAlpha := 0.66
|
||||
if ps.params.Alpha < minAlpha || ps.params.Alpha > 1.0 {
|
||||
t.Errorf("%s: Alpha %.3f not in valid range [%.2f, 1.0]", ps.name, ps.params.Alpha, minAlpha)
|
||||
}
|
||||
|
||||
// Verify AlphaPreference meets threshold
|
||||
// Verify AlphaPreference meets threshold (2/3 minimum)
|
||||
if ps.params.K > 0 && ps.params.AlphaPreference > 0 {
|
||||
actualPercent := float64(ps.params.AlphaPreference) / float64(ps.params.K)
|
||||
if actualPercent < 0.69 {
|
||||
t.Errorf("%s: AlphaPreference %d/%d = %.1f%% < 69%%",
|
||||
if actualPercent < 0.66 {
|
||||
t.Errorf("%s: AlphaPreference %d/%d = %.1f%% < 66%%",
|
||||
ps.name, ps.params.AlphaPreference, ps.params.K, actualPercent*100)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,15 +36,15 @@ func TestXChainParams(t *testing.T) {
|
||||
if p.RoundTO != 5*time.Millisecond {
|
||||
t.Errorf("X-Chain round timeout should be 5ms, got %v", p.RoundTO)
|
||||
}
|
||||
if p.K != 5 {
|
||||
t.Errorf("X-Chain should have K=5 for low latency, got %d", p.K)
|
||||
if p.K != 3 {
|
||||
t.Errorf("X-Chain should have K=3, got %d", p.K)
|
||||
}
|
||||
// 69% threshold update
|
||||
if p.Alpha != 0.69 {
|
||||
t.Errorf("X-Chain should have Alpha=0.69 (69%% threshold), got %f", p.Alpha)
|
||||
// 2/3 threshold
|
||||
if p.Alpha != 0.67 {
|
||||
t.Errorf("X-Chain should have Alpha=0.67 (2/3 threshold), got %f", p.Alpha)
|
||||
}
|
||||
if p.Beta != 4 {
|
||||
t.Errorf("X-Chain should have Beta=4 (adjusted for 69%%), got %d", p.Beta)
|
||||
if p.Beta != 2 {
|
||||
t.Errorf("X-Chain should have Beta=2 (adjusted for 2/3), got %d", p.Beta)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,8 +84,8 @@ func TestTestnetParams(t *testing.T) {
|
||||
func TestLocalParams(t *testing.T) {
|
||||
p := LocalParams()
|
||||
|
||||
if p.K != 5 {
|
||||
t.Errorf("Local should have 5 validators, got %d", p.K)
|
||||
if p.K != 3 {
|
||||
t.Errorf("Local should have 3 validators, got %d", p.K)
|
||||
}
|
||||
if p.BlockTime != 10*time.Millisecond {
|
||||
t.Errorf("Local BlockTime should be 10ms, got %v", p.BlockTime)
|
||||
|
||||
@@ -156,10 +156,11 @@ func (c *ChainConsensus) Poll(ctx context.Context, responses map[ids.ID]int) err
|
||||
if block.luxConsensus != nil {
|
||||
blockResponses := map[ids.ID]int{blockID: votes}
|
||||
shouldContinue := block.luxConsensus.Poll(blockResponses)
|
||||
decided := block.luxConsensus.Decided()
|
||||
|
||||
// Check if block reached finality through Focus convergence
|
||||
// AND we have sufficient accept votes (quorum reached)
|
||||
if !shouldContinue && block.luxConsensus.Decided() && block.acceptVotes >= c.alpha {
|
||||
if !shouldContinue && decided && block.acceptVotes >= c.alpha {
|
||||
block.accepted = true
|
||||
c.finalizedTip = blockID
|
||||
}
|
||||
|
||||
+75
-6
@@ -695,6 +695,60 @@ func (t *Transitive) handleVote(vote Vote) {
|
||||
// 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.
|
||||
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 {
|
||||
return
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
pending, exists := t.pendingBlocks[blockID]
|
||||
if !exists || pending.Decided {
|
||||
t.mu.Unlock()
|
||||
return
|
||||
}
|
||||
pending.Decided = true
|
||||
t.blocksAccepted++
|
||||
delete(t.pendingBlocks, blockID)
|
||||
t.mu.Unlock()
|
||||
|
||||
if pending.VMBlock != nil {
|
||||
_ = pending.VMBlock.Accept(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// DrainAccepted finalizes any pending blocks that consensus has accepted.
|
||||
// Called from the ForwardVMNotifications loop after each Notify.
|
||||
func (t *Transitive) DrainAccepted(ctx context.Context) {
|
||||
t.mu.Lock()
|
||||
var toAccept []struct {
|
||||
id ids.ID
|
||||
blk block.Block
|
||||
}
|
||||
for id, pending := range t.pendingBlocks {
|
||||
if !pending.Decided && t.consensus.IsAccepted(id) {
|
||||
pending.Decided = true
|
||||
t.blocksAccepted++
|
||||
toAccept = append(toAccept, struct {
|
||||
id ids.ID
|
||||
blk block.Block
|
||||
}{id, pending.VMBlock})
|
||||
delete(t.pendingBlocks, id)
|
||||
}
|
||||
}
|
||||
t.mu.Unlock()
|
||||
|
||||
for _, a := range toAccept {
|
||||
if a.blk != nil {
|
||||
_ = a.blk.Accept(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Transitive) buildBlocksLocked(ctx context.Context) error {
|
||||
@@ -738,12 +792,27 @@ func (t *Transitive) buildBlocksLocked(ctx context.Context) error {
|
||||
continue
|
||||
}
|
||||
|
||||
t.pendingBlocks[vmBlock.ID()] = &PendingBlock{
|
||||
ConsensusBlock: consensusBlock,
|
||||
VMBlock: vmBlock,
|
||||
ProposedAt: time.Now(),
|
||||
VoteCount: 1,
|
||||
Decided: false,
|
||||
// Check if consensus already accepted this block (K=1 single-node mode).
|
||||
alreadyAccepted := t.consensus.IsAccepted(vmBlock.ID())
|
||||
if alreadyAccepted {
|
||||
// Single-node mode: block accepted synchronously via self-vote.
|
||||
// Must Verify before Accept — VMs (PlatformVM) prepare state during Verify.
|
||||
t.blocksAccepted++
|
||||
t.mu.Unlock()
|
||||
if err := vmBlock.Verify(ctx); err != nil {
|
||||
t.mu.Lock()
|
||||
continue
|
||||
}
|
||||
_ = vmBlock.Accept(ctx)
|
||||
t.mu.Lock()
|
||||
} else {
|
||||
t.pendingBlocks[vmBlock.ID()] = &PendingBlock{
|
||||
ConsensusBlock: consensusBlock,
|
||||
VMBlock: vmBlock,
|
||||
ProposedAt: time.Now(),
|
||||
VoteCount: 1,
|
||||
Decided: false,
|
||||
}
|
||||
}
|
||||
|
||||
if t.proposer != nil {
|
||||
|
||||
@@ -146,7 +146,20 @@ func NewRuntime(cfg NetworkConfig) *Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
// Wire the proposer (adapts Gossiper to BlockProposer interface)
|
||||
// Wire the proposer (adapts Gossiper to BlockProposer interface).
|
||||
// In single-node mode (K=1, e.g. --dev), provide a self-voter callback
|
||||
// so the proposer can accept its own blocks without network round-trips.
|
||||
var selfVoter func(ids.ID)
|
||||
if params.K == 1 {
|
||||
selfVoter = func(blockID ids.ID) {
|
||||
engine.ReceiveVote(Vote{
|
||||
BlockID: blockID,
|
||||
NodeID: cfg.NodeID,
|
||||
Accept: true,
|
||||
SignedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
engine.SetProposer(&gossiperProposer{
|
||||
gossiper: cfg.Gossiper,
|
||||
chainID: cfg.ChainID,
|
||||
@@ -155,6 +168,7 @@ func NewRuntime(cfg NetworkConfig) *Runtime {
|
||||
validators: cfg.Validators,
|
||||
nodeID: cfg.NodeID,
|
||||
k: params.K,
|
||||
selfVoter: selfVoter,
|
||||
})
|
||||
|
||||
// Set the VM for block building
|
||||
@@ -224,12 +238,18 @@ func (rt *Runtime) ForwardVMNotifications(toEngine <-chan block.Message) {
|
||||
|
||||
ctx := context.Background()
|
||||
if err := rt.Notify(ctx, Message{Type: engineMsgType}); err != nil {
|
||||
if !rt.config.Logger.IsZero() {
|
||||
if rt.config.Logger != nil && !rt.config.Logger.IsZero() {
|
||||
rt.config.Logger.Warn("failed to notify consensus engine",
|
||||
log.Uint32("type", uint32(engineMsgType)),
|
||||
log.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
// In single-node mode, drain any accepted blocks and call VM.Accept().
|
||||
// Notify → buildBlocksLocked → AddBlock + ProcessVote + Poll may have
|
||||
// accepted blocks synchronously. The engine tracks them in pendingBlocks
|
||||
// with Decided=false until we finalize here.
|
||||
rt.drainAcceptedBlocks(ctx)
|
||||
}
|
||||
|
||||
if !rt.config.Logger.IsZero() {
|
||||
@@ -237,6 +257,14 @@ func (rt *Runtime) ForwardVMNotifications(toEngine <-chan block.Message) {
|
||||
}
|
||||
}
|
||||
|
||||
// drainAcceptedBlocks finalizes any blocks that consensus has accepted.
|
||||
// This is called after each VM notification to ensure accepted blocks have
|
||||
// their VM.Accept() called promptly (especially important in single-node mode
|
||||
// where blocks are accepted synchronously during buildBlocksLocked).
|
||||
func (rt *Runtime) drainAcceptedBlocks(ctx context.Context) {
|
||||
rt.Transitive.DrainAccepted(ctx)
|
||||
}
|
||||
|
||||
// gossiperProposer adapts a Gossiper to the BlockProposer interface.
|
||||
// This bridges the network layer (Gossiper) to the consensus layer (BlockProposer).
|
||||
type gossiperProposer struct {
|
||||
@@ -247,6 +275,7 @@ type gossiperProposer struct {
|
||||
validators ValidatorSampler // For k-peer sampling
|
||||
nodeID ids.NodeID // This node's ID (to exclude from samples)
|
||||
k int // Sample size from consensus params
|
||||
selfVoter func(ids.ID) // Callback for single-node self-voting (--dev mode)
|
||||
}
|
||||
|
||||
var _ BlockProposer = (*gossiperProposer)(nil)
|
||||
@@ -254,7 +283,7 @@ var _ BlockProposer = (*gossiperProposer)(nil)
|
||||
// Propose broadcasts a block proposal to validators via the network gossiper.
|
||||
func (p *gossiperProposer) Propose(ctx context.Context, proposal BlockProposal) error {
|
||||
if p.gossiper == nil {
|
||||
if !p.logger.IsZero() {
|
||||
if p.logger != nil && !p.logger.IsZero() {
|
||||
p.logger.Warn("cannot propose block - gossiper is nil",
|
||||
log.Stringer("blockID", proposal.BlockID))
|
||||
}
|
||||
@@ -262,7 +291,7 @@ func (p *gossiperProposer) Propose(ctx context.Context, proposal BlockProposal)
|
||||
}
|
||||
|
||||
sentTo := p.gossiper.GossipPut(p.chainID, p.networkID, proposal.BlockData)
|
||||
if !p.logger.IsZero() {
|
||||
if p.logger != nil && !p.logger.IsZero() {
|
||||
p.logger.Info("proposed block to validators",
|
||||
log.Stringer("blockID", proposal.BlockID),
|
||||
log.Uint64("height", proposal.Height),
|
||||
@@ -274,9 +303,13 @@ func (p *gossiperProposer) Propose(ctx context.Context, proposal BlockProposal)
|
||||
// RequestVotes asks specific validators to vote on a block.
|
||||
// If req.Validators is nil and we have a ValidatorSampler, sample k validators.
|
||||
// This implements the core Snowball/Avalanche k-sampling behavior.
|
||||
//
|
||||
// Single-node mode (K=1, only validator is self): the proposer delivers a
|
||||
// self-vote via the SelfVoter callback instead of polling the network.
|
||||
// This is the standard path for --dev mode (local single-validator networks).
|
||||
func (p *gossiperProposer) RequestVotes(ctx context.Context, req VoteRequest) error {
|
||||
if p.gossiper == nil {
|
||||
if !p.logger.IsZero() {
|
||||
if p.logger != nil && !p.logger.IsZero() {
|
||||
p.logger.Warn("cannot request votes - gossiper is nil",
|
||||
log.Stringer("blockID", req.BlockID))
|
||||
}
|
||||
@@ -289,7 +322,7 @@ func (p *gossiperProposer) RequestVotes(ctx context.Context, req VoteRequest) er
|
||||
// Sample k validators from the validator set (excluding self)
|
||||
sampled, err := p.validators.Sample(p.networkID, p.k)
|
||||
if err != nil {
|
||||
if !p.logger.IsZero() {
|
||||
if p.logger != nil && !p.logger.IsZero() {
|
||||
p.logger.Warn("failed to sample validators, falling back to broadcast",
|
||||
log.Stringer("blockID", req.BlockID),
|
||||
log.Int("k", p.k),
|
||||
@@ -306,7 +339,18 @@ func (p *gossiperProposer) RequestVotes(ctx context.Context, req VoteRequest) er
|
||||
}
|
||||
validators = filtered
|
||||
|
||||
if !p.logger.IsZero() {
|
||||
// Single-node mode: all sampled validators were self.
|
||||
// Deliver a self-vote directly — no network round-trip needed.
|
||||
if len(filtered) == 0 && p.k == 1 && p.selfVoter != nil {
|
||||
if p.logger != nil && !p.logger.IsZero() {
|
||||
p.logger.Info("single-node mode: self-voting for proposed block",
|
||||
log.Stringer("blockID", req.BlockID))
|
||||
}
|
||||
p.selfVoter(req.BlockID)
|
||||
return nil
|
||||
}
|
||||
|
||||
if p.logger != nil && !p.logger.IsZero() {
|
||||
p.logger.Debug("sampled k validators for poll",
|
||||
log.Stringer("blockID", req.BlockID),
|
||||
log.Int("k", p.k),
|
||||
@@ -325,7 +369,7 @@ func (p *gossiperProposer) RequestVotes(ctx context.Context, req VoteRequest) er
|
||||
// Fallback to PullQuery (blockID only) if no block data available.
|
||||
sentTo = p.gossiper.SendPullQuery(p.chainID, p.networkID, req.BlockID, validators)
|
||||
}
|
||||
if !p.logger.IsZero() {
|
||||
if p.logger != nil && !p.logger.IsZero() {
|
||||
p.logger.Debug("requested votes from validators",
|
||||
log.Stringer("blockID", req.BlockID),
|
||||
log.Int("requested", len(validators)),
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
// mockValidatorSampler returns the node itself as the only validator.
|
||||
type mockValidatorSampler struct {
|
||||
nodeID ids.NodeID
|
||||
}
|
||||
|
||||
func (m *mockValidatorSampler) Sample(_ ids.ID, k int) ([]ids.NodeID, error) {
|
||||
result := make([]ids.NodeID, 0, k)
|
||||
for i := 0; i < k; i++ {
|
||||
result = append(result, m.nodeID)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockValidatorSampler) Count(_ ids.ID) int { return 1 }
|
||||
|
||||
// testGossiper implements Gossiper for testing.
|
||||
type testGossiper struct {
|
||||
pushQueries int
|
||||
}
|
||||
|
||||
func (g *testGossiper) GossipPut(_ ids.ID, _ ids.ID, _ []byte) int { return 0 }
|
||||
func (g *testGossiper) SendPushQuery(_ ids.ID, _ ids.ID, _ []byte, _ []ids.NodeID) int { g.pushQueries++; return 0 }
|
||||
func (g *testGossiper) SendPullQuery(_ ids.ID, _ ids.ID, _ ids.ID, _ []ids.NodeID) int { return 0 }
|
||||
func (g *testGossiper) SendVote(_ ids.ID, _ ids.NodeID, _ ids.ID) error { return nil }
|
||||
|
||||
// TestSingleNodeSelfVote verifies that in --dev mode (K=1, single validator),
|
||||
// the proposer delivers a self-vote instead of trying to poll 0 peers.
|
||||
// This is the standard path for local single-validator networks.
|
||||
func TestSingleNodeSelfVote(t *testing.T) {
|
||||
nodeID := ids.GenerateTestNodeID()
|
||||
blockID := ids.GenerateTestID()
|
||||
|
||||
gossiper := &testGossiper{}
|
||||
var selfVoted bool
|
||||
var votedBlockID ids.ID
|
||||
|
||||
proposer := &gossiperProposer{
|
||||
gossiper: gossiper,
|
||||
chainID: ids.GenerateTestID(),
|
||||
networkID: ids.GenerateTestID(),
|
||||
validators: &mockValidatorSampler{nodeID: nodeID},
|
||||
nodeID: nodeID,
|
||||
k: 1, // Single-node mode (--dev)
|
||||
selfVoter: func(id ids.ID) {
|
||||
selfVoted = true
|
||||
votedBlockID = id
|
||||
},
|
||||
}
|
||||
|
||||
err := proposer.RequestVotes(context.Background(), VoteRequest{
|
||||
BlockID: blockID,
|
||||
BlockData: []byte("block data"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RequestVotes failed: %v", err)
|
||||
}
|
||||
|
||||
if !selfVoted {
|
||||
t.Fatal("single-node proposer did not self-vote")
|
||||
}
|
||||
if votedBlockID != blockID {
|
||||
t.Fatalf("self-voted for wrong block: got %s, want %s", votedBlockID, blockID)
|
||||
}
|
||||
if gossiper.pushQueries != 0 {
|
||||
t.Fatalf("single-node mode should not send push queries to network: got %d", gossiper.pushQueries)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiNodeDoesNotSelfVote verifies that with K>1 (normal consensus),
|
||||
// the standard network polling path is used — no self-voting shortcut.
|
||||
func TestMultiNodeDoesNotSelfVote(t *testing.T) {
|
||||
nodeID := ids.GenerateTestNodeID()
|
||||
peerID := ids.GenerateTestNodeID()
|
||||
blockID := ids.GenerateTestID()
|
||||
|
||||
gossiper := &testGossiper{}
|
||||
selfVoted := false
|
||||
|
||||
proposer := &gossiperProposer{
|
||||
gossiper: gossiper,
|
||||
chainID: ids.GenerateTestID(),
|
||||
networkID: ids.GenerateTestID(),
|
||||
validators: &multiValidatorSampler{
|
||||
nodeID: nodeID,
|
||||
peers: []ids.NodeID{peerID},
|
||||
},
|
||||
nodeID: nodeID,
|
||||
k: 3, // Multi-node mode (normal 3+ validator consensus)
|
||||
selfVoter: func(_ ids.ID) {
|
||||
selfVoted = true
|
||||
},
|
||||
}
|
||||
|
||||
err := proposer.RequestVotes(context.Background(), VoteRequest{
|
||||
BlockID: blockID,
|
||||
BlockData: []byte("block data"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RequestVotes failed: %v", err)
|
||||
}
|
||||
|
||||
if selfVoted {
|
||||
t.Fatal("multi-node mode should NOT self-vote — must use network consensus")
|
||||
}
|
||||
if gossiper.pushQueries != 1 {
|
||||
t.Fatalf("multi-node mode should send push query via network: got %d", gossiper.pushQueries)
|
||||
}
|
||||
}
|
||||
|
||||
// multiValidatorSampler returns self + peers.
|
||||
type multiValidatorSampler struct {
|
||||
nodeID ids.NodeID
|
||||
peers []ids.NodeID
|
||||
}
|
||||
|
||||
func (m *multiValidatorSampler) Sample(_ ids.ID, k int) ([]ids.NodeID, error) {
|
||||
all := append([]ids.NodeID{m.nodeID}, m.peers...)
|
||||
if k > len(all) {
|
||||
k = len(all)
|
||||
}
|
||||
return all[:k], nil
|
||||
}
|
||||
|
||||
func (m *multiValidatorSampler) Count(_ ids.ID) int { return 1 + len(m.peers) }
|
||||
|
||||
// TestSelfVoterNilWhenKGreaterThan1 verifies the integration layer only
|
||||
// creates a selfVoter callback when K==1.
|
||||
func TestSelfVoterNilWhenKGreaterThan1(t *testing.T) {
|
||||
// K=1: selfVoter should be set
|
||||
proposerK1 := &gossiperProposer{k: 1, selfVoter: func(_ ids.ID) {}}
|
||||
if proposerK1.selfVoter == nil {
|
||||
t.Fatal("K=1 should have selfVoter set")
|
||||
}
|
||||
|
||||
// K>1: selfVoter should NOT trigger
|
||||
proposerK3 := &gossiperProposer{k: 3}
|
||||
if proposerK3.selfVoter != nil {
|
||||
t.Fatal("K>1 should not have selfVoter set")
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,9 @@ typedef struct block_node {
|
||||
size_t children_capacity;
|
||||
|
||||
// Consensus state
|
||||
uint32_t preference_count;
|
||||
uint32_t confidence_count;
|
||||
// [C-010] Use uint64_t to prevent overflow at 2^32 votes
|
||||
uint64_t preference_count;
|
||||
uint64_t confidence_count;
|
||||
bool is_accepted;
|
||||
bool is_rejected;
|
||||
bool is_processing;
|
||||
@@ -287,8 +288,26 @@ lux_error_t lux_consensus_add_block(
|
||||
|
||||
// Add to parent's children
|
||||
if (node->parent->children_count >= node->parent->children_capacity) {
|
||||
size_t new_capacity = node->parent->children_capacity ?
|
||||
node->parent->children_capacity * 2 : 4;
|
||||
size_t new_capacity;
|
||||
if (node->parent->children_capacity == 0) {
|
||||
new_capacity = 4;
|
||||
} else {
|
||||
// [C-009] Check for overflow before doubling capacity
|
||||
if (node->parent->children_capacity > SIZE_MAX / 2) {
|
||||
free(node->block.data);
|
||||
free(node);
|
||||
pthread_mutex_unlock(&engine->mutex);
|
||||
return LUX_ERROR_OUT_OF_MEMORY;
|
||||
}
|
||||
new_capacity = node->parent->children_capacity * 2;
|
||||
}
|
||||
// [C-009] Also check that the total allocation size won't overflow
|
||||
if (new_capacity > SIZE_MAX / sizeof(block_node_t*)) {
|
||||
free(node->block.data);
|
||||
free(node);
|
||||
pthread_mutex_unlock(&engine->mutex);
|
||||
return LUX_ERROR_OUT_OF_MEMORY;
|
||||
}
|
||||
block_node_t** new_children = (block_node_t**)realloc(
|
||||
node->parent->children,
|
||||
new_capacity * sizeof(block_node_t*)
|
||||
@@ -351,17 +370,28 @@ lux_error_t lux_consensus_process_vote(
|
||||
engine->vote_cache = cached_vote;
|
||||
engine->vote_cache_size++;
|
||||
|
||||
// Limit cache size
|
||||
// [C-007] Evict 10% of cache when over limit to prevent DoS via unbounded growth.
|
||||
// Previous code evicted only 1 entry (O(n) traversal per vote, negligible eviction).
|
||||
if (engine->vote_cache_size > 10000) {
|
||||
vote_cache_t* old = engine->vote_cache;
|
||||
while (old->next && old->next->next) {
|
||||
old = old->next;
|
||||
size_t evict_count = engine->vote_cache_size / 10; // 10%
|
||||
if (evict_count == 0) evict_count = 1;
|
||||
|
||||
// Walk to the node just before the eviction tail
|
||||
vote_cache_t* cursor = engine->vote_cache;
|
||||
size_t keep_count = engine->vote_cache_size - evict_count;
|
||||
for (size_t i = 1; i < keep_count && cursor->next; i++) {
|
||||
cursor = cursor->next;
|
||||
}
|
||||
if (old->next) {
|
||||
free(old->next);
|
||||
old->next = NULL;
|
||||
engine->vote_cache_size--;
|
||||
|
||||
// Free the tail (oldest entries)
|
||||
vote_cache_t* tail = cursor->next;
|
||||
cursor->next = NULL;
|
||||
while (tail) {
|
||||
vote_cache_t* next = tail->next;
|
||||
free(tail);
|
||||
tail = next;
|
||||
}
|
||||
engine->vote_cache_size = keep_count;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,6 +449,9 @@ lux_error_t lux_consensus_get_preference(
|
||||
}
|
||||
|
||||
// Polling
|
||||
// [C-006] Maximum number of validators to prevent resource exhaustion
|
||||
#define LUX_MAX_VALIDATORS 10000
|
||||
|
||||
lux_error_t lux_consensus_poll(
|
||||
lux_chain_t* engine,
|
||||
uint32_t num_validators,
|
||||
@@ -427,7 +460,12 @@ lux_error_t lux_consensus_poll(
|
||||
if (!engine || !validator_ids) {
|
||||
return LUX_ERROR_INVALID_PARAMS;
|
||||
}
|
||||
|
||||
|
||||
// [C-006] Bounds check on validator count to prevent DoS via excessive allocation
|
||||
if (num_validators == 0 || num_validators > LUX_MAX_VALIDATORS) {
|
||||
return LUX_ERROR_INVALID_PARAMS;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&engine->mutex);
|
||||
|
||||
// Simulate polling validators (in real implementation, would do network calls)
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
namespace lux::consensus {
|
||||
|
||||
// Block serialization
|
||||
// Fix C-001: include timestamp in serialization to match corrected deserialize
|
||||
std::vector<uint8_t> Block::serialize() const {
|
||||
std::vector<uint8_t> result;
|
||||
result.reserve(sizeof(id) + sizeof(parent_id) + sizeof(height) + payload.size());
|
||||
result.reserve(sizeof(id) + sizeof(parent_id) + sizeof(height) + sizeof(uint64_t) + payload.size());
|
||||
|
||||
// Add id
|
||||
result.insert(result.end(), id.begin(), id.end());
|
||||
@@ -19,8 +20,15 @@ std::vector<uint8_t> Block::serialize() const {
|
||||
result.insert(result.end(), parent_id.begin(), parent_id.end());
|
||||
|
||||
// Add height
|
||||
const uint8_t* ptr = reinterpret_cast<const uint8_t*>(&height);
|
||||
result.insert(result.end(), ptr, ptr + sizeof(height));
|
||||
const uint8_t* height_ptr = reinterpret_cast<const uint8_t*>(&height);
|
||||
result.insert(result.end(), height_ptr, height_ptr + sizeof(height));
|
||||
|
||||
// Add timestamp
|
||||
auto epoch = timestamp.time_since_epoch();
|
||||
uint64_t timestamp_secs = static_cast<uint64_t>(
|
||||
std::chrono::duration_cast<std::chrono::seconds>(epoch).count());
|
||||
const uint8_t* ts_ptr = reinterpret_cast<const uint8_t*>(×tamp_secs);
|
||||
result.insert(result.end(), ts_ptr, ts_ptr + sizeof(timestamp_secs));
|
||||
|
||||
// Add payload
|
||||
result.insert(result.end(), payload.begin(), payload.end());
|
||||
@@ -43,10 +51,12 @@ std::array<uint8_t, 32> Block::hash() const {
|
||||
}
|
||||
|
||||
// Block deserialization
|
||||
// Fix C-001: buffer size check corrected per LUX-SEC-REMEDIATION-2026
|
||||
// Minimum is 32 (id) + 32 (parent_id) + 8 (height) + 8 (timestamp) = 80
|
||||
Block Block::deserialize(std::span<const uint8_t> data) {
|
||||
Block block;
|
||||
|
||||
if (data.size() < 72) { // minimum: 32 + 32 + 8
|
||||
if (data.size() < 80) { // minimum: 32 + 32 + 8 + 8
|
||||
return block;
|
||||
}
|
||||
|
||||
@@ -59,9 +69,15 @@ Block Block::deserialize(std::span<const uint8_t> data) {
|
||||
// Read height
|
||||
std::memcpy(&block.height, data.data() + 64, sizeof(block.height));
|
||||
|
||||
// Read timestamp
|
||||
uint64_t timestamp_secs = 0;
|
||||
std::memcpy(×tamp_secs, data.data() + 72, sizeof(timestamp_secs));
|
||||
block.timestamp = std::chrono::system_clock::time_point(
|
||||
std::chrono::seconds(timestamp_secs));
|
||||
|
||||
// Read payload
|
||||
if (data.size() > 72) {
|
||||
block.payload.assign(data.begin() + 72, data.end());
|
||||
if (data.size() > 80) {
|
||||
block.payload.assign(data.begin() + 80, data.end());
|
||||
}
|
||||
|
||||
return block;
|
||||
|
||||
Reference in New Issue
Block a user