mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
The v1.25.1 IsOwnProposal handleVote trust path closed the proposer's
re-Verify-flip-to-Reject gap, but only along the code path where peer
Chits actually arrive on the proposer. Production validation on
lux-devnet v1.27.26 (K=3 alpha=2) shows the gap reappears when peer
Chits do not reach the proposer in a timely fashion — the proposer's
acceptVotes stays at 1 (lone self-vote), below alpha; PendingBlock
stays Decided=false; VM.Accept is never called; the proposer freezes
at its proposal height while peers fast-follow and advance.
Empirical signal from devnet logs:
- Proposer: "proposed block to validators"
- Followers: "fast-follow: accepting block from validator" + advance
- Proposer: NO chit / fast-follow / finalize event; height stays
- Stuck until a non-proposer round produces the next block
This is independent of the v1.25.1 IsOwnProposal logic (which is
correct and still required for the path where Chits do arrive but
re-Verify flips them to Reject). The v1.25.2 fix routes the proposer
through a separate self-finalize path at the end of buildBlocksLocked,
once Propose + RequestVotes have queued the network broadcast.
Surgery (3 files):
consensus.go: add ForceAccept(blockID) — consensus-level counterpart
of ForcePreference, marks block accepted bypassing alpha-of-K. Only
authorized caller is engine.finalizeOwnProposal.
engine.go: extract finalizePendingLocked shared finalization tail
(VMBlock.Accept + SetPreference + signalPipeline + idempotency
guard) shared by both tryFinalizeBlock and the new
finalizeOwnProposal. The new helper is gated on
pending.IsOwnProposal=true && !pending.Decided && proposer != nil
so follower-tracked entries (HandleIncomingBlock slow path,
IsOwnProposal=false) and unit-test paths (no Proposer wired) still
require natural alpha-of-K quorum.
proposer_self_accept_test.go: three new regression tests with
recordingProposer (zero-network Proposer):
- TestProposerSelfAccept_FinalizesWithoutPeerChits: zero peer
votes, Accept must fire exactly once at proposal time.
- TestProposerSelfAccept_IdempotentUnderLateChits: late vote.Accept=false
arrivals must not drive a second Accept call.
- TestFollower_StillRequiresAlphaQuorum_AfterSelfAcceptFix: direct
call to finalizeOwnProposal on an IsOwnProposal=false entry must
be a no-op.
Safety argument:
1. The proposer ran Verify 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.
2. Under honest-validator + deterministic-Verify assumption (devnet,
all production deployments) peers running the same Verify reach
the same decision; structurally no divergence.
3. Cluster authoritative finality is unaffected: the BLS+Pulsar
witness is the canonical record, slashing evidence flags a
byzantine proposer that commits a block peers reject.
4. Idempotent under concurrent invocation: pending.Decided=true
+ delete(pendingBlocks) guard in finalizePendingLocked prevent
double-Accept across pipeline tick, vote handler, and the new
self-finalize path.
5. Tightly scoped: only fires for IsOwnProposal=true entries with
a wired Proposer; the v1.25.1 IsOwnProposal trust path in
handleVote is preserved and complementary.
Test results: all 6 proposer_self_accept tests PASS under -race;
full engine/chain suite PASS (without -race; pre-existing test leak
in TestNotifyPendingTxsTriggersBuildBlock blocks -race run, not
introduced by this commit).
No API surface change. No backwards-compat shim. No new dependency.
331 lines
8.0 KiB
Go
331 lines
8.0 KiB
Go
// Copyright (C) 2019-2025, Lux Partners Limited All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package chain
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
|
|
"github.com/luxfi/consensus/engine"
|
|
"github.com/luxfi/ids"
|
|
)
|
|
|
|
// Block represents a block in the chain
|
|
type Block struct {
|
|
id ids.ID
|
|
parentID ids.ID
|
|
height uint64
|
|
timestamp int64
|
|
data []byte
|
|
|
|
// Consensus state - Photon -> Wave -> Focus finality
|
|
driver *engine.Driver
|
|
accepted bool
|
|
rejected bool
|
|
|
|
// Vote tracking for rejection support
|
|
acceptVotes int
|
|
rejectVotes int
|
|
}
|
|
|
|
// ChainConsensus implements real Lux consensus for linear chains using Photon → Wave → Focus
|
|
type ChainConsensus struct {
|
|
mu sync.RWMutex
|
|
|
|
// Parameters
|
|
k int // Sample size
|
|
alpha int // Quorum size
|
|
beta int // Decision threshold
|
|
|
|
// State
|
|
blocks map[ids.ID]*Block
|
|
tips map[ids.ID]bool // Current chain tips
|
|
|
|
// Consensus tracking
|
|
bootstrapped bool
|
|
finalizedTip ids.ID
|
|
}
|
|
|
|
// NewChainConsensus creates a real consensus engine
|
|
func NewChainConsensus(k, alpha, beta int) *ChainConsensus {
|
|
return &ChainConsensus{
|
|
k: k,
|
|
alpha: alpha,
|
|
beta: beta,
|
|
blocks: make(map[ids.ID]*Block),
|
|
tips: make(map[ids.ID]bool),
|
|
}
|
|
}
|
|
|
|
// AddBlock adds a block to consensus
|
|
func (c *ChainConsensus) AddBlock(ctx context.Context, block *Block) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
// Check if block already exists
|
|
if _, exists := c.blocks[block.id]; exists {
|
|
return fmt.Errorf("block already exists: %s", block.id)
|
|
}
|
|
|
|
// Initialize Lux consensus for this block using Photon → Wave → Focus
|
|
block.driver = engine.NewLuxConsensus(c.k, c.alpha, c.beta)
|
|
|
|
// Add to blocks map
|
|
c.blocks[block.id] = block
|
|
|
|
// Update tips
|
|
if block.parentID != ids.Empty {
|
|
// Remove parent from tips (no longer a tip)
|
|
delete(c.tips, block.parentID)
|
|
}
|
|
c.tips[block.id] = true
|
|
|
|
return nil
|
|
}
|
|
|
|
// ProcessVote processes a vote for a block
|
|
func (c *ChainConsensus) ProcessVote(ctx context.Context, blockID ids.ID, accept bool) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
block, exists := c.blocks[blockID]
|
|
if !exists {
|
|
return fmt.Errorf("block not found: %s", blockID)
|
|
}
|
|
|
|
if block.driver == nil {
|
|
return fmt.Errorf("block not initialized for consensus")
|
|
}
|
|
|
|
// Track both accept and reject votes
|
|
if accept {
|
|
block.acceptVotes++
|
|
block.driver.RecordVote(blockID)
|
|
} else {
|
|
block.rejectVotes++
|
|
}
|
|
|
|
// Check if acceptance quorum is reached (accept votes >= alpha)
|
|
if block.acceptVotes >= c.alpha && !block.accepted {
|
|
block.accepted = true
|
|
c.finalizedTip = blockID
|
|
}
|
|
|
|
// Check if rejection quorum is reached (reject votes >= alpha)
|
|
if block.rejectVotes >= c.alpha {
|
|
block.rejected = true
|
|
// Remove from tips since this block is rejected
|
|
delete(c.tips, blockID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Poll conducts a consensus poll
|
|
func (c *ChainConsensus) Poll(ctx context.Context, responses map[ids.ID]int) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
// Poll each block's Lux consensus instance using Wave → Focus protocols
|
|
for blockID, votes := range responses {
|
|
block, exists := c.blocks[blockID]
|
|
if !exists {
|
|
continue
|
|
}
|
|
|
|
// Skip already decided blocks
|
|
if block.accepted || block.rejected {
|
|
continue
|
|
}
|
|
|
|
// Check if rejection quorum already reached (reject votes >= alpha)
|
|
if block.rejectVotes >= c.alpha {
|
|
block.rejected = true
|
|
delete(c.tips, blockID)
|
|
continue
|
|
}
|
|
|
|
// Only consider acceptance if we have enough accept votes
|
|
// This prevents premature acceptance with insufficient quorum
|
|
if block.acceptVotes < c.alpha {
|
|
continue
|
|
}
|
|
|
|
if block.driver != nil {
|
|
blockResponses := map[ids.ID]int{blockID: votes}
|
|
shouldContinue := block.driver.Poll(blockResponses)
|
|
decided := block.driver.Decided()
|
|
|
|
// Check if block reached finality through Focus convergence
|
|
// AND we have sufficient accept votes (quorum reached)
|
|
if !shouldContinue && decided && block.acceptVotes >= c.alpha {
|
|
block.accepted = true
|
|
c.finalizedTip = blockID
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// IsAccepted checks if a block is accepted
|
|
func (c *ChainConsensus) IsAccepted(blockID ids.ID) bool {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
block, exists := c.blocks[blockID]
|
|
if !exists {
|
|
return false
|
|
}
|
|
|
|
return block.accepted
|
|
}
|
|
|
|
// IsRejected checks if a block is rejected
|
|
func (c *ChainConsensus) IsRejected(blockID ids.ID) bool {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
block, exists := c.blocks[blockID]
|
|
if !exists {
|
|
return false
|
|
}
|
|
|
|
return block.rejected
|
|
}
|
|
|
|
// Preference returns current preferred block
|
|
func (c *ChainConsensus) Preference() ids.ID {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
// Return the finalized tip if available
|
|
if c.finalizedTip != ids.Empty {
|
|
return c.finalizedTip
|
|
}
|
|
|
|
// Otherwise return latest tip
|
|
for tip := range c.tips {
|
|
return tip
|
|
}
|
|
|
|
return ids.Empty
|
|
}
|
|
|
|
// GetBlock returns a block by ID
|
|
func (c *ChainConsensus) GetBlock(blockID ids.ID) (*Block, bool) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
block, exists := c.blocks[blockID]
|
|
return block, exists
|
|
}
|
|
|
|
// Stats returns consensus statistics
|
|
func (c *ChainConsensus) Stats() map[string]interface{} {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
accepted := 0
|
|
rejected := 0
|
|
pending := 0
|
|
|
|
for _, block := range c.blocks {
|
|
if block.accepted {
|
|
accepted++
|
|
} else if block.rejected {
|
|
rejected++
|
|
} else {
|
|
pending++
|
|
}
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"total_blocks": len(c.blocks),
|
|
"accepted": accepted,
|
|
"rejected": rejected,
|
|
"pending": pending,
|
|
"tips": len(c.tips),
|
|
"finalized_tip": c.finalizedTip.String(),
|
|
}
|
|
}
|
|
|
|
// SyncState synchronizes consensus state with external state (e.g., after RLP import).
|
|
// This updates the finalizedTip and marks the consensus as bootstrapped so that
|
|
// new blocks can be built on top of the imported chain.
|
|
//
|
|
// This is called by the syncer after admin_importChain to reconcile consensus
|
|
// state with the EVM state database.
|
|
func (c *ChainConsensus) SyncState(lastAcceptedID ids.ID, height uint64) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
// Update finalized tip to the synced block
|
|
c.finalizedTip = lastAcceptedID
|
|
|
|
// Mark as bootstrapped so new blocks can be proposed
|
|
c.bootstrapped = true
|
|
|
|
// Update tips - the synced block is now the only tip
|
|
c.tips = make(map[ids.ID]bool)
|
|
if lastAcceptedID != ids.Empty {
|
|
c.tips[lastAcceptedID] = true
|
|
}
|
|
|
|
// Clear any blocks below the synced height (they're now stale)
|
|
for blockID, block := range c.blocks {
|
|
if block.height < height {
|
|
delete(c.blocks, blockID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ForcePreference forces the consensus preferred tip to the given block.
|
|
// This is a recovery mechanism used when SetPreference fails after Accept —
|
|
// without it, the VM and consensus engine disagree on the chain tip, causing
|
|
// a state divergence death spiral.
|
|
func (c *ChainConsensus) ForcePreference(blockID ids.ID) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.finalizedTip = blockID
|
|
// Ensure the forced block is a tip
|
|
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.
|
|
//
|
|
// 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.
|
|
//
|
|
// Idempotent: subsequent calls on an already-accepted block no-op.
|
|
func (c *ChainConsensus) ForceAccept(blockID ids.ID) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
block, exists := c.blocks[blockID]
|
|
if !exists {
|
|
return
|
|
}
|
|
if block.accepted {
|
|
return
|
|
}
|
|
block.accepted = true
|
|
c.finalizedTip = blockID
|
|
}
|
|
|
|
// GetFinalizedTip returns the current finalized tip block ID.
|
|
// This is useful for debugging and health checks.
|
|
func (c *ChainConsensus) GetFinalizedTip() ids.ID {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.finalizedTip
|
|
}
|