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:
2026-04-02 13:40:05 -07:00
parent 6072adcce1
commit 4ae55610ef
10 changed files with 427 additions and 99 deletions
+18 -18
View File
@@ -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
View File
@@ -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,
+7 -6
View File
@@ -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)
}
}
+9 -9
View File
@@ -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)
+2 -1
View File
@@ -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
View File
@@ -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 {
+52 -8
View File
@@ -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)),
+149
View File
@@ -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")
}
}
+51 -13
View File
@@ -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)
+22 -6
View File
@@ -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*>(&timestamp_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(&timestamp_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;