mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
RED decomplect-review follow-ups (non-blocking), on top of v1.35.15: - ONE ⅔ supermajority formula: bftAlpha(count) = TwoThirdsStakeFloor(count)+1 is the single definition of the integer accept quorum; bftCommittee and the 1→N reclamp both call it. The count quorum and the stake quorum are now literally the same rational threshold, never two hand-inlined 2*x/3+1 copies that could drift. - Lock doctrine: the unlock-before-call-out comment now draws the exact CALL-OUT vs LEAF-LOCK boundary — external surfaces (VM, gossiper, ReceiveVote, poll delivery, callbacks) are banned under t.mu; acquiring an internal leaf lock (c.mu via t.consensus.*, the vote-guard store) is SAFE because the leaf never re-enters the engine and the order t.mu→c.mu is fixed and acyclic. - Removed the untracked round_view_red2_test.go scratch file. Formula consolidation is behavior-identical (2*x/3+1 == floor(2x/3)+1); full engine/chain suite green. Co-authored-by: Hanzo Dev <[email protected]>
3406 lines
153 KiB
Go
3406 lines
153 KiB
Go
package chain
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/luxfi/consensus/config"
|
||
"github.com/luxfi/consensus/core/slashing"
|
||
"github.com/luxfi/consensus/engine"
|
||
"github.com/luxfi/consensus/engine/chain/block"
|
||
"github.com/luxfi/ids"
|
||
"github.com/luxfi/log"
|
||
)
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Errors
|
||
// -----------------------------------------------------------------------------
|
||
|
||
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)")
|
||
)
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Interfaces
|
||
// -----------------------------------------------------------------------------
|
||
|
||
// Engine is the chain consensus engine interface.
|
||
type Engine interface {
|
||
Start(context.Context, bool) error
|
||
StopWithError(context.Context, error) error
|
||
Context() context.Context
|
||
HealthCheck(context.Context) (interface{}, error)
|
||
IsBootstrapped() bool
|
||
}
|
||
|
||
// BlockBuilder is the minimal interface consensus needs from a VM.
|
||
// Intentionally small: VMs may implement more, but consensus only needs these.
|
||
type BlockBuilder interface {
|
||
BuildBlock(context.Context) (block.Block, error)
|
||
GetBlock(context.Context, ids.ID) (block.Block, error)
|
||
ParseBlock(context.Context, []byte) (block.Block, error)
|
||
LastAccepted(context.Context) (ids.ID, error)
|
||
// SetPreference tells the VM which block to build on next.
|
||
// This MUST be called after accepting a block to keep the VM's preferred
|
||
// block in sync with the last accepted block. Without this, the VM's
|
||
// Preferred() returns the old block while GetLastAccepted() returns the
|
||
// new block, causing GetState(preferred) to fail during block building.
|
||
SetPreference(context.Context, ids.ID) error
|
||
}
|
||
|
||
// BlockProposer submits blocks to validators.
|
||
// Consensus expresses WHAT (propose block); implementation decides HOW.
|
||
type BlockProposer interface {
|
||
Propose(ctx context.Context, proposal BlockProposal) error
|
||
RequestVotes(ctx context.Context, req VoteRequest) error
|
||
}
|
||
|
||
// VoteEmitter is deprecated. Use BlockProposer.
|
||
type VoteEmitter = BlockProposer
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Message types
|
||
// -----------------------------------------------------------------------------
|
||
|
||
type (
|
||
MessageType = engine.MessageType
|
||
Message = engine.Message
|
||
)
|
||
|
||
const (
|
||
PendingTxs = engine.PendingTxs
|
||
StateSyncDone = engine.StateSyncDone
|
||
)
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Protocol types
|
||
// -----------------------------------------------------------------------------
|
||
|
||
// BlockProposal contains data needed to propose a block.
|
||
type BlockProposal struct {
|
||
BlockID ids.ID
|
||
BlockData []byte
|
||
Height uint64
|
||
ParentID ids.ID
|
||
}
|
||
|
||
// VoteRequest asks specific validators to vote.
|
||
type VoteRequest struct {
|
||
BlockID ids.ID
|
||
BlockData []byte // Block bytes for PushQuery (peers can immediately verify and vote)
|
||
Validators []ids.NodeID
|
||
}
|
||
|
||
// Vote represents a validator's decision.
|
||
type Vote struct {
|
||
BlockID ids.ID
|
||
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.
|
||
type PendingBlock struct {
|
||
ConsensusBlock *Block
|
||
VMBlock block.Block
|
||
ProposedAt time.Time
|
||
VoteCount int // Accept votes
|
||
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
|
||
|
||
// lastRePoll is when the re-poll loop last re-solicited votes for this block
|
||
// (zero until the first re-poll). The re-poll loop re-drives a block at most
|
||
// once per its CURRENT backoff window (rePollBackoff), so a stuck block
|
||
// recovers without a gossip storm. See rePollAllPending — this is the liveness
|
||
// retry the topology doc promises ("vote-broadcast + cert-gossip + the
|
||
// poll-timeout re-request"), now with exponential backoff + a hard cap.
|
||
lastRePoll time.Time
|
||
|
||
// rePollBackoff is the CURRENT re-poll interval for this block. It starts at
|
||
// the base RoundTO and DOUBLES after each re-poll (capped at maxRePollBackoff),
|
||
// so a block that is stuck because peers are behind is re-solicited on a
|
||
// geometric schedule (RoundTO, 2·RoundTO, 4·RoundTO, …), not a 250ms hot loop.
|
||
// Zero ⇒ "use the base interval for the first re-poll".
|
||
rePollBackoff time.Duration
|
||
|
||
// rePollAttempts counts how many times the re-poll loop has re-solicited this
|
||
// block. For a NON-OWN (gossiped) block, once it reaches maxRePollAttempts the
|
||
// block is ABANDONED for re-poll purposes (rePollAbandoned) — re-soliciting a
|
||
// gossiped block to peers who cannot vote (they are behind its parent) is pure
|
||
// spam and never recovers it; the catch-up path (requestCatchup) is what recovers
|
||
// a behind frontier. An OWN proposal is never abandoned (the proposer drives it to
|
||
// finality), so this counter only paces its bounded-backoff re-solicitation.
|
||
rePollAttempts int
|
||
|
||
// rePollAbandoned is set once a NON-OWN block's rePollAttempts hits the cap. An
|
||
// abandoned block is NEVER re-polled again (no infinite spam), but is NOT deleted:
|
||
// it remains pending and recoverable — a late cert (HandleIncomingCert) or a
|
||
// catch-up that fills its parent can still finalize it. An OWN proposal never sets
|
||
// this flag: it is re-solicited until it decides, so a down/wedged/forked
|
||
// designated proposer cannot halt the chain by starving the substitute's votes.
|
||
rePollAbandoned bool
|
||
|
||
// IsOwnProposal is true when this node built and proposed the block. It now
|
||
// selects ONLY the finalization ENTRY POINT (finalizeOwnProposal vs.
|
||
// tryFinalizeBlock); it does NOT alter how votes are counted. The former
|
||
// REJECT→ACCEPT laundering it used to gate ("a peer's reject counts as accept
|
||
// for my own block") is DELETED — vote.Accept is authoritative once the
|
||
// signature verifies (handleVote), so an own-proposal finalizes only on a
|
||
// real α-of-K cert (K>1) or the 1-of-1 force (K==1), never on self-promises.
|
||
IsOwnProposal bool
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Configuration
|
||
// -----------------------------------------------------------------------------
|
||
|
||
// Config holds engine dependencies and settings.
|
||
type Config struct {
|
||
Params config.Parameters
|
||
VM BlockBuilder
|
||
Proposer BlockProposer
|
||
|
||
// Channel buffer sizes (defaults applied if zero)
|
||
VoteRequestBuffer int
|
||
VoteBuffer int
|
||
}
|
||
|
||
// DefaultConfig returns sensible defaults.
|
||
func DefaultConfig() Config {
|
||
return Config{
|
||
Params: config.DefaultParams(),
|
||
VoteRequestBuffer: 100,
|
||
VoteBuffer: 1000,
|
||
}
|
||
}
|
||
|
||
// Validate checks config validity.
|
||
func (c Config) Validate() error {
|
||
return c.Params.Valid()
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Functional options
|
||
// -----------------------------------------------------------------------------
|
||
|
||
// Option configures the engine.
|
||
type Option func(*Transitive)
|
||
|
||
// WithParams sets consensus parameters.
|
||
func WithParams(p config.Parameters) Option {
|
||
return func(t *Transitive) {
|
||
t.params = p
|
||
t.consensus = NewChainConsensus(p.K, p.AlphaPreference, int(p.Beta))
|
||
}
|
||
}
|
||
|
||
// WithVM sets the block builder.
|
||
func WithVM(vm BlockBuilder) Option {
|
||
return func(t *Transitive) {
|
||
t.vm = vm
|
||
}
|
||
}
|
||
|
||
// WithProposer sets the block proposer.
|
||
func WithProposer(p BlockProposer) Option {
|
||
return func(t *Transitive) {
|
||
t.proposer = p
|
||
}
|
||
}
|
||
|
||
// WithEmitter is deprecated. Use WithProposer.
|
||
func WithEmitter(e BlockProposer) Option {
|
||
return WithProposer(e)
|
||
}
|
||
|
||
// WithSlashing enables equivocation detection and slashing evidence collection.
|
||
func WithSlashing(detector *slashing.Detector, db *slashing.DB) Option {
|
||
return func(t *Transitive) {
|
||
t.slashingDetector = detector
|
||
t.slashingDB = db
|
||
}
|
||
}
|
||
|
||
// WithLogger sets the engine logger.
|
||
func WithLogger(l log.Logger) Option {
|
||
return func(t *Transitive) {
|
||
t.log = l
|
||
}
|
||
}
|
||
|
||
// 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 goes through the 1-of-1 cert →
|
||
// FinalizeBranch, and no peer 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
|
||
}
|
||
}
|
||
|
||
// WithStakeWeighting makes finality STAKE-WEIGHTED (HIGH-3): a cert is accepted
|
||
// only when its voters hold a strict ⅔-of-stake supermajority at the cert's
|
||
// height, in addition to the α-of-K count. The node supplies a StakeSource
|
||
// backed by the chain's validator set (weights from the same set the verifier
|
||
// authenticates against). REQUIRED for a PoS value chain with unequal stake;
|
||
// omit it only when equal stake is enforced at admission (then count-α is the
|
||
// correct, equivalent rule). It is ALSO a precondition for Mode() to report
|
||
// ModeQuorumFinality (the engine's stake-weighted finality regime) — see Mode().
|
||
func WithStakeWeighting(stake StakeSource) Option {
|
||
return func(t *Transitive) {
|
||
t.stakeSource = stake
|
||
}
|
||
}
|
||
|
||
// WithStrictPQ marks the engine as running under a STRICT post-quantum security
|
||
// profile (the node derives this from the chain's consensus profile —
|
||
// config.Profile.IsStrict()). When set, Mode() additionally requires a PQ
|
||
// cryptoWitness (WithCryptoWitness) before it reports ModeQuorumFinality, so the
|
||
// engine cannot report a quorum-finality regime the chain cannot witness
|
||
// post-quantum. A non-strict chain leaves this false (the requirement is vacuous).
|
||
func WithStrictPQ(strict bool) Option {
|
||
return func(t *Transitive) {
|
||
t.strictPQ = strict
|
||
}
|
||
}
|
||
|
||
// WithCryptoWitness wires the post-quantum finality witness source a strict-PQ
|
||
// chain uses. It is REQUIRED for Mode() to report ModeQuorumFinality on a strict-PQ
|
||
// chain; on a non-strict chain it is unused. The node supplies a source whose
|
||
// Scheme() names the PQ witness scheme actually in force.
|
||
func WithCryptoWitness(w CryptoWitnessSource) Option {
|
||
return func(t *Transitive) {
|
||
t.cryptoWitness = w
|
||
}
|
||
}
|
||
|
||
// WithCatchup wires the engine's runtime auto-recovery seam (Catchup): the
|
||
// transport the engine uses to fetch ancestors it is missing when a gossiped
|
||
// child or a verified cert references a parent it does not have. Without it a
|
||
// follower that falls behind during normal operation is stranded (it can neither
|
||
// vote on nor finalize a block whose parent it lacks); with it the follower
|
||
// self-heals by fetching the missing chain and rejoining the frontier. Optional;
|
||
// nil keeps the legacy no-self-heal behaviour.
|
||
func WithCatchup(c Catchup) Option {
|
||
return func(t *Transitive) {
|
||
t.catchup = c
|
||
}
|
||
}
|
||
|
||
// WithValidatorSetRoot binds every vote/cert this engine produces to the active
|
||
// weighted validator set at the block's height (the MEDIUM fix). The node
|
||
// supplies a ValidatorSetRootSource backed by the chain's validator set; the
|
||
// engine stamps the root into each VotePosition so a cert is pinned to the exact
|
||
// set it was certified under — a cross-epoch cert (votes cast under set R
|
||
// re-presented as certifying under set R') fails signature verification because
|
||
// every signature was over R. REQUIRED alongside WithStakeWeighting on a chain
|
||
// whose validator set / stake can change across epochs, so the ⅔-by-stake
|
||
// predicate is enforced at the cert-position epoch rather than assumed. Omit it
|
||
// only on a fixed-set chain (then Empty-root binding is the correct no-op).
|
||
func WithValidatorSetRoot(src ValidatorSetRootSource) Option {
|
||
return func(t *Transitive) {
|
||
t.setRootSource = src
|
||
}
|
||
}
|
||
|
||
// WithVoteBuffers sets channel buffer sizes.
|
||
func WithVoteBuffers(requests, votes int) Option {
|
||
return func(t *Transitive) {
|
||
if requests > 0 {
|
||
t.voteRequests = make(chan VoteRequest, requests)
|
||
}
|
||
if votes > 0 {
|
||
t.votes = make(chan Vote, votes)
|
||
}
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Transitive consensus engine
|
||
// -----------------------------------------------------------------------------
|
||
|
||
// Transitive implements chain consensus using Lux protocols.
|
||
//
|
||
// Construction:
|
||
//
|
||
// New() // defaults
|
||
// New(WithVM(vm), WithProposer(p)) // with options
|
||
// NewWithConfig(cfg) // explicit config
|
||
// NewWithConfig(cfg, WithVM(vm)) // config + option overrides
|
||
//
|
||
// Lifecycle: New -> Start -> (running) -> Stop
|
||
type Transitive struct {
|
||
// Consensus lock discipline:
|
||
// Do not call VM.Accept, ReceiveVote, network send, poll delivery,
|
||
// certificate callbacks, or any external callback while holding Transitive.mu.
|
||
// Queue work or release the lock before call-out.
|
||
//
|
||
// WHY (the deadlock class this bans, not one instance): mu is a sync.RWMutex — NOT reentrant.
|
||
// A call-out invoked while a goroutine holds mu (write) that reenters the engine and takes
|
||
// mu.RLock self-deadlocks that goroutine forever (the live single-validator freeze: the
|
||
// selfVoter called ReceiveVote from RequestVotes under mu). Network sends under mu are equally
|
||
// banned — they must never sit behind the engine lock. The pattern everywhere is: capture what
|
||
// the call-out needs UNDER mu, Unlock, do EVERY call-out (VM.Accept/Reject, Propose,
|
||
// RequestVotes, GossipCert, SetPreference, ReceiveVote, the finalizer, signalPipeline) with mu
|
||
// RELEASED, then re-Lock. Enforced by TestBlue_NoCallOutUnderEngineLock (a lock-instrumented
|
||
// runtime run) + the source audit in that file.
|
||
mu engineMutex // sync.RWMutex + a test-time write-holder instrument (lock_invariant.go)
|
||
|
||
// Core consensus
|
||
consensus *ChainConsensus
|
||
params config.Parameters
|
||
|
||
// Dependencies
|
||
vm BlockBuilder
|
||
proposer BlockProposer
|
||
|
||
// convergenceVoter, when wired by the Runtime, is the SOLE per-height accept-vote
|
||
// emitter for a multi-validator (K>1) engine. Rather than binding this node's one
|
||
// signature to whatever block it BUILT or FIRST-SAW — which fragments the vote
|
||
// across conflicting siblings during a fresh-net storm and stalls the quorum with
|
||
// no single block reaching α — it emits the signature for the DETERMINISTICALLY
|
||
// CONVERGED winner at a height: the lowest signed-canonical among the live
|
||
// siblings. Every honest node with the same tracked set picks the SAME winner, so
|
||
// they converge their one vote onto ONE block and exactly one α-of-K cert forms
|
||
// per height (the cert thus CERTIFIES the converged decision — it is never an
|
||
// independent finality path that could certify a conflicting sibling). nil in
|
||
// single-engine tests (which inject votes directly); wired in NewRuntime.
|
||
convergenceVoter ConvergenceVoter
|
||
|
||
// Runtime state
|
||
ctx context.Context
|
||
cancel context.CancelFunc
|
||
bootstrapped bool
|
||
started bool
|
||
wg sync.WaitGroup // tracks background goroutines
|
||
|
||
// bootstrapPhase is true while the node is INITIAL-SYNCING — fetching and
|
||
// executing the chain from a peer's accepted frontier down to its local tip —
|
||
// and false once it has reached the frontier and entered live consensus. It is
|
||
// the SOLE gate on the bootstrap accept authority (Runtime.AcceptBootstrapBlock):
|
||
// a block fetched-from-frontier-and-re-executed may finalize WITHOUT an α-of-K
|
||
// cert ONLY while this is true. The instant the node goes live (FinishBootstrap),
|
||
// the bootstrap path is fail-closed and finality flows ONLY through the
|
||
// cert-witnessed α-of-K road — so the weak-subjectivity-on-the-beacon-set trust
|
||
// of bootstrap can never be used to bypass the live cert-gate. Defaults true at
|
||
// construction (a fresh engine is bootstrapping); the node flips it false exactly
|
||
// when it signals the chain bootstrapped.
|
||
bootstrapPhase bool
|
||
|
||
// Block management
|
||
pendingBlocks map[ids.ID]*PendingBlock
|
||
pendingBuildBlocks int
|
||
|
||
// finalizedByCert is the engine's authoritative finality record: the set of
|
||
// block IDs that were committed through the SOLE cert-gated finalizer
|
||
// (AcceptWithCert, which requires a VerifiedQuorumCert). It is deliberately
|
||
// SEPARATE from the consensus core's block.accepted / finalizedByHeight, which
|
||
// the α-of-K COUNT path populates directly (consensus.go marks a block accepted
|
||
// on acceptVotes>=alpha). A block sitting at count-α but lacking the verified
|
||
// cert is "accepted" in the consensus core but is NOT in this set — and
|
||
// Transitive.IsAccepted reports THIS set, so the engine never claims finality
|
||
// for a block it refused to finalize. Bounded by the same finalize cadence as
|
||
// the chain (one entry per finalized height); a production node prunes it
|
||
// alongside the slashing window if retention ever matters.
|
||
finalizedByCert map[ids.ID]struct{}
|
||
|
||
// certBytesByBlock persists the marshaled finality cert for each block this node
|
||
// finalized, so it can SERVE that cert to a peer catching up (CertForBlock). It
|
||
// is written at the SOLE finalizer (acceptWithCertCore), so every finalize path
|
||
// captures its cert in ONE place. certServedOrder is the companion FIFO of block
|
||
// ids in finalize (== ascending height) order, used to evict the oldest cert once
|
||
// the store passes maxServedCerts — a bounded sliding window, never an unbounded
|
||
// map. A node lagging beyond the window bootstraps instead of catching up.
|
||
certBytesByBlock map[ids.ID][]byte
|
||
certServedOrder []ids.ID
|
||
|
||
// Vote channels
|
||
voteRequests chan VoteRequest
|
||
votes chan Vote
|
||
|
||
// Pipeline: signal channel for continuous block production
|
||
pipelineSignal chan struct{}
|
||
|
||
// Metrics
|
||
blocksBuilt uint64
|
||
blocksAccepted uint64
|
||
blocksRejected uint64
|
||
votesSent uint64
|
||
votesReceived uint64
|
||
|
||
// Slashing: equivocation detection (optional, nil disables)
|
||
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
|
||
|
||
// presetK is the TARGET committee sample size — the configured K BEFORE the construction-time
|
||
// clamp to the live validator count (bftCommittee). reclampCommitteeLocked re-clamps the live
|
||
// committee UP toward it as the validator set grows, so a chain that launched single-validator
|
||
// tracks its set instead of staying stuck at K=1 (the 1→N decentralization fork). 0 ⇒ no
|
||
// re-clamp (K stays exactly as constructed — the path for tests / --dev / a chain with no
|
||
// preset). Wired by the Runtime.
|
||
presetK int
|
||
// liveValidatorCount reports the CURRENT validator count for THIS chain's network (wired by the
|
||
// Runtime from the validator sampler); nil for tests / --dev without a sampler. It is read at
|
||
// each finalize decision so both the committee (reclampCommitteeLocked) and the single-validator
|
||
// synthesis guard (buildSingleValidatorCertLocked) track the LIVE set rather than the frozen
|
||
// construction-time count. A wired sampler returns 0 when the set is not yet resolved for this
|
||
// network, ≥1 once loaded.
|
||
liveValidatorCount func() int
|
||
|
||
// committedSlot enforces the per-HEIGHT NON-EQUIVOCATION safety rule (SlotKey =
|
||
// {height}): this node signs an accept vote for AT MOST ONE canonical block per
|
||
// value-chain height — regardless of the block's validator-set epoch. Signing two
|
||
// conflicting siblings at one height would place this node's stake in two
|
||
// conflicting ⅔-quorums and break the quorum-intersection argument that gives
|
||
// f<n/3 safety — the exact cross-node fork (two valid siblings each gathering a
|
||
// legit cert). The epoch was DELIBERATELY removed from the key: it is a
|
||
// proposer-chosen axis (ValidatorSetRoot(block.pChainHeight)) that differs between
|
||
// honest siblings at one height, so keying on it FRAGMENTED the slot and let one
|
||
// validator sign both siblings → the fresh-net double-finalization fatal. The
|
||
// epoch binding remains in the SIGNED message + cert verification, where it stops
|
||
// cross-epoch cert forgery. Keyed SlotKey → bound canonical id:
|
||
// a second DIFFERENT canonical at a bound slot is REFUSED (never signed); the SAME
|
||
// one is idempotent (safe re-solicit). Guarded by its own slotMu so BOTH signing
|
||
// sites can call it — recordOwnVoteLocked (under t.mu) and the follower path in
|
||
// followVerifiedBlock (t.mu released). Pruned below the finalized height (a
|
||
// finalized height can never legitimately re-sign — pruneCommittedSlotsBelow, run
|
||
// from the sole finalizer acceptWithCertCore).
|
||
//
|
||
// voteGuard (optional) is the DURABLE backing (HIGH-1): every new binding is
|
||
// fsync'd BEFORE the vote is cast and reloaded on startup, so the guard's memory
|
||
// spans a crash/restart. nil = memory-only (verify-only nodes and tests; Start
|
||
// warns a signer that has none). See vote_guard.go.
|
||
slotMu sync.Mutex
|
||
committedSlot map[SlotKey]ids.ID
|
||
// views holds the round-scoped view-change machine per unfinalized height (guarded by
|
||
// slotMu), used ONLY when params.ViewChange is set. It makes the α-of-K precommit LIVE
|
||
// and re-convergent under competing siblings: fluid prevotes across rounds + precommit
|
||
// only on a POL + lock. Lazily created per height; pruned on finalize. See round_view.go.
|
||
views map[uint64]*roundView
|
||
// prevoteSigs stores the SIGNED prevotes observed (own + peers), keyed
|
||
// {height,round,canonical} → nodeID → signature, so a node can RELAY the validValue POL's
|
||
// constituent prevotes to a peer that missed them (the POL-propagation the isolated proof
|
||
// showed is required for a 2|N split where one side holds a POL to converge). Guarded by
|
||
// slotMu; pruned with views on finalize.
|
||
prevoteSigs map[pvSigKey]map[ids.NodeID][]byte
|
||
// recoveredLocks are heights whose committedSlot binding was RECOVERED from the durable
|
||
// vote-guard at boot (a value this node precommitted before a crash). Under ViewChange the
|
||
// in-session round+lock rule governs re-precommit, but the ROUND of a recovered lock is not
|
||
// on disk — so for a recovered height we CANNOT evaluate the unlock rule safely. Fail-safe:
|
||
// treat a recovered binding as a HARD lock (reserveSlotForSign keeps the STRICT refusal of a
|
||
// conflicting canonical there, even under ViewChange), so a rolling-restart node never
|
||
// precommits a second, conflicting value at a pre-crash height (the double-precommit RED
|
||
// flagged). Liveness is unharmed: if the network finalized a different value, the node
|
||
// finalizes it via the α-of-K cert gossip (HandleIncomingCert), not a local re-precommit.
|
||
// A height leaves this set once it finalizes (pruned). Guarded by slotMu.
|
||
recoveredLocks map[uint64]struct{}
|
||
// lockRounds is the round-scoped view-change LOCK ROUND per height (the round this node
|
||
// precommitted+locked the height's binding). Persisted in the v3 vote-guard so a restarted
|
||
// node seeds its roundView lock and re-converges under the unlock rule (instead of the
|
||
// conservative recoveredLocks hard-lock). Updated on precommit; pruned on finalize. Guarded
|
||
// by slotMu.
|
||
lockRounds map[uint64]uint32
|
||
// decidedFloor is the DURABLE, MONOTONIC decided-through height: the highest height
|
||
// this node has ever finalized. The sign gate refuses signing at any height <= it, so a
|
||
// decided height is permanently unsignable EVEN AFTER its committedSlot entry is pruned
|
||
// (the strictly-below prune persists the removal of below-tip slots). It is seeded on
|
||
// boot from the vote-guard file's fsync'd floor (WithVoteGuard) — the authoritative
|
||
// durable source that never lags the consensus decision — and advanced at each finalize
|
||
// (pruneCommittedSlotsBelow). The gate also folds in consensus.GetDecidedFloor() (the
|
||
// certified height OR the vm.LastAccepted hint) as a complementary lower bound. This is
|
||
// the CROSS-RESTART backstop: without it, after a rolling restart mid-storm the certified
|
||
// ledger.Height() is a (0,false) hint (PART-A) and the below-tip slots are gone, so a
|
||
// re-gossiped sibling at a decided height could collect a SECOND signature. SIGN-GATE
|
||
// ONLY — never enters the finality ledger, byHeight, or the equivocation index (PART-A).
|
||
// Guarded by slotMu (lives with committedSlot).
|
||
decidedFloor uint64
|
||
voteGuard VoteGuardStore
|
||
|
||
// stakeSource (optional) makes finality STAKE-WEIGHTED instead of a raw voter
|
||
// count (HIGH-3). When set (a value/PoS chain with unequal stake), a cert is
|
||
// accepted only if its voters hold a strict ⅔ supermajority of stake at the
|
||
// cert's height (VerifyWeighted), in addition to the α-of-K count. When nil,
|
||
// finality is count-α and the chain MUST enforce equal stake at validator
|
||
// admission (the documented invariant) — the node wires this for value chains.
|
||
stakeSource StakeSource
|
||
|
||
// setRootSource (optional) supplies the commitment to the active weighted
|
||
// validator set at a block's height (the MEDIUM fix). When set, every
|
||
// VotePosition this engine signs/assembles carries that set-root, so a cert
|
||
// is cryptographically pinned to the exact set it was certified under and
|
||
// cannot be re-verified against a different epoch's set. When nil, positions
|
||
// carry ids.Empty (behavior identical to before set-root binding) — a chain
|
||
// without epoch-versioned sets needs no binding.
|
||
setRootSource ValidatorSetRootSource
|
||
|
||
// strictPQ records that this chain runs under a STRICT post-quantum security
|
||
// profile (set via WithStrictPQ, from config.Profile.IsStrict()). When true,
|
||
// Mode() additionally requires a PQ cryptoWitness before reporting
|
||
// ModeQuorumFinality, so the engine cannot report a quorum-finality
|
||
// regime the chain cannot witness post-quantum.
|
||
strictPQ bool
|
||
|
||
// cryptoWitness (optional) is the post-quantum finality witness source a strict-PQ
|
||
// chain wires (the SAME node-layer CryptoWitnessSource that upgrades an engine
|
||
// QuorumCert into a quasar.WeightedQuorumCert — see quasar.go). It is
|
||
// REQUIRED for Mode() to report ModeQuorumFinality on a strict-PQ chain: without it the
|
||
// cert path cannot produce the PQ (quasar) witness the profile demands, so the value-
|
||
// DEX gate must not certify a quorum-finality regime that cannot be witnessed post-
|
||
// quantum. On a non-strict chain it is unused. Injected like voteVerifier/stakeSource so
|
||
// the gate reads the SAME field that delivers the witness — nil means "PQ witness not
|
||
// plumbed", exactly the semantics ToQuasarCert already relies on.
|
||
cryptoWitness CryptoWitnessSource
|
||
|
||
// catchup (optional) is the engine's seam for runtime auto-recovery when it
|
||
// falls behind — see Catchup. When a gossiped child or a verified cert
|
||
// references a parent this node does not have, the engine asks catchup to
|
||
// fetch the missing ancestors (requestCatchupLocked) instead of silently
|
||
// dropping the child (the old behaviour that stranded a behind follower). nil
|
||
// disables self-healing (legacy). The engine owns idempotency + rate-limiting
|
||
// so the implementation can be a thin adapter onto the network getter.
|
||
catchup Catchup
|
||
|
||
// catchupRequested rate-limits ancestor fetches: it remembers the missing
|
||
// parent IDs we have already asked for and when, so a re-gossip of the same
|
||
// orphan (or many children of one missing parent) issues ONE fetch per
|
||
// catchupCooldown, never a fetch storm. Keyed by the MISSING block ID.
|
||
//
|
||
// BOUNDED two ways (both fail-closed), because a Byzantine validator can stream
|
||
// votes for forged random IDs that never arrive:
|
||
// - Reclaim-on-known: an entry is deleted the moment its block becomes TRACKED
|
||
// or DECIDED — at the accept span, the reject site, the sync reset, and at
|
||
// claimCatchupLocked's early returns (already-tracked / known-to-consensus).
|
||
// A block that actually arrives reclaims its slot; honest entries never pile
|
||
// up.
|
||
// - Hard cap + TTL: a forged ID that never arrives is never reclaimed above, so
|
||
// claimCatchupLocked refuses to grow the map past maxCatchupRequested — at
|
||
// the cap it sweeps entries older than catchupRequestTTL and, if still full
|
||
// (an active young flood), refuses the new claim. The map can never exceed
|
||
// maxCatchupRequested.
|
||
catchupRequested map[ids.ID]time.Time
|
||
|
||
// bufferedVotes parks signed accept/reject votes that arrived for a block this
|
||
// node does not yet TRACK (the gossip race: a peer's vote can outrun the block
|
||
// bytes). The old handleVote DROPPED such a vote — and because votes are only
|
||
// solicited once, a dropped vote was lost forever, so a follower that missed
|
||
// the block bytes could never reach the α-of-K quorum and the block wedged. We
|
||
// instead BUFFER the vote (no signature work yet) and fetch the missing block
|
||
// via the SAME catch-up seam used for a missing parent; when the block lands at
|
||
// a tracking site, drainBufferedVotes replays these through the normal channel
|
||
// path so each is signature-verified exactly as a live vote (buffering never
|
||
// bypasses the gate). Keyed by the voted-on (missing) BlockID, and within each
|
||
// block deduped by NodeID: at most ONE buffered vote per (BlockID, validator)
|
||
// — a repeat from the same NodeID REPLACES its parked vote, never appends — so
|
||
// the per-block slice is bounded by DISTINCT voters, not raw arrivals (the dual
|
||
// of certVotes' NodeID keying; defeats single-Byzantine-ID buffer crowd-out).
|
||
// Bounded by maxBufferedVoteBlocks distinct keys and maxBufferedVotesPerBlock
|
||
// distinct NodeIDs per key (fail-closed: a new vote past a cap is dropped,
|
||
// never evicting an existing one). Drained on track, deleted on decide — it
|
||
// cannot leak.
|
||
bufferedVotes map[ids.ID][]Vote
|
||
|
||
// requestMissing is the engine's hook into the runtime's catch-up TRANSPORT
|
||
// (Runtime.requestCatchup): "I am missing block `id` — fetch it from `from`".
|
||
// It is the SAME one mechanism the missing-PARENT self-heal uses; the engine
|
||
// (which does not hold the networkID) signals WHAT to fetch and the runtime
|
||
// supplies the networkID + RequestAncestors round-trip. nil when no runtime is
|
||
// wired (a bare engine in a unit test that drives delivery itself), in which
|
||
// case a buffered vote simply waits for the block to be delivered by other
|
||
// means. Set by NewRuntime; idempotency + rate-limiting stay in the engine
|
||
// (claimCatchupLocked), so this is a thin signal, never a second transport.
|
||
requestMissing func(missingID ids.ID, from 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)
|
||
}
|
||
|
||
// Catchup is the engine's SOLE seam for "I am behind — fetch the block(s) I am
|
||
// missing". It is the one mechanism for runtime auto-recovery (decomplected from
|
||
// the finality path): the engine expresses WHAT (fetch the ancestors rooted at a
|
||
// missing block ID), the node decides HOW (a GetAncestors/Get round-trip on its
|
||
// existing network transport, delivering the fetched blocks back through
|
||
// HandleIncomingBlock).
|
||
//
|
||
// It is wired by the node (WithCatchup); when nil the engine simply does not
|
||
// self-heal a behind state (the legacy behaviour — a follower that falls behind
|
||
// at runtime is stranded). Idempotency and rate-limiting live in the ENGINE
|
||
// (requestCatchupLocked), so an implementation may be a thin, stateless adapter
|
||
// onto the network getter.
|
||
type Catchup interface {
|
||
// RequestAncestors asks a peer to deliver the chain of blocks ending at
|
||
// missingBlockID (the parent a gossiped child / verified cert referenced but
|
||
// which this node does not have). `from` is the peer that advertised the
|
||
// child — the natural source to fetch its parent from. chainID/networkID
|
||
// scope the request to this chain's validator network. The fetched blocks are
|
||
// expected to arrive via HandleIncomingBlock, at which point the formerly
|
||
// orphaned child can be tracked, voted, and finalized.
|
||
RequestAncestors(chainID ids.ID, networkID ids.ID, missingBlockID ids.ID, from ids.NodeID) error
|
||
}
|
||
|
||
// New creates an engine with default parameters.
|
||
// Apply options to configure dependencies.
|
||
//
|
||
// Example:
|
||
//
|
||
// engine := New(WithVM(vm), WithProposer(proposer))
|
||
func New(opts ...Option) *Transitive {
|
||
return NewWithConfig(DefaultConfig(), opts...)
|
||
}
|
||
|
||
// NewWithConfig creates an engine from explicit config plus options.
|
||
// Options are applied after config, allowing overrides.
|
||
//
|
||
// Example:
|
||
//
|
||
// cfg := Config{Params: config.MainnetParams(), VM: vm}
|
||
// engine := NewWithConfig(cfg, WithProposer(proposer))
|
||
func NewWithConfig(cfg Config, opts ...Option) *Transitive {
|
||
// Scale buffers for burst mode — 1ms blocks produce 1000 blocks/sec,
|
||
// so vote channels need depth to avoid back-pressure stalls.
|
||
burst := cfg.Params.BlockTime <= time.Millisecond
|
||
if cfg.VoteRequestBuffer == 0 {
|
||
cfg.VoteRequestBuffer = 100
|
||
if burst {
|
||
cfg.VoteRequestBuffer = 4096
|
||
}
|
||
}
|
||
if cfg.VoteBuffer == 0 {
|
||
cfg.VoteBuffer = 1000
|
||
if burst {
|
||
cfg.VoteBuffer = 16384
|
||
}
|
||
}
|
||
|
||
t := &Transitive{
|
||
consensus: NewChainConsensus(cfg.Params.K, cfg.Params.AlphaPreference, int(cfg.Params.Beta)),
|
||
params: cfg.Params,
|
||
vm: cfg.VM,
|
||
proposer: cfg.Proposer,
|
||
bootstrapPhase: true, // a fresh engine is initial-syncing until it reaches the frontier
|
||
pendingBlocks: make(map[ids.ID]*PendingBlock),
|
||
finalizedByCert: make(map[ids.ID]struct{}),
|
||
certBytesByBlock: make(map[ids.ID][]byte),
|
||
committedSlot: make(map[SlotKey]ids.ID),
|
||
views: make(map[uint64]*roundView),
|
||
prevoteSigs: make(map[pvSigKey]map[ids.NodeID][]byte),
|
||
recoveredLocks: make(map[uint64]struct{}),
|
||
lockRounds: make(map[uint64]uint32),
|
||
catchupRequested: make(map[ids.ID]time.Time),
|
||
bufferedVotes: make(map[ids.ID][]Vote),
|
||
voteRequests: make(chan VoteRequest, cfg.VoteRequestBuffer),
|
||
votes: make(chan Vote, cfg.VoteBuffer),
|
||
pipelineSignal: make(chan struct{}, 1),
|
||
}
|
||
|
||
for _, opt := range opts {
|
||
opt(t)
|
||
}
|
||
|
||
if t.log == nil {
|
||
t.log = log.Noop()
|
||
}
|
||
|
||
return t
|
||
}
|
||
|
||
// NewWithParams creates an engine with specific parameters.
|
||
func NewWithParams(params config.Parameters) *Transitive {
|
||
cfg := DefaultConfig()
|
||
cfg.Params = params
|
||
return NewWithConfig(cfg)
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Lifecycle
|
||
// -----------------------------------------------------------------------------
|
||
|
||
// Start starts the engine.
|
||
func (t *Transitive) Start(ctx context.Context, _ bool) error {
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
|
||
if t.started {
|
||
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
|
||
}
|
||
|
||
// A signing validator SHOULD have a durable equivocation guard so a crash between
|
||
// signing and finalizing cannot forget a per-height binding and permit a fork
|
||
// (HIGH-1). Memory-only is correct for verify-only nodes and tests; in production
|
||
// the node wires WithVoteGuard. Warn (don't fail) so tests/dev keep working while
|
||
// the gap is visible.
|
||
if t.voteSigner != nil && t.voteGuard == nil {
|
||
t.log.Warn("vote-once: signing WITHOUT a durable equivocation guard — a crash between " +
|
||
"signing and finalizing may permit equivocation; wire WithVoteGuard in production")
|
||
}
|
||
|
||
// BOOT SEED (v1.35.5): seed the durable decided-height floor DIRECTLY from the VM's
|
||
// last-accepted height BEFORE the signing goroutines launch. This makes the sign gate
|
||
// safe from the first instant of boot — even for a node upgrading in place from a legacy
|
||
// v1 vote-guard file (finalizedThrough=0) whose certified ledger is a (0,false) hint
|
||
// until the first post-upgrade finalize (PART-A). Without it, the floor would be 0 in
|
||
// that window and a re-gossiped sibling at a decided-below-tip height could be signed
|
||
// (the mainnet v1→v2 in-place upgrade window). vm.LastAccepted is a durable, sound lower
|
||
// bound on the decided height (every accepted block was finalized). SIGN-GATE ONLY — it
|
||
// only advances t.decidedFloor, never the finality ledger / byHeight (PART-A intact).
|
||
// UNLOCK-BEFORE-CALL-OUT: the seed reads the VM (LastAccepted/GetBlock). Release t.mu around it
|
||
// (the signing goroutines have not launched yet, but the discipline is global — no external
|
||
// call-out under the engine lock). The seed touches only t.vm (stable pre-Start) + slotMu.
|
||
t.mu.Unlock()
|
||
t.seedDecidedFloorFromVM(ctx)
|
||
t.mu.Lock()
|
||
|
||
t.ctx, t.cancel = context.WithCancel(ctx)
|
||
t.bootstrapped = true
|
||
t.started = true
|
||
|
||
// Capture ctx in local variable to avoid race with struct field access
|
||
engineCtx := t.ctx
|
||
|
||
t.wg.Add(5)
|
||
go t.pollLoopWithCtx(engineCtx)
|
||
go t.voteHandlerWithCtx(engineCtx)
|
||
go t.pipelineLoop(engineCtx)
|
||
go t.rePollLoopWithCtx(engineCtx)
|
||
go t.convergenceLoopWithCtx(engineCtx)
|
||
|
||
return nil
|
||
}
|
||
|
||
// StartWithID starts with a request ID.
|
||
func (t *Transitive) StartWithID(ctx context.Context, requestID uint32) error {
|
||
return t.Start(ctx, requestID > 0)
|
||
}
|
||
|
||
// Stop stops the engine.
|
||
func (t *Transitive) Stop(ctx context.Context) error {
|
||
// Cancel context first, outside the lock, to signal goroutines to exit.
|
||
// This prevents deadlock where goroutines are blocked waiting for the lock
|
||
// while we're holding the lock waiting for them to exit.
|
||
t.mu.RLock()
|
||
cancel := t.cancel
|
||
t.mu.RUnlock()
|
||
|
||
if cancel != nil {
|
||
cancel()
|
||
}
|
||
|
||
// Wait for goroutines to exit before updating state.
|
||
// This ensures clean shutdown without goroutine leaks.
|
||
t.wg.Wait()
|
||
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
|
||
t.bootstrapped = false
|
||
t.started = false
|
||
return nil
|
||
}
|
||
|
||
// StopWithError stops with an error.
|
||
func (t *Transitive) StopWithError(ctx context.Context, _ error) error {
|
||
return t.Stop(ctx)
|
||
}
|
||
|
||
// Context returns the engine's context.
|
||
func (t *Transitive) Context() context.Context {
|
||
t.mu.RLock()
|
||
defer t.mu.RUnlock()
|
||
if t.ctx == nil {
|
||
return context.Background()
|
||
}
|
||
return t.ctx
|
||
}
|
||
|
||
// IsBootstrapped returns bootstrap status.
|
||
func (t *Transitive) IsBootstrapped() bool {
|
||
t.mu.RLock()
|
||
defer t.mu.RUnlock()
|
||
return t.bootstrapped
|
||
}
|
||
|
||
// InBootstrapPhase reports whether the engine is still INITIAL-SYNCING (fetching
|
||
// + executing the chain from the network frontier). It is the gate the bootstrap
|
||
// accept authority (Runtime.AcceptBootstrapBlock) checks: a fetched-from-frontier
|
||
// block may finalize without an α-of-K cert ONLY while this is true. Once the node
|
||
// reaches the frontier (FinishBootstrap) it returns false and the bootstrap path is
|
||
// fail-closed — the live cert-gate is the only finalizer thereafter.
|
||
func (t *Transitive) InBootstrapPhase() bool {
|
||
t.mu.RLock()
|
||
defer t.mu.RUnlock()
|
||
return t.bootstrapPhase
|
||
}
|
||
|
||
// FinishBootstrap ends the bootstrap phase: the node has executed up to the
|
||
// discovered frontier and is entering live consensus. After this call the bootstrap
|
||
// accept path is fail-closed (InBootstrapPhase == false) and finality flows ONLY
|
||
// through the cert-witnessed α-of-K road. The node MUST call this exactly when it
|
||
// signals the chain bootstrapped (so the two transitions — "accept without a cert"
|
||
// and "no longer accept without a cert" — happen together). Idempotent.
|
||
func (t *Transitive) FinishBootstrap() {
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
t.bootstrapPhase = false
|
||
}
|
||
|
||
// SyncState synchronizes consensus state with VM state.
|
||
// This is called by the syncer after RLP import or state sync to reconcile
|
||
// the consensus engine's lastAccepted pointer with the VM's actual state.
|
||
//
|
||
// This method:
|
||
// 1. Updates the consensus finalizedTip to match the VM's last accepted block
|
||
// 2. Clears any stale pending blocks that conflict with the new chain tip
|
||
// 3. Marks the engine as bootstrapped
|
||
//
|
||
// This is safe to call multiple times - it's idempotent.
|
||
func (t *Transitive) SyncState(ctx context.Context, lastAcceptedID ids.ID, height uint64) error {
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
|
||
// Update consensus state. A backward import is refused (ErrSyncStateRegression)
|
||
// and must abort the whole reconcile — we do NOT clear pending blocks or flip
|
||
// bootstrapped on a refused import, so a rejected regression is a clean no-op
|
||
// rather than a partial state mutation.
|
||
if t.consensus != nil {
|
||
if err := t.consensus.SyncState(lastAcceptedID, height); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
// Seed the durable decided-height floor DIRECTLY from the reconciled last-accepted
|
||
// height (v1.35.5). SyncStateFromVM passes vm.LastAccepted here, so this keeps
|
||
// t.decidedFloor at the VM head independently of the consensus ledger's hint — which
|
||
// lives on a different lock and can be cleared by an empty/genesis reset. Monotonic,
|
||
// sign-gate-only (never touches byHeight / ledger.Height() — PART-A intact).
|
||
if height > 0 {
|
||
t.slotMu.Lock()
|
||
if height > t.decidedFloor {
|
||
t.decidedFloor = height
|
||
}
|
||
t.slotMu.Unlock()
|
||
}
|
||
|
||
// Clear any pending blocks that are now stale (below the synced height)
|
||
for blockID, pending := range t.pendingBlocks {
|
||
if pending.ConsensusBlock != nil && pending.ConsensusBlock.height <= height {
|
||
delete(t.pendingBlocks, blockID)
|
||
// Votes parked for a stale (now-synced-past) block will never be drained
|
||
// — drop them so a sync cannot leave buffered-vote residue.
|
||
delete(t.bufferedVotes, blockID)
|
||
// Same for its catch-up throttle entry: synced past ⇒ never re-fetched.
|
||
delete(t.catchupRequested, blockID)
|
||
}
|
||
}
|
||
|
||
// Ensure we're marked as bootstrapped
|
||
t.bootstrapped = true
|
||
|
||
return nil
|
||
}
|
||
|
||
// HealthCheck returns health stats.
|
||
func (t *Transitive) HealthCheck(ctx context.Context) (interface{}, error) {
|
||
t.mu.RLock()
|
||
defer t.mu.RUnlock()
|
||
|
||
stats := t.consensus.Stats()
|
||
stats["bootstrapped"] = t.bootstrapped
|
||
stats["k"] = t.params.K
|
||
stats["alpha"] = t.params.AlphaPreference
|
||
stats["beta"] = t.params.Beta
|
||
return stats, nil
|
||
}
|
||
|
||
// SetProposer sets the block proposer.
|
||
func (t *Transitive) SetProposer(proposer BlockProposer) {
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
t.proposer = proposer
|
||
}
|
||
|
||
// SetEmitter sets the proposer (alias for SetProposer).
|
||
func (t *Transitive) SetEmitter(e BlockProposer) {
|
||
t.SetProposer(e)
|
||
}
|
||
|
||
// SetVM sets the block builder.
|
||
func (t *Transitive) SetVM(vm BlockBuilder) {
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
t.vm = vm
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Consensus operations
|
||
// -----------------------------------------------------------------------------
|
||
|
||
// AddBlock adds a block to consensus.
|
||
func (t *Transitive) AddBlock(ctx context.Context, blk *Block) error {
|
||
return t.consensus.AddBlock(ctx, blk)
|
||
}
|
||
|
||
// CheckBlockProposal checks a block proposal for double-signing.
|
||
// Call this when receiving a block from a remote validator before adding it to consensus.
|
||
// Returns the evidence if the proposer equivocated, nil otherwise.
|
||
func (t *Transitive) CheckBlockProposal(proposerID ids.NodeID, height uint64, blockID ids.ID, blockData []byte) *slashing.Evidence {
|
||
t.mu.RLock()
|
||
detector := t.slashingDetector
|
||
sdb := t.slashingDB
|
||
t.mu.RUnlock()
|
||
|
||
if detector == nil {
|
||
return nil
|
||
}
|
||
|
||
// Reject proposals from jailed validators
|
||
if sdb != nil && sdb.IsJailed(proposerID) {
|
||
return &slashing.Evidence{
|
||
Type: slashing.DoubleSign,
|
||
ValidatorID: proposerID,
|
||
Height: height,
|
||
}
|
||
}
|
||
|
||
ev := detector.CheckBlock(proposerID, height, blockID, blockData)
|
||
if ev != nil && sdb != nil {
|
||
sdb.RecordEvidence(*ev)
|
||
}
|
||
return ev
|
||
}
|
||
|
||
// SlashingDB returns the slashing database, or nil if slashing is disabled.
|
||
func (t *Transitive) SlashingDB() *slashing.DB {
|
||
t.mu.RLock()
|
||
defer t.mu.RUnlock()
|
||
return t.slashingDB
|
||
}
|
||
|
||
// ProcessVote processes a vote.
|
||
func (t *Transitive) ProcessVote(ctx context.Context, blockID ids.ID, accept bool) error {
|
||
return t.consensus.ProcessVote(ctx, blockID, accept)
|
||
}
|
||
|
||
// Poll conducts a poll.
|
||
func (t *Transitive) Poll(ctx context.Context, responses map[ids.ID]int) error {
|
||
return t.consensus.Poll(ctx, responses)
|
||
}
|
||
|
||
// IsAccepted reports whether the block has been FINALIZED by this engine —
|
||
// committed through the SOLE cert-gated finalizer (AcceptWithCert), which is
|
||
// reachable only with a VerifiedQuorumCert. It is the engine's finality truth,
|
||
// NOT a vote count.
|
||
//
|
||
// CRITICAL: this no longer forwards consensus.IsAccepted. That is the raw α-of-K
|
||
// COUNT predicate — consensus.go sets block.accepted=true (and even populates its
|
||
// per-height finalized ledger) the instant acceptVotes>=alpha, with NO stake
|
||
// check. On a stake-weighted chain a low-stake/high-count coalition flips that
|
||
// count true WITHOUT a ⅔-stake supermajority, so reporting it would leak a
|
||
// finality claim the engine REFUSED to act on (no VM.Accept ran). Reading the
|
||
// engine's own finalizedByCert set — written only by AcceptWithCert — makes
|
||
// "accepted" mean exactly "finalized with a verified cert". A block stuck at
|
||
// count-α but lacking the cert is correctly NOT accepted here.
|
||
func (t *Transitive) IsAccepted(blockID ids.ID) bool {
|
||
t.mu.RLock()
|
||
defer t.mu.RUnlock()
|
||
_, ok := t.finalizedByCert[blockID]
|
||
return ok
|
||
}
|
||
|
||
// HasEnoughResponsesForRetry reports the LIVENESS signal: α-of-K validators have
|
||
// responded for the block (consensus.IsAccepted's raw count). This is the old
|
||
// IsAccepted count predicate, renamed to make its role unmistakable — it returns
|
||
// a RETRY signal, NEVER a finality verdict. A trigger may use it to decide
|
||
// whether it is worth calling TryAccept; it must NEVER itself finalize, and it
|
||
// does not increment blocksAccepted or touch the VM. Finality is decided solely
|
||
// by AcceptWithCert holding a VerifiedQuorumCert.
|
||
func (t *Transitive) HasEnoughResponsesForRetry(blockID ids.ID) bool {
|
||
return t.consensus.IsAccepted(blockID)
|
||
}
|
||
|
||
// Preference returns preferred block.
|
||
func (t *Transitive) Preference() ids.ID {
|
||
return t.consensus.Preference()
|
||
}
|
||
|
||
// PreferredBuildTip returns the deterministic build target — the deepest verified
|
||
// block extending the finalized chain — so the VM builds on the convergent tip
|
||
// (one block per height) instead of a competing sibling. See
|
||
// ChainConsensus.PreferredBuildTip.
|
||
func (t *Transitive) PreferredBuildTip() ids.ID {
|
||
return t.consensus.PreferredBuildTip()
|
||
}
|
||
|
||
// GetBlock handles a block request.
|
||
func (t *Transitive) GetBlock(ctx context.Context, nodeID ids.NodeID, requestID uint32, blockID ids.ID) error {
|
||
return nil
|
||
}
|
||
|
||
// Notify handles VM notifications.
|
||
func (t *Transitive) Notify(ctx context.Context, msg Message) error {
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
|
||
switch msg.Type {
|
||
case PendingTxs:
|
||
t.pendingBuildBlocks++
|
||
return t.buildBlocksLocked(ctx)
|
||
case StateSyncDone:
|
||
return nil
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ReceiveVote queues a vote for processing.
|
||
// Returns false if the engine is not started (vote is dropped).
|
||
func (t *Transitive) ReceiveVote(vote Vote) bool {
|
||
t.mu.RLock()
|
||
started := t.started
|
||
t.mu.RUnlock()
|
||
|
||
if !started {
|
||
return false
|
||
}
|
||
|
||
select {
|
||
case t.votes <- vote:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// Stats returns engine statistics.
|
||
func (t *Transitive) Stats() map[string]interface{} {
|
||
t.mu.RLock()
|
||
defer t.mu.RUnlock()
|
||
|
||
stats := t.consensus.Stats()
|
||
stats["blocks_built"] = t.blocksBuilt
|
||
stats["blocks_accepted"] = t.blocksAccepted
|
||
stats["blocks_rejected"] = t.blocksRejected
|
||
stats["votes_sent"] = t.votesSent
|
||
stats["votes_received"] = t.votesReceived
|
||
stats["pending_blocks"] = len(t.pendingBlocks)
|
||
stats["bootstrapped"] = t.bootstrapped
|
||
return stats
|
||
}
|
||
|
||
// PendingBuildBlocks returns pending build count.
|
||
func (t *Transitive) PendingBuildBlocks() int {
|
||
t.mu.RLock()
|
||
defer t.mu.RUnlock()
|
||
return t.pendingBuildBlocks
|
||
}
|
||
|
||
// HasPendingBlock checks if a block is in the pending blocks map (built or received but not yet finalized).
|
||
// This is used by the Vote handler to determine if votes should be processed immediately
|
||
// (block exists) or buffered (block not yet available).
|
||
func (t *Transitive) HasPendingBlock(blockID ids.ID) bool {
|
||
t.mu.RLock()
|
||
defer t.mu.RUnlock()
|
||
_, exists := t.pendingBlocks[blockID]
|
||
return exists
|
||
}
|
||
|
||
// GetPendingBlock returns the VMBlock for a pending block if it exists.
|
||
// This allows the Vote handler to process votes for blocks that are in consensus
|
||
// but not yet verified/stored in the VM.
|
||
func (t *Transitive) GetPendingBlock(blockID ids.ID) (block.Block, bool) {
|
||
t.mu.RLock()
|
||
defer t.mu.RUnlock()
|
||
pending, exists := t.pendingBlocks[blockID]
|
||
if !exists || pending.VMBlock == nil {
|
||
return nil, false
|
||
}
|
||
return pending.VMBlock, true
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Internal
|
||
// -----------------------------------------------------------------------------
|
||
|
||
func (t *Transitive) pollLoopWithCtx(ctx context.Context) {
|
||
defer t.wg.Done()
|
||
|
||
// Use BlockTime as poll interval — the engine must check finalization
|
||
// at least as fast as blocks are produced. For 1ms blocks this means
|
||
// 1ms polling; for mainnet 200ms blocks, 200ms polling.
|
||
interval := t.params.BlockTime
|
||
if interval <= 0 {
|
||
interval = 50 * time.Millisecond // fallback
|
||
}
|
||
ticker := time.NewTicker(interval)
|
||
defer ticker.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
if ctx.Err() != nil {
|
||
return
|
||
}
|
||
t.processPendingBlocks()
|
||
}
|
||
}
|
||
}
|
||
|
||
// Re-poll backoff/cap constants. The re-poll loop is a RARE liveness BACKSTOP,
|
||
// not a hot loop: a block stuck because peers are BEHIND its parent is never
|
||
// recovered by re-soliciting the same block (those peers cannot vote), so we
|
||
// re-poll on a geometric schedule and then STOP, leaving recovery to the
|
||
// catch-up path (which fetches the missing chain so peers can vote).
|
||
const (
|
||
// maxRePollBackoff caps the per-block re-poll interval. Starting from the base
|
||
// RoundTO and doubling, the interval climbs RoundTO, 2·, 4·, … but never
|
||
// exceeds this — so even a long-lived pending block is re-polled at most once
|
||
// every maxRePollBackoff, turning a 250ms storm into a trickle.
|
||
maxRePollBackoff = 16 * time.Second
|
||
|
||
// maxRePollAttempts is the hard cap on re-poll attempts for a NON-OWN (gossiped)
|
||
// block. After this many re-solicitations such a block is abandoned for re-poll
|
||
// (rePollAbandoned) — it is never re-polled again (but stays pending, recoverable
|
||
// via cert or catch-up): re-soliciting a gossiped block whose voters are behind
|
||
// its parent is spam that re-poll cannot fix. An UNDECIDED OWN proposal is NEVER
|
||
// abandoned (see rePollAllPending): the proposer keeps re-soliciting it until it
|
||
// finalizes, matching avalanchego's "re-poll a processing block until decided".
|
||
// With doubling from a 250ms base, 8 attempts span
|
||
// 0.25+0.5+1+2+4+8+16+16 ≈ 48s of backstop before giving up on a gossiped block.
|
||
maxRePollAttempts = 8
|
||
|
||
// catchupCooldown rate-limits ancestor fetches per missing parent: at most one
|
||
// RequestAncestors per missing block ID per cooldown, so many children of one
|
||
// missing parent (or repeated gossip of one orphan) cannot become a fetch
|
||
// storm. The cooldown is generous because a fetch is a round-trip that, once it
|
||
// lands, removes the orphan condition entirely.
|
||
catchupCooldown = 2 * time.Second
|
||
|
||
// maxCatchupRequested HARD-bounds the catchupRequested throttle map so a
|
||
// Byzantine validator streaming votes for forged random BlockIDs (which never
|
||
// arrive, so the delete-on-track/decide reclaim never fires for them) cannot
|
||
// grow it without limit. Layer-1 (delete-on-track/decide + the early-return
|
||
// reclaims in claimCatchupLocked) keeps honest entries from accumulating; this
|
||
// hard cap is layer-2 (defence in depth) for the all-forged flood where layer-1
|
||
// never reclaims. At the cap we first TTL-sweep, then fail closed (refuse the
|
||
// new claim) rather than grow — so the map can never exceed this size.
|
||
maxCatchupRequested = 4096
|
||
|
||
// catchupRequestTTL is the age past which a catchupRequested entry is reclaimed
|
||
// by the at-cap sweep. It is far beyond catchupCooldown: an honest fetch either
|
||
// lands (and is reclaimed by delete-on-track) or is abandoned well inside 30s,
|
||
// so a still-young entry at the cap signals an active forged flood — which we
|
||
// answer by refusing new claims, never by unbounded growth.
|
||
catchupRequestTTL = 30 * time.Second
|
||
|
||
// maxBufferedVotesPerBlock caps how many votes we park for ONE missing block.
|
||
// One vote per validator per block is the natural ceiling, so this must be ≥ the
|
||
// largest supported validator set or the buffered fast-path silently drops
|
||
// genuine votes 257..N and a net with α > cap cannot finalize from buffered votes
|
||
// alone (recoverable only via re-poll). node/config/tokenomics.go defines a
|
||
// 500-validator tier (and an unlimited tier); on a K=N / α=⌈⅔N⌉ chain with
|
||
// N=500, α≈334. 512 covers the 500-tier with margin. A flood beyond the cap for
|
||
// a single block ID is dropped (fail-closed) — the genuine α-of-K voters fit.
|
||
// The bound stays small: 512 × maxBufferedVoteBlocks × ~64B ≈ 33MB worst case.
|
||
maxBufferedVotesPerBlock = 512
|
||
|
||
// maxBufferedVoteBlocks caps how many DISTINCT missing block IDs we will park
|
||
// votes for at once. A spam stream of votes for never-delivered random block
|
||
// IDs cannot grow the buffer past this many keys: once full, votes for a NEW
|
||
// block ID are dropped (we do NOT evict an existing key — the simplest sound
|
||
// bound). Happy-path keys are removed on drain (the block arrived) or on decide,
|
||
// so this ceiling is only ever approached under adversarial junk.
|
||
maxBufferedVoteBlocks = 1024
|
||
)
|
||
|
||
// rePollLoopWithCtx is the LIVENESS retry that prevents a terminal first-poll
|
||
// stall. The proposer issues exactly ONE RequestVotes when it builds a block
|
||
// (buildBlocksLocked) and runs finalizeOwnProposal ONCE right after; if at that
|
||
// instant the α-of-K signed votes have not yet arrived (the common case at
|
||
// genesis — peers are still bootstrapping, or the first PushQuery was dropped at
|
||
// the network boundary), the proposer's block sits in pendingBlocks with only its
|
||
// own self-vote and NOTHING re-solicits the missing votes. The finality poll loop
|
||
// (processPendingBlocks) only CHECKS consensus.IsAccepted; it never re-requests.
|
||
// So a single lagging validator at height 0 wedged finality forever — the devnet
|
||
// freeze. This loop implements the "poll-timeout re-request" the topology doc
|
||
// (topology.go) already promises but that was never wired.
|
||
//
|
||
// It is a pure liveness retry: it re-solicits votes and re-attempts cert
|
||
// assembly, and changes NOTHING about the finality predicate. A block still
|
||
// finalizes only on a verified α-of-K cert (multi-validator) or the 1-of-1 force
|
||
// (single-validator); a genuine minority still cannot and does not finalize.
|
||
//
|
||
// The ticker wakes on the base RoundTO, but each block is gated by its OWN
|
||
// EXPONENTIAL BACKOFF (PendingBlock.rePollBackoff, doubling per attempt, capped)
|
||
// and a HARD ATTEMPT CAP (maxRePollAttempts): a stuck block is re-driven on a
|
||
// geometric schedule and then ABANDONED for re-poll — turning the former
|
||
// fixed-cadence 250ms storm (the devnet self-DoS) into a bounded trickle. A
|
||
// behind follower is NOT recovered by re-polling (it cannot vote without the
|
||
// parent); it is recovered by the catch-up path that fetches the missing chain.
|
||
func (t *Transitive) rePollLoopWithCtx(ctx context.Context) {
|
||
defer t.wg.Done()
|
||
|
||
// Wake on the base round budget; per-block backoff decides whether a given
|
||
// block is actually due. Fall back to a conservative 250ms if unset.
|
||
interval := t.params.RoundTO
|
||
if interval <= 0 {
|
||
interval = 250 * time.Millisecond
|
||
}
|
||
ticker := time.NewTicker(interval)
|
||
defer ticker.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
if ctx.Err() != nil {
|
||
return
|
||
}
|
||
t.rePollAllPending(ctx, interval)
|
||
}
|
||
}
|
||
}
|
||
|
||
// convergenceSettleWindow is how long a fork slot must have been OBSERVED (since this
|
||
// node first tracked any block at that height) before this node casts its one accept
|
||
// vote for the converged winner. It gives near-simultaneous sibling proposals time to
|
||
// gossip in, so every honest node has the SAME sibling set and selects the SAME
|
||
// lowest-canonical winner — the difference between a converging quorum and a permanent
|
||
// vote split. Derived from the round budget but clamped to a small band: long enough to
|
||
// cover intra-cluster gossip, short enough to add negligible per-block latency.
|
||
func (t *Transitive) convergenceSettleWindow() time.Duration {
|
||
// An operator-set window wins outright — DECOUPLED from RoundTO so a high-latency (WAN)
|
||
// validator set can lengthen the settle for its p99 gossip WITHOUT slowing the round
|
||
// cadence (the M-liveness / N4 gate: prod RoundTO 250-400ms yields a 150ms auto window,
|
||
// too tight for a 100-300ms-p99 WAN under a storm). A floor is still applied so a
|
||
// misconfigured near-zero value cannot disable the settle entirely.
|
||
if t.params.ConvergenceSettleWindow > 0 {
|
||
w := t.params.ConvergenceSettleWindow
|
||
if w < 150*time.Millisecond {
|
||
w = 150 * time.Millisecond
|
||
}
|
||
return w
|
||
}
|
||
// AUTO: half the round budget — collect competing proposals for half a round, then vote
|
||
// the lowest-canonical winner. This must comfortably exceed the sibling-gossip latency
|
||
// so every honest node has the SAME sibling set before it binds its one signature — a
|
||
// settle shorter than gossip lets a node settle on its OWN block before peers' arrive,
|
||
// which splits the vote and, under one-signature-per-height, is unrecoverable. Clamped
|
||
// so a tiny test round still gives a workable window and a huge production round does
|
||
// not stall block production waiting to vote.
|
||
w := t.params.RoundTO / 2
|
||
if w < 150*time.Millisecond {
|
||
w = 150 * time.Millisecond
|
||
}
|
||
if w > 2*time.Second {
|
||
w = 2 * time.Second
|
||
}
|
||
return w
|
||
}
|
||
|
||
// convergenceLoopWithCtx drives the per-height vote CONVERGENCE. On a fast tick it asks
|
||
// the wired ConvergenceVoter to sweep every undecided, still-unsigned fork slot whose
|
||
// settle window has elapsed and cast this node's one accept vote for the deterministic
|
||
// winner (lowest-canonical live sibling). This is what makes a fresh-net storm — where
|
||
// many validators build competing siblings at one height — converge: instead of each
|
||
// node binding its signature to the block it built or first-saw (a 5-way split that
|
||
// never reaches α), all honest nodes independently pick the SAME winner and one α-of-K
|
||
// cert assembles. A no-op when no convergence voter is wired (single-engine tests, which
|
||
// inject votes directly). K is re-checked EVERY tick, NOT captured once (N2): a net that
|
||
// grows from K==1 (its own accept is the quorum, no convergence needed) to K>1 without an
|
||
// engine restart must begin converging the moment the set expands — gating the whole
|
||
// goroutine on the K seen at start would leave such a chain permanently unable to converge.
|
||
func (t *Transitive) convergenceLoopWithCtx(ctx context.Context) {
|
||
defer t.wg.Done()
|
||
if t.convergenceVoter == nil {
|
||
return
|
||
}
|
||
tick := t.convergenceSettleWindow() / 3
|
||
if tick < 20*time.Millisecond {
|
||
tick = 20 * time.Millisecond
|
||
}
|
||
ticker := time.NewTicker(tick)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
if ctx.Err() != nil {
|
||
return
|
||
}
|
||
if t.consensus.K() <= 1 {
|
||
continue // single-validator right now: its own accept is the quorum
|
||
}
|
||
t.convergenceVoter.RunSettlePass(ctx)
|
||
}
|
||
}
|
||
}
|
||
|
||
// rePollAllPending re-drives every undecided pending block that has gone a full
|
||
// re-poll interval without being re-solicited. For each such block it:
|
||
//
|
||
// 1. re-attempts finalization (tryFinalizeBlock) — idempotent; assembles +
|
||
// gossips the cert if α signed votes are now present, so a follower that
|
||
// missed the proposer's single cert-gossip still finalizes; and
|
||
// 2. if this node PROPOSED the block and a proposer transport is wired, re-issues
|
||
// RequestVotes — re-sending the PushQuery so a laggard/peer that missed the
|
||
// first poll receives the block + vote request again and can sign.
|
||
//
|
||
// Single-validator (K==1) engines never stall here (their own accept is the
|
||
// quorum, finalized synchronously), so the re-poll is a no-op for them.
|
||
//
|
||
// base is the base re-poll interval (RoundTO). Each block is gated by its OWN
|
||
// backoff window (rePollBackoff, doubling per attempt from base, capped at
|
||
// maxRePollBackoff) and abandoned once rePollAttempts reaches maxRePollAttempts.
|
||
func (t *Transitive) rePollAllPending(ctx context.Context, base time.Duration) {
|
||
// K==1: no peer votes are ever needed; nothing to re-solicit.
|
||
if t.consensus.K() <= 1 {
|
||
return
|
||
}
|
||
|
||
now := time.Now()
|
||
|
||
// Snapshot the blocks due for a re-poll under the lock, then act without it
|
||
// (RequestVotes / tryFinalizeBlock take their own locks). A block is due once
|
||
// it has been undecided for its CURRENT backoff window since the later of
|
||
// ProposedAt and its last re-poll — so the FIRST re-poll waits one base
|
||
// interval after proposal (giving the normal fast path time to finalize), then
|
||
// the window DOUBLES each attempt, and after the cap the block is abandoned.
|
||
type due struct {
|
||
blockID ids.ID
|
||
blockData []byte
|
||
ownProp bool
|
||
}
|
||
var dueBlocks []due
|
||
t.mu.Lock()
|
||
for blockID, pending := range t.pendingBlocks {
|
||
if pending.Decided || pending.rePollAbandoned {
|
||
continue
|
||
}
|
||
// The window for THIS attempt: base for the first, doubling thereafter,
|
||
// capped. rePollBackoff carries the PREVIOUS window (0 before the first).
|
||
window := pending.rePollBackoff
|
||
if window <= 0 {
|
||
window = base
|
||
}
|
||
last := pending.lastRePoll
|
||
if last.IsZero() {
|
||
last = pending.ProposedAt
|
||
}
|
||
if now.Sub(last) < window {
|
||
continue
|
||
}
|
||
|
||
// This block is due. Record the attempt and advance the backoff (double,
|
||
// cap) so re-solicitation is a bounded trickle (≤ maxRePollBackoff), never a
|
||
// storm.
|
||
pending.lastRePoll = now
|
||
pending.rePollAttempts++
|
||
next := window * 2
|
||
if next > maxRePollBackoff {
|
||
next = maxRePollBackoff
|
||
}
|
||
pending.rePollBackoff = next
|
||
// LIVENESS (the down/wedged/forked-proposer halt): an UNDECIDED OWN proposal
|
||
// is NEVER abandoned. This node BUILT it on the finalized tip (its voters
|
||
// therefore HAVE the parent and CAN vote), and as the proposer it owns driving
|
||
// it to an α-of-K cert — so it must keep re-soliciting until the block decides
|
||
// (then it leaves pendingBlocks and the re-poll quiesces). This is exactly
|
||
// avalanchego's contract: re-poll a PROCESSING block until it is decided, and
|
||
// only quiesce at NumProcessing()==0 (snow/engine/snowman voter.go +
|
||
// Engine.Gossip's 100ms repoll). Abandoning an own proposal after a fixed
|
||
// attempt cap was the Lux-only divergence that froze mainnet C-Chain: the
|
||
// substitute's canonical block stopped being re-solicited and the chain halted
|
||
// even though the honest majority was ready to vote (zero-margin 4-of-5 once a
|
||
// 5th validator forks/wedges). The bounded backoff above keeps this storm-safe.
|
||
//
|
||
// A NON-own (gossiped) block keeps the attempt cap: re-soliciting a block whose
|
||
// voters are BEHIND its parent (the gossip-from-an-ahead-peer case) is pure spam
|
||
// and never recovers it — that block recovers via cert-gossip or the catch-up
|
||
// fetch, not by re-poll. So the cap still bounds the follower path.
|
||
if !pending.IsOwnProposal && pending.rePollAttempts >= maxRePollAttempts {
|
||
pending.rePollAbandoned = true
|
||
t.log.Warn("re-poll: gossiped block abandoned after attempt cap — not re-soliciting further (recoverable via cert/catch-up)",
|
||
"blockID", blockID, "attempts", pending.rePollAttempts)
|
||
}
|
||
|
||
var data []byte
|
||
if pending.VMBlock != nil {
|
||
data = pending.VMBlock.Bytes()
|
||
}
|
||
dueBlocks = append(dueBlocks, due{blockID: blockID, blockData: data, ownProp: pending.IsOwnProposal})
|
||
}
|
||
proposer := t.proposer
|
||
t.mu.Unlock()
|
||
|
||
for _, d := range dueBlocks {
|
||
// (1) Re-attempt finalization first: if α signed votes already arrived but
|
||
// the single finalize attempt raced (or a follower missed the cert gossip),
|
||
// this assembles + gossips the cert and commits now. Idempotent.
|
||
t.tryFinalizeBlock(ctx, d.blockID)
|
||
|
||
// (2) Proposer re-poll: re-send the vote request so a laggard re-receives
|
||
// the block and votes. Only the proposer polls peers (followers learn the
|
||
// block via gossip and broadcast their own votes); a follower short of
|
||
// quorum recovers via the cert-gossip path that step (1) re-runs, or via
|
||
// catch-up if it is behind the block's parent. The backoff above bounds how
|
||
// often this fires; the cap stops it entirely for a terminally stuck block.
|
||
if d.ownProp && proposer != nil {
|
||
_ = proposer.RequestVotes(ctx, VoteRequest{
|
||
BlockID: d.blockID,
|
||
BlockData: d.blockData,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// claimCatchupLocked is the engine's idempotency + rate-limit GATE for "fetch
|
||
// this missing ancestor". It is the SINGLE decision point for whether a catch-up
|
||
// fetch should fire — the Runtime owns the actual network round-trip (it carries
|
||
// the networkID + transport). Caller holds t.mu.
|
||
//
|
||
// Returns true iff the caller should now issue exactly one RequestAncestors for
|
||
// missingID. It returns false (suppressing the fetch) when:
|
||
// - no catchup transport is wired (legacy: a behind follower stays stranded),
|
||
// - missingID is Empty (genesis/no parent — nothing to fetch),
|
||
// - the block is already tracked or known to consensus (not actually missing),
|
||
// - a fetch for this missing ID fired within catchupCooldown (throttle — so
|
||
// many children of one missing parent, or repeated gossip of one orphan,
|
||
// issue ONE fetch per cooldown, never a fetch storm).
|
||
//
|
||
// On a true return it records the throttle stamp, so the gate is self-arming:
|
||
// the caller does not need to remember anything. This is the catch-up analogue
|
||
// of the re-poll backoff — one mechanism, one place.
|
||
//
|
||
// The catchupRequested map is bounded TWO ways, both fail-closed:
|
||
// - Layer 1 (reclaim-on-known): when missingID turns out to be already tracked
|
||
// or known to consensus, its throttle entry can never be needed again, so we
|
||
// delete it at those early returns (the dual of delete-on-track/decide at the
|
||
// accept/reject/sync sites). An honest fetch that LANDS is reclaimed this way.
|
||
// - Layer 2 (hard cap + TTL): a FORGED id that never arrives is never reclaimed
|
||
// by layer 1, so before inserting a new entry at the cap we sweep entries
|
||
// older than catchupRequestTTL; if the map is still at the cap (all young — an
|
||
// active flood) we REFUSE the claim (no insert, no fetch). The map can never
|
||
// exceed maxCatchupRequested.
|
||
func (t *Transitive) claimCatchupLocked(missingID ids.ID) bool {
|
||
if t.catchup == nil || missingID == ids.Empty {
|
||
return false
|
||
}
|
||
// Already have it tracked (or it just finalized) ⇒ not missing. A now-arrived
|
||
// block can never be re-needed, so reclaim any throttle entry for it (layer 1).
|
||
if _, tracked := t.pendingBlocks[missingID]; tracked {
|
||
delete(t.catchupRequested, missingID)
|
||
return false
|
||
}
|
||
if _, ok := t.consensus.GetBlock(missingID); ok {
|
||
delete(t.catchupRequested, missingID)
|
||
return false
|
||
}
|
||
now := time.Now()
|
||
if last, ok := t.catchupRequested[missingID]; ok && now.Sub(last) < catchupCooldown {
|
||
return false // throttled — one fetch per missing parent per cooldown
|
||
}
|
||
// HARD bound (layer 2): only when at the cap and inserting a NEW key. Sweep
|
||
// entries past the TTL (an honest fetch resolves far inside catchupRequestTTL);
|
||
// if still full afterward, every entry is young — an active forged-ID flood —
|
||
// so fail closed: refuse the new claim rather than grow past the cap. The sweep
|
||
// is O(size), bounded, and runs only at the cap (free in steady state).
|
||
if _, existing := t.catchupRequested[missingID]; !existing && len(t.catchupRequested) >= maxCatchupRequested {
|
||
for id, stamp := range t.catchupRequested {
|
||
if now.Sub(stamp) >= catchupRequestTTL {
|
||
delete(t.catchupRequested, id)
|
||
}
|
||
}
|
||
if len(t.catchupRequested) >= maxCatchupRequested {
|
||
return false // map saturated with active (young) entries — fail closed
|
||
}
|
||
}
|
||
t.catchupRequested[missingID] = now
|
||
return true
|
||
}
|
||
|
||
// pipelineLoop implements pipelined block production: as soon as a block is
|
||
// finalized, immediately build the next block. This decouples throughput from
|
||
// latency — with a 10-stage pipeline, a 10ms round produces 1 block/ms.
|
||
func (t *Transitive) pipelineLoop(ctx context.Context) {
|
||
defer t.wg.Done()
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-t.pipelineSignal:
|
||
if ctx.Err() != nil {
|
||
return
|
||
}
|
||
// A block was just finalized — immediately try to build the next one
|
||
t.mu.Lock()
|
||
if t.vm != nil {
|
||
t.pendingBuildBlocks++
|
||
_ = t.buildBlocksLocked(ctx)
|
||
}
|
||
t.mu.Unlock()
|
||
}
|
||
}
|
||
}
|
||
|
||
// signalPipeline wakes the pipeline goroutine to build the next block.
|
||
func (t *Transitive) signalPipeline() {
|
||
select {
|
||
case t.pipelineSignal <- struct{}{}:
|
||
default: // already signaled
|
||
}
|
||
}
|
||
|
||
func (t *Transitive) processPendingBlocks() {
|
||
// Phase 1: Snapshot pending block IDs under t.mu (fast read lock).
|
||
type candidate struct {
|
||
blockID ids.ID
|
||
vmBlock block.Block
|
||
}
|
||
t.mu.RLock()
|
||
var candidates []candidate
|
||
for blockID, pending := range t.pendingBlocks {
|
||
if !pending.Decided {
|
||
candidates = append(candidates, candidate{blockID: blockID, vmBlock: pending.VMBlock})
|
||
}
|
||
}
|
||
t.mu.RUnlock()
|
||
|
||
if len(candidates) == 0 {
|
||
return
|
||
}
|
||
|
||
// Phase 2: classify each candidate WITHOUT holding t.mu (avoids nested lock).
|
||
//
|
||
// consensus.IsAccepted / IsRejected here are the LIVENESS TRIGGER, not the
|
||
// finality authority: a true IsAccepted means "α-of-K voters responded — it is
|
||
// worth ATTEMPTING to finalize", nothing more. The actual accept decision is
|
||
// made by TryAccept in phase 3, which finalizes ONLY with a VerifiedQuorumCert
|
||
// (the strict >⅔-of-stake gate). So a low-stake/high-count coalition that flips
|
||
// IsAccepted here cannot finalize: TryAccept refuses it (ErrNoVerifiedQC) and
|
||
// the block stays pending. Rejections carry no stake-safety concern (a block is
|
||
// dropped, not finalized) and are committed inline.
|
||
type blockAction struct {
|
||
blockID ids.ID
|
||
vmBlock block.Block
|
||
accept bool
|
||
}
|
||
var actions []blockAction
|
||
for _, c := range candidates {
|
||
if t.consensus.IsRejected(c.blockID) {
|
||
actions = append(actions, blockAction{blockID: c.blockID, vmBlock: c.vmBlock, accept: false})
|
||
} else if t.consensus.IsAccepted(c.blockID) {
|
||
actions = append(actions, blockAction{blockID: c.blockID, vmBlock: c.vmBlock, accept: true})
|
||
}
|
||
}
|
||
|
||
if len(actions) == 0 {
|
||
return
|
||
}
|
||
|
||
// Phase 3: accepts go through the SOLE cert-gated path (TryAccept); rejects
|
||
// are committed here. TryAccept is idempotent and takes its own lock — it
|
||
// finalizes iff a verified cert exists, otherwise returns ErrNoVerifiedQC and
|
||
// changes nothing (the block waits for the next tick). It also subsumes the
|
||
// VM.Accept + SetPreference + pipeline-signal that the old phase 4 did inline,
|
||
// so accepts no longer touch VM state from this loop at all.
|
||
rejected := make([]blockAction, 0, len(actions))
|
||
for _, action := range actions {
|
||
if action.accept {
|
||
_ = t.TryAccept(context.Background(), action.blockID)
|
||
continue
|
||
}
|
||
rejected = append(rejected, action)
|
||
}
|
||
|
||
if len(rejected) == 0 {
|
||
return
|
||
}
|
||
|
||
// Phase 4: commit the rejections (no cert required — a reject finalizes
|
||
// nothing). Mirror the previous found/double-decide guard so a block already
|
||
// decided by another trigger is not Reject'd twice.
|
||
t.mu.Lock()
|
||
ctx := t.ctx
|
||
found := make([]bool, len(rejected))
|
||
for i, action := range rejected {
|
||
pending, exists := t.pendingBlocks[action.blockID]
|
||
if !exists || pending.Decided {
|
||
continue
|
||
}
|
||
found[i] = true
|
||
pending.Decided = true
|
||
t.blocksRejected++
|
||
delete(t.pendingBlocks, action.blockID)
|
||
// Drop any votes parked for a now-rejected block (it will never be tracked
|
||
// to drain them) so the buffer cannot leak.
|
||
delete(t.bufferedVotes, action.blockID)
|
||
// A rejected (decided) block is no longer "missing" — reclaim its catch-up
|
||
// throttle entry so catchupRequested stays bounded.
|
||
delete(t.catchupRequested, action.blockID)
|
||
}
|
||
t.mu.Unlock()
|
||
|
||
for i, action := range rejected {
|
||
if !found[i] {
|
||
continue // already decided by another trigger
|
||
}
|
||
if action.vmBlock != nil {
|
||
action.vmBlock.Reject(ctx)
|
||
}
|
||
}
|
||
}
|
||
|
||
func (t *Transitive) voteHandlerWithCtx(ctx context.Context) {
|
||
defer t.wg.Done()
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case vote := <-t.votes:
|
||
// Check context again before processing to avoid work after shutdown
|
||
if ctx.Err() != nil {
|
||
return
|
||
}
|
||
t.handleVote(vote)
|
||
}
|
||
}
|
||
}
|
||
|
||
func (t *Transitive) handleVote(vote Vote) {
|
||
// Collect state under t.mu, release before calling consensus methods
|
||
// to avoid nested lock (t.mu -> c.mu) deadlock.
|
||
t.mu.Lock()
|
||
t.votesReceived++
|
||
pending, exists := t.pendingBlocks[vote.BlockID]
|
||
detector := t.slashingDetector
|
||
sdb := t.slashingDB
|
||
ctx := t.ctx
|
||
|
||
if !exists {
|
||
// A vote for an ALREADY-FINALIZED block needs nothing: the block is decided
|
||
// (removed from pendingBlocks, recorded in finalizedByCert). Do NOT buffer or
|
||
// fetch it — that would re-park a late/duplicate vote AFTER acceptWithCertCore
|
||
// cleared the buffer, leaking a slot for a block that will never re-track to
|
||
// drain it. Drop it (the finality cert already exists).
|
||
if _, finalized := t.finalizedByCert[vote.BlockID]; finalized {
|
||
t.mu.Unlock()
|
||
return
|
||
}
|
||
// VOTE FOR A BLOCK WE DO NOT YET TRACK — the gossip race that wedged the
|
||
// write path: a peer's vote outran the block bytes. The old code DROPPED it
|
||
// here, and since votes are solicited only once, the drop was permanent —
|
||
// the missing-bytes follower could never reach α-of-K and the block never
|
||
// Accepted. Instead BUFFER the vote (bounded, no signature work yet) and ask
|
||
// the catch-up seam to FETCH the missing block, exactly as the missing-parent
|
||
// path does. When the block lands at a tracking site, drainBufferedVotes
|
||
// replays each parked vote through the normal channel path, where it is
|
||
// signature-verified like any live vote (buffering NEVER bypasses the gate;
|
||
// a forged/unsigned parked vote costs one map slot and is dropped on replay).
|
||
accepted := t.bufferVoteLocked(vote)
|
||
// GATE the fetch on buffer acceptance. If the buffer REFUSED the vote (a cap
|
||
// was hit), do NOT fetch — a fetch for a vote we did not park is pure
|
||
// amplification: there is nothing buffered for the fetched block to drain
|
||
// into. Fetching ONLY for parked votes gives a bounded aggregate fetch rate:
|
||
// at most min(maxBufferedVoteBlocks, maxCatchupRequested) distinct fetches in
|
||
// flight (one per parked-and-claimed missing ID), each re-fireable at most
|
||
// once per catchupCooldown. That is the global fetch ceiling — it falls out
|
||
// of bounding BOTH bufferedVotes and catchupRequested.
|
||
var fetch func(missingID ids.ID, from ids.NodeID)
|
||
if accepted {
|
||
fetch = t.requestMissing
|
||
}
|
||
t.mu.Unlock()
|
||
// Fire the fetch WITHOUT the lock: requestMissing routes to
|
||
// Runtime.requestCatchup, which claims its OWN lock for the idempotency gate
|
||
// (claimCatchupLocked) and then does the RequestAncestors round-trip. Calling
|
||
// it while still holding t.mu would re-enter the lock (non-reentrant) and
|
||
// deadlock. nil ⇒ no runtime wired (bare engine) OR the buffer rejected the
|
||
// vote (no payoff in fetching); either way the vote waits / is dropped.
|
||
if fetch != nil {
|
||
fetch(vote.BlockID, vote.NodeID)
|
||
}
|
||
return
|
||
}
|
||
|
||
var height uint64
|
||
if pending.ConsensusBlock != nil {
|
||
height = pending.ConsensusBlock.height
|
||
}
|
||
|
||
// Equivocation + jail checks before counting the vote.
|
||
// Detector and DB use their own locks; safe to call under t.mu
|
||
// since there is no lock ordering conflict (t.mu -> detector.mu is one direction only).
|
||
if detector != nil {
|
||
if ev := detector.CheckVote(vote.NodeID, height, vote.BlockID, vote.Accept); ev != nil {
|
||
if sdb != nil {
|
||
sdb.RecordEvidence(*ev)
|
||
}
|
||
t.mu.Unlock()
|
||
return
|
||
}
|
||
if sdb != nil && sdb.IsJailed(vote.NodeID) {
|
||
t.mu.Unlock()
|
||
return
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
// Resolve the voter's pubkey at the block's P-CHAIN epoch height (RESIDUAL-B),
|
||
// the same height the position's set-root commits to.
|
||
if len(vote.Signature) == 0 || !t.voteVerifier.VerifyVote(vote.NodeID, msg, vote.Signature, t.epochHeightLocked(pending)) {
|
||
// 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 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, accept); err != nil {
|
||
return
|
||
}
|
||
_ = t.consensus.Poll(ctx, map[ids.ID]int{vote.BlockID: voteCount})
|
||
|
||
// Finalize: if consensus reached the α-of-K accept quorum, assemble the
|
||
// cert, gossip it, and call VM.Accept().
|
||
t.tryFinalizeBlock(ctx, vote.BlockID)
|
||
}
|
||
|
||
// bufferVoteLocked parks a vote for a not-yet-tracked block, enforcing both
|
||
// bounds (fail-closed) AND one-vote-per-(block, validator) dedup. Caller holds
|
||
// t.mu. It does NO signature work — a parked vote is verified only when
|
||
// drainBufferedVotes replays it through handleVote.
|
||
//
|
||
// Dedup invariant: at most ONE buffered vote per (BlockID, NodeID). If a vote for
|
||
// this {BlockID, NodeID} pair is already parked, it is REPLACED in place rather
|
||
// than appended, so the per-block slice is bounded by DISTINCT NodeIDs, not raw
|
||
// arrival count. This is the dual of the certVotes NodeID-keyed dedup that
|
||
// recordCertVoteLocked applies to live votes — same keying, mirrored onto the
|
||
// slice. It closes the single-Byzantine-ID crowd-out: one NodeID can occupy at
|
||
// most ONE slot per block, so it cannot flood maxBufferedVotesPerBlock junk
|
||
// entries and crowd genuine validators' votes out of the buffer fast-path.
|
||
//
|
||
// Returns accepted=true iff a vote for this block is now parked/represented; the
|
||
// caller uses this to GATE the catch-up fetch. A same-(block, node) REPLACEMENT
|
||
// still returns true: there is a parked vote for that block to drain into, so the
|
||
// fetch is still warranted. accepted=false only when a cap dropped the vote — and
|
||
// a vote we refused to even buffer must NOT trigger a fetch (firing one for a
|
||
// dropped vote is pure amplification with no payoff — nothing is parked for the
|
||
// fetched block to drain into).
|
||
// - Per-block cap: if maxBufferedVotesPerBlock DISTINCT NodeIDs are already
|
||
// parked for this block ID, a vote from a NEW NodeID is dropped (the real
|
||
// α-of-K voters fit well within; a replacement of an existing NodeID never
|
||
// hits the cap — it does not grow the slice).
|
||
// - Total-keys cap: if this is a NEW block ID and maxBufferedVoteBlocks distinct
|
||
// IDs are already parked, the new key is dropped (we never evict an existing
|
||
// key — the simplest sound bound; existing keys drain on track or delete on
|
||
// decide).
|
||
func (t *Transitive) bufferVoteLocked(vote Vote) (accepted bool) {
|
||
existing, seen := t.bufferedVotes[vote.BlockID]
|
||
if !seen && len(t.bufferedVotes) >= maxBufferedVoteBlocks {
|
||
return false // total distinct-block ceiling reached — fail closed
|
||
}
|
||
// Dedup by NodeID (dual of certVotes): if this validator already has a vote
|
||
// parked for this block, replace it in place — never append a second. This is
|
||
// what bounds the slice by distinct NodeIDs and defeats single-ID crowd-out.
|
||
for i := range existing {
|
||
if existing[i].NodeID == vote.NodeID {
|
||
existing[i] = vote
|
||
return true // a vote for this block is parked — fetch still warranted
|
||
}
|
||
}
|
||
if len(existing) >= maxBufferedVotesPerBlock {
|
||
return false // per-block ceiling reached — fail closed
|
||
}
|
||
t.bufferedVotes[vote.BlockID] = append(existing, vote)
|
||
return true
|
||
}
|
||
|
||
// drainBufferedVotes replays every vote parked for blockID now that the block is
|
||
// tracked. It removes the slice under t.mu, deletes the key (so the buffer cannot
|
||
// leak for a block that did arrive), and re-enqueues each vote via ReceiveVote —
|
||
// the SAME channel path a live vote takes. Re-enqueueing (rather than calling
|
||
// handleVote inline) avoids re-entrant locking and keeps ONE code path: each
|
||
// replayed vote re-enters handleVote with the block now tracked, so it is
|
||
// signature-verified exactly as a live vote (a forged/unsigned parked vote is
|
||
// dropped at the gate; it never counts). Called at every block-tracking site. If
|
||
// the vote channel is full, ReceiveVote returns false and the vote is dropped —
|
||
// acceptable: the periodic re-poll / re-gossip will re-deliver it.
|
||
func (t *Transitive) drainBufferedVotes(blockID ids.ID) {
|
||
t.mu.Lock()
|
||
parked := t.bufferedVotes[blockID]
|
||
delete(t.bufferedVotes, blockID)
|
||
t.mu.Unlock()
|
||
|
||
for _, vote := range parked {
|
||
t.ReceiveVote(vote)
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
// VIEW-CHANGE: a proposer must NOT precommit its own block at build time — it must
|
||
// PREVOTE it and precommit only on a POL (round_view.go). Recording an immediate own
|
||
// precommit here would be an unjustified precommit (no POL) that the lock rule forbids.
|
||
// The view-change driver (runViewChangePass) casts this node's prevote+precommit for the
|
||
// converged winner instead. Skip the build-time self-precommit under view-change.
|
||
if t.params.ViewChange {
|
||
return
|
||
}
|
||
pos := t.blockPositionLocked(pending, blockID)
|
||
// NON-EQUIVOCATION (fork guard): refuse to sign a conflicting sibling at a HEIGHT
|
||
// this node has already committed to — and DURABLY record the binding before
|
||
// signing so a crash cannot forget it (HIGH-1). Idempotent for the same canonical
|
||
// block. One signature per consensus height is the invariant that keeps two
|
||
// α-of-K certs at one height impossible.
|
||
if !t.reserveSlotForSign(pos.Height, slotCanonical(pos)) {
|
||
t.log.Warn("vote-once: refusing to sign a conflicting sibling at an already-committed slot",
|
||
"height", pos.Height, "blockID", blockID)
|
||
return
|
||
}
|
||
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,
|
||
})
|
||
}
|
||
|
||
// reserveSlotForSign enforces the per-HEIGHT non-equivocation rule before this node
|
||
// casts an accept signature, and DURABLY records the binding first so it survives a
|
||
// crash (HIGH-1). `canonical` is the block's inner execution commitment (the id
|
||
// finality certifies). The slot is epoch-BLIND: one signature per consensus height,
|
||
// full stop, so no proposer-chosen epoch can fragment it. Returns true if signing is
|
||
// permitted: the slot is unbound (binds it now, AFTER the durable write commits) or
|
||
// already bound to THIS canonical (idempotent — a legitimate re-solicit of the same
|
||
// block, already durable). Returns FALSE if the slot is bound to a DIFFERENT
|
||
// canonical — a conflicting sibling this node must NEVER sign (the cross-node fork
|
||
// Red proved) — OR if the durable write FAILS (fail-closed: no memory of the
|
||
// binding ⇒ no signature). Self-locking (slotMu) so both signing sites —
|
||
// recordOwnVoteLocked (t.mu held) and the follower path in followVerifiedBlock
|
||
// (t.mu released) — share the one guard without deadlock; the durable write also
|
||
// runs under slotMu, so the fixed-name temp file is never contended.
|
||
func (t *Transitive) reserveSlotForSign(height uint64, canonical ids.ID) bool {
|
||
// DECIDED-HEIGHT GATE (avalanchego's lastAcceptedHeight frontier, lifted to the sign
|
||
// path). A height at or below the decided frontier is DECIDED: exactly one block was
|
||
// certified there, and — as in topological.go's acceptPreferredChild + rejectTransitively
|
||
// — its every sibling is permanently unsignable and any vote for it is dropped. We
|
||
// enforce that same invariant on the cert layer here: never sign at a decided height.
|
||
//
|
||
// This closes the prune-then-resign fork. The per-height committedSlot below refuses a
|
||
// conflicting sibling only WHILE the slot lives; but the slot is pruned as the window
|
||
// advances, and a losing sibling with a DIFFERENT outer parent (a bare/pre-fork envelope
|
||
// vs a wrapped one) is NOT in losingSubtrees, so it stays tracked and undecided after the
|
||
// winner finalizes. Without this gate, once the winner's height slot is pruned, that
|
||
// late/differently-enveloped sibling would pass the (now empty) slot check and get this
|
||
// node's SECOND signature — the path that assembled two α-of-K certs at one height on the
|
||
// fresh devnet (artifacts-A: the conflicting cert's canonical == its envelope, the bare
|
||
// sibling).
|
||
//
|
||
// The floor is the max of two DURABLE, restart-surviving sources — this is what makes the
|
||
// refusal span a crash (the certified ledger.Height() alone does NOT: on boot it is a
|
||
// (0,false) non-authoritative hint until the first post-restart cert — incident-1082814
|
||
// PART-A):
|
||
// • t.decidedFloor — seeded on boot from the vote-guard file's fsync'd finalizedThrough
|
||
// (WithVoteGuard) and advanced at each finalize. This is authoritative: the vote-guard
|
||
// is written by the consensus engine at finalize time, so it NEVER lags the decision
|
||
// (unlike vm.LastAccepted, which a fire-and-forget Accept can leave frozen).
|
||
// • consensus.GetDecidedFloor() — max(certified height, vm.LastAccepted hint); a
|
||
// complementary lower bound, and the sole floor for a signer whose vote-guard is fresh.
|
||
// SIGN-GATE ONLY: neither source enters the finality ledger / byHeight / the equivocation
|
||
// index, so PART-A is preserved (Height() stays (0,false)-until-cert). Read the consensus
|
||
// floor BEFORE slotMu so this method never holds slotMu while touching the consensus lock.
|
||
consensusFloor := uint64(0)
|
||
if t.consensus != nil {
|
||
consensusFloor = t.consensus.GetDecidedFloor()
|
||
}
|
||
key := SlotKey{Height: height}
|
||
t.slotMu.Lock()
|
||
defer t.slotMu.Unlock()
|
||
floor := t.decidedFloor
|
||
if consensusFloor > floor {
|
||
floor = consensusFloor
|
||
}
|
||
if height <= floor {
|
||
return false // decided height — permanently unsignable (durable across restart)
|
||
}
|
||
if bound, ok := t.committedSlot[key]; ok {
|
||
if bound == canonical {
|
||
return true // same block ⇒ idempotent (already durable)
|
||
}
|
||
// VIEW-CHANGE: a conflicting canonical at an UN-finalized height is permitted here —
|
||
// the round-scoped lock rule (round_view.go recordOwnPrecommit) already governs WHICH
|
||
// value this node may precommit and at what round, so the height-only refusal would
|
||
// wrongly freeze the re-convergence. The DURABLE decided-floor gate above still makes a
|
||
// FINALIZED height unsignable (the v1.35.5 PART-A / HIGH-1 finalized-floor safety is
|
||
// untouched). For the legacy single-phase path, keep the strict height-only refusal.
|
||
if !t.params.ViewChange {
|
||
return false // legacy: sibling at an already-signed height ⇒ refuse
|
||
}
|
||
// ROLLING-RESTART SAFETY: a binding RECOVERED from disk has no persisted lock round, so
|
||
// the unlock rule cannot be evaluated — treat it as a HARD lock and REFUSE a conflicting
|
||
// value (the node finalizes the network's choice via cert gossip, never via a second
|
||
// local precommit). In-session bindings fall through to the round+lock re-precommit.
|
||
if _, recovered := t.recoveredLocks[key.Height]; recovered {
|
||
return false
|
||
}
|
||
// fall through: re-bind to the newly-precommitted canonical (persist below).
|
||
}
|
||
// First binding at this slot. PERSIST it durably BEFORE permitting the signature —
|
||
// a crash after signing but before finalizing must not forget it. Mutate the map,
|
||
// snapshot it to stable storage, and ROLL BACK on failure so in-memory state stays
|
||
// consistent with what is durable and we FAIL CLOSED (return false ⇒ no signature).
|
||
t.committedSlot[key] = canonical
|
||
if t.voteGuard != nil {
|
||
if err := t.voteGuard.Persist(t.committedSlot, t.lockRounds, t.decidedFloor); err != nil {
|
||
delete(t.committedSlot, key)
|
||
t.log.Error("vote-once: durable equivocation-guard write FAILED — refusing to sign (fail-closed)",
|
||
"height", height, "error", err)
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// hasSignedHeight reports whether this node has already bound its ONE accept
|
||
// signature at the given consensus height (any canonical). The convergence voter
|
||
// checks it to emit at most one vote per height and never re-broadcast. Self-locking
|
||
// (slotMu).
|
||
func (t *Transitive) hasSignedHeight(height uint64) bool {
|
||
t.slotMu.Lock()
|
||
_, ok := t.committedSlot[SlotKey{Height: height}]
|
||
t.slotMu.Unlock()
|
||
return ok
|
||
}
|
||
|
||
// pruneCommittedSlotsBelow drops equivocation-guard entries STRICTLY BELOW a finalized
|
||
// height — retaining the just-finalized tip's slot, exactly as avalanchego keeps the last
|
||
// accepted block in ts.blocks. Heights strictly below the tip are guaranteed unsignable by
|
||
// the decided-height gate in reserveSlotForSign (height <= finalizedHeight ⇒ refuse), which
|
||
// is durable and monotonic, so their in-memory slot is dead weight and is dropped to keep
|
||
// committedSlot (and its snapshot) bounded to the live window. The tip's slot is KEPT as a
|
||
// second, in-memory belt: it refuses a conflicting sibling at the tip height even in the
|
||
// window before finalizedHeight is observed. It is itself pruned only once the NEXT height
|
||
// finalizes (k.Height < height at the higher finalize), by which point the decided-height
|
||
// gate covers it durably.
|
||
//
|
||
// CRITICAL — why STRICTLY below, not at-or-below: an inclusive prune (k.Height <= height)
|
||
// deleted the just-finalized height's slot, opening the prune-then-resign fork. A losing
|
||
// sibling with a DIFFERENT outer parent than the winner (bare vs wrapped envelope) is not in
|
||
// losingSubtrees, so it stays tracked+undecided after the winner finalizes; with the slot
|
||
// deleted and no decided-height gate, the convergence pass would then place this node's
|
||
// SECOND signature on it — two α-of-K certs at one height → the exit(1) equivocation fatal.
|
||
//
|
||
// Called from the sole finalizer acceptWithCertCore, so EVERY finality path — local
|
||
// vote-assembly AND incoming-cert — prunes (MEDIUM-1). The durable shrink is best-effort: a
|
||
// stale-LARGER durable set only ever REFUSES more (fail-safe direction), corrected on the
|
||
// next successful Persist.
|
||
func (t *Transitive) pruneCommittedSlotsBelow(height uint64) {
|
||
t.slotMu.Lock()
|
||
defer t.slotMu.Unlock()
|
||
// Advance the DURABLE decided-through floor: this height is now finalized, so it (and
|
||
// everything below) is permanently unsignable. Monotonic. Persisting it in the SAME
|
||
// write as the pruned map keeps the floor and the slot removal atomic on disk — so a
|
||
// restart recovers "these heights are decided" even though their slots are gone.
|
||
floorAdvanced := false
|
||
if height > t.decidedFloor {
|
||
t.decidedFloor = height
|
||
floorAdvanced = true
|
||
}
|
||
changed := false
|
||
for k := range t.committedSlot {
|
||
if k.Height < height {
|
||
delete(t.committedSlot, k)
|
||
changed = true
|
||
}
|
||
}
|
||
// A recovered hard lock is released once its height is decided (≤ finalized): it is either
|
||
// the value that finalized, or the node has since finalized the network's value via cert
|
||
// gossip — either way the height is settled and the strict-refusal belt is no longer needed.
|
||
for h := range t.recoveredLocks {
|
||
if h <= height {
|
||
delete(t.recoveredLocks, h)
|
||
}
|
||
}
|
||
for h := range t.lockRounds {
|
||
if h < height {
|
||
delete(t.lockRounds, h) // strictly-below, mirroring committedSlot (retain the tip's lock round)
|
||
}
|
||
}
|
||
if (changed || floorAdvanced) && t.voteGuard != nil {
|
||
if err := t.voteGuard.Persist(t.committedSlot, t.lockRounds, t.decidedFloor); err != nil {
|
||
t.log.Warn("vote-once: durable equivocation-guard shrink failed (non-fatal; floor re-persisted on next bind)",
|
||
"belowHeight", height, "error", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// seedDecidedFloorFromVMLocked advances the durable decided-height floor from the VM's
|
||
// last-accepted height. It runs at boot (from Start, before the signing goroutines launch)
|
||
// so the sign gate has a real floor from the first instant — the fix for the mainnet v1→v2
|
||
// vote-guard upgrade window, where the file floor is 0 and the certified ledger is a
|
||
// (0,false) hint until the first post-upgrade finalize. vm.LastAccepted is DURABLE and a
|
||
// sound lower bound on the decided height (every accepted block was finalized), so seeding
|
||
// the floor from it can only ever REFUSE MORE signing (fail-safe). SIGN-GATE ONLY: it
|
||
// touches only t.decidedFloor (under slotMu), never the finality ledger / byHeight — PART-A
|
||
// intact. Best-effort: any VM error / empty head leaves the floor as-is (the vote-guard
|
||
// file floor and the SyncState seed remain). Caller holds t.mu.
|
||
func (t *Transitive) seedDecidedFloorFromVM(ctx context.Context) {
|
||
if t.vm == nil {
|
||
return
|
||
}
|
||
id, err := t.vm.LastAccepted(ctx)
|
||
if err != nil || id == ids.Empty {
|
||
return
|
||
}
|
||
blk, err := t.vm.GetBlock(ctx, id)
|
||
if err != nil || blk == nil {
|
||
return
|
||
}
|
||
h := blk.Height()
|
||
t.slotMu.Lock()
|
||
if h > t.decidedFloor {
|
||
t.decidedFloor = h
|
||
}
|
||
t.slotMu.Unlock()
|
||
}
|
||
|
||
// slotCanonical is the effective canonical identity a VotePosition binds for the
|
||
// equivocation guard: the inner execution commitment (CanonicalID) for a
|
||
// proposervm-wrapped block, else the outer id (bare-block degrade — matches
|
||
// canonicalVoteMessageFor, so the guarded identity is exactly the SIGNED one).
|
||
func slotCanonical(pos VotePosition) ids.ID {
|
||
if pos.CanonicalID != ids.Empty {
|
||
return pos.CanonicalID
|
||
}
|
||
return pos.BlockID
|
||
}
|
||
|
||
// ConvergenceVoter casts this node's single per-height accept vote for the
|
||
// deterministically-converged winner at each fork slot. It decouples the (binding,
|
||
// one-per-height) signature from block build/receipt: a node NEVER binds its vote to
|
||
// the block it merely built or first-saw (that fragments the α-of-K vote across
|
||
// siblings and stalls a fresh-net storm). Instead RunSettlePass — driven by the
|
||
// convergence tick — sweeps every undecided, still-unsigned fork slot whose settle
|
||
// window has elapsed and casts the vote for the lowest-canonical live sibling, so every
|
||
// honest node signs the SAME block and exactly one cert forms per height. Wired by the
|
||
// Runtime (which owns the sign+gossip path); nil in single-engine tests.
|
||
type ConvergenceVoter interface {
|
||
RunSettlePass(ctx context.Context)
|
||
}
|
||
|
||
// convergedWinnerAtHeightLocked returns the block THIS node must place its one
|
||
// per-height accept signature on at the (height, parentID) fork slot: the LOWEST
|
||
// slotCanonical among tracked, undecided, non-abandoned sibling blocks extending
|
||
// parentID at that height, plus the count of such siblings. The tie-break is the
|
||
// signed canonical id (the exact identity a cert binds), so every honest node with
|
||
// the same tracked set selects the IDENTICAL winner and their one-vote-per-height
|
||
// signatures converge onto it. Abandoned blocks (a dead proposer's sibling that
|
||
// stopped being re-solicited) are excluded, so the winner advances to the
|
||
// lowest-canonical LIVE sibling — the f=1 self-heal. Caller holds t.mu.
|
||
//
|
||
// GRINDABILITY (RED M-grind, a testnet/mainnet gate — NOT a safety or halt break):
|
||
// the tie-break is the block's content hash (CanonicalID), which the PROPOSER controls.
|
||
// A validator eligible at a CONTESTED height can grind ~2^k block variants in the settle
|
||
// window to obtain the lowest canonical and make every honest node converge on ITS block
|
||
// — a censorship/MEV lever. It is bounded to MULTI-PROPOSER CONTENTION only: at a
|
||
// height with a single proposervm-eligible proposer (the steady state) there are no
|
||
// siblings to win, so the grind buys nothing; it bites only during the fresh-net /
|
||
// down-designated-proposer transient. Progress and single-block finality are UNAFFECTED
|
||
// (one block per height still finalizes). The grind-RESISTANT replacement is a tie-break
|
||
// the proposer cannot bias — a VRF over height‖parentID keyed to the staking key, or the
|
||
// proposervm eligibility VRF already carried in the wrapped block — which requires
|
||
// plumbing the proposer's VRF output into the consensus Block (a node-layer change) and
|
||
// is the tracked follow-up before adversarial-validator mainnet promotion.
|
||
func (t *Transitive) convergedWinnerAtHeightLocked(height uint64, parentID ids.ID) (ids.ID, int, bool) {
|
||
var winner, winnerCanon ids.ID
|
||
count := 0
|
||
for id, pb := range t.pendingBlocks {
|
||
cb := pb.ConsensusBlock
|
||
if cb == nil || pb.Decided || pb.rePollAbandoned {
|
||
continue
|
||
}
|
||
if cb.height != height || cb.parentID != parentID {
|
||
continue
|
||
}
|
||
canon := cb.canonicalID
|
||
if canon == ids.Empty {
|
||
canon = id
|
||
}
|
||
count++
|
||
if winner == ids.Empty || canon.Compare(winnerCanon) < 0 {
|
||
winner, winnerCanon = id, canon
|
||
}
|
||
}
|
||
if winner == ids.Empty {
|
||
return ids.Empty, 0, false
|
||
}
|
||
return winner, count, true
|
||
}
|
||
|
||
// parentIsProvenLoserLocked reports whether parentID has DEFINITIVELY lost the
|
||
// convergence at its OWN height: a tracked, non-abandoned SIBLING of parentID (same
|
||
// height, same grandparent) carries a strictly-lower signed-canonical. Such a parent is
|
||
// on a branch every honest node is converging AWAY from, so a height-H block extending it
|
||
// can never finalize; binding this node's one height-H signature to it would waste the
|
||
// vote under the height-only vote-once rule and could STALL height H — the transient
|
||
// H-1-fork case (N1). Conservative on purpose: an UNTRACKED parent (the finalized tip, or
|
||
// a block this node is behind) returns false — it cannot be PROVEN a loser and must not be
|
||
// filtered, or the normal H = finalizedHeight+1 path would itself stall. Caller holds t.mu.
|
||
func (t *Transitive) parentIsProvenLoserLocked(parentID ids.ID) bool {
|
||
pb, ok := t.pendingBlocks[parentID]
|
||
if !ok || pb.ConsensusBlock == nil {
|
||
return false
|
||
}
|
||
p := pb.ConsensusBlock
|
||
pc := p.canonicalID
|
||
if pc == ids.Empty {
|
||
pc = parentID
|
||
}
|
||
for sibID, sib := range t.pendingBlocks {
|
||
cb := sib.ConsensusBlock
|
||
if cb == nil || sib.rePollAbandoned || sibID == parentID {
|
||
continue
|
||
}
|
||
if cb.height != p.height || cb.parentID != p.parentID {
|
||
continue
|
||
}
|
||
sc := cb.canonicalID
|
||
if sc == ids.Empty {
|
||
sc = sibID
|
||
}
|
||
if sc.Compare(pc) < 0 {
|
||
return true // a strictly-lower-canonical sibling of the parent exists ⇒ parent lost
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// votableSlot identifies one (height, parentID) fork slot the settle pass may need to
|
||
// cast a converged vote for.
|
||
type votableSlot struct {
|
||
height uint64
|
||
parentID ids.ID
|
||
}
|
||
|
||
// snapshotVotableSlotsLocked returns the DISTINCT (height, parentID) fork slots that
|
||
// (a) have an undecided, non-abandoned tracked block, (b) this node has NOT yet signed
|
||
// (committedSlot has no binding at that height), and (c) have SETTLED — the earliest
|
||
// local track time of any sibling at that slot is at least one settle window ago, so
|
||
// near-simultaneous sibling proposals have had time to gossip in and the winner is
|
||
// stable. The settle window is the whole point: it is what lets every honest node see
|
||
// the SAME sibling set before it binds its one signature, so they all pick the SAME
|
||
// lowest-canonical winner instead of racing to bind their own first-seen block. Caller
|
||
// holds t.mu (and takes slotMu internally to read committedSlot).
|
||
func (t *Transitive) snapshotVotableSlotsLocked() []votableSlot {
|
||
// Read the set of already-signed heights once, under slotMu.
|
||
// Read the consensus decided floor BEFORE slotMu so this method never holds slotMu
|
||
// while touching the consensus lock.
|
||
consensusFloor := uint64(0)
|
||
if t.consensus != nil {
|
||
consensusFloor = t.consensus.GetDecidedFloor()
|
||
}
|
||
t.slotMu.Lock()
|
||
signed := make(map[uint64]struct{}, len(t.committedSlot))
|
||
for k := range t.committedSlot {
|
||
signed[k.Height] = struct{}{}
|
||
}
|
||
// DECIDED-HEIGHT FILTER (liveness): never OFFER a slot at or below the DURABLE decided
|
||
// floor (the same floor the sign gate enforces: max of the vote-guard-seeded decidedFloor
|
||
// and the consensus floor). Such a height is decided — reserveSlotForSign would refuse it
|
||
// anyway, but because a refused sign leaves committedSlot empty, the slot would otherwise
|
||
// resurface on every tick and the pass would busy-replay it. Mirrors avalanchego dropping
|
||
// votes for a Decided() block.
|
||
decidedFloor := t.decidedFloor
|
||
if consensusFloor > decidedFloor {
|
||
decidedFloor = consensusFloor
|
||
}
|
||
t.slotMu.Unlock()
|
||
|
||
settle := t.convergenceSettleWindow()
|
||
now := time.Now()
|
||
// Track the LATEST local track time of any sibling at each slot. Settling from the
|
||
// LAST sibling (not the first) means a slot becomes votable only once NO new sibling
|
||
// has arrived for a full window — so while proposals are still dribbling in (slow or
|
||
// contended gossip) the node keeps waiting, and it binds its one signature only after
|
||
// the sibling set has gone quiet. That is what makes every honest node vote the SAME
|
||
// lowest-canonical winner instead of racing to bind an incomplete set.
|
||
latest := make(map[votableSlot]time.Time)
|
||
for _, pb := range t.pendingBlocks {
|
||
cb := pb.ConsensusBlock
|
||
if cb == nil || pb.Decided || pb.rePollAbandoned {
|
||
continue
|
||
}
|
||
if cb.height <= decidedFloor {
|
||
continue // decided height — permanently unsignable, never offer it
|
||
}
|
||
if _, ok := signed[cb.height]; ok {
|
||
continue // already cast our one vote at this height
|
||
}
|
||
s := votableSlot{height: cb.height, parentID: cb.parentID}
|
||
if t1, ok := latest[s]; !ok || pb.ProposedAt.After(t1) {
|
||
latest[s] = pb.ProposedAt
|
||
}
|
||
}
|
||
var out []votableSlot
|
||
for s, t1 := range latest {
|
||
if now.Sub(t1) < settle {
|
||
continue // not settled — siblings may still be arriving
|
||
}
|
||
if t.parentIsProvenLoserLocked(s.parentID) {
|
||
continue // N1: parent lost its own height's convergence — its children are a dead branch
|
||
}
|
||
out = append(out, s)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// pChainHeighter is the subset of block.SignedBlock the engine needs to pin a
|
||
// block's validator-set epoch: the P-CHAIN height the block was proposed at. A
|
||
// proposervm block satisfies it; a bare VM block does not (epoch height 0 →
|
||
// fail-closed on the K>1 finality path). Defined locally so the engine depends
|
||
// only on the one method it reads, not the whole SignedBlock surface.
|
||
type pChainHeighter interface {
|
||
PChainHeight() uint64
|
||
}
|
||
|
||
// pChainHeightOf extracts the P-chain height a VM block was proposed at, or 0 if
|
||
// the block does not carry one (pre-fork / no proposervm wrapper). This is the
|
||
// SOLE place the engine reads PChainHeight off a VM block, so every consensus
|
||
// Block records the same epoch the proposervm signed.
|
||
func pChainHeightOf(b block.Block) uint64 {
|
||
if ph, ok := b.(pChainHeighter); ok {
|
||
return ph.PChainHeight()
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// PChainHeightOfForTest exposes the engine's block→P-chain-height boundary read to
|
||
// a test in ANOTHER module (the node's chains package), so it can prove the
|
||
// node-layer wrapper delivers the REAL epoch height (not 0) through the EXACT
|
||
// function the engine uses. Exported only for that cross-module test reach.
|
||
func PChainHeightOfForTest(b block.Block) uint64 { return pChainHeightOf(b) }
|
||
|
||
// canonicalCommitter is the OPTIONAL block interface that exposes the inner
|
||
// EXECUTION commitment (the incident-1082814 canonical identity), plumbed up from
|
||
// the proposervm wrapper. A proposervm signed block that wraps an inner execution
|
||
// block implements it; a bare/in-process VM block does not. Defined locally so the
|
||
// engine depends only on the four methods it reads.
|
||
//
|
||
// THE CONTRACT: CanonicalID is the inner execution block id — the value finality is
|
||
// defined over. Two outer proposervm envelopes wrapping the SAME inner block return
|
||
// the SAME CanonicalID, which is exactly what collapses them to duplicates instead
|
||
// of forks. ParentCanonicalID / ExecutionStateRoot / PayloadRoot bind the canonical
|
||
// ancestry and the exact execution result into the signed cert.
|
||
type canonicalCommitter interface {
|
||
CanonicalID() ids.ID
|
||
ParentCanonicalID() ids.ID
|
||
ExecutionStateRoot() ids.ID
|
||
PayloadRoot() ids.ID
|
||
}
|
||
|
||
// canonicalIDOf returns the block's inner EXECUTION commitment, or — for a block
|
||
// that does not expose one (bare/in-process VM, no proposervm wrapping at the engine
|
||
// boundary) — the block's OWN outer id. The fallback makes the scheme degrade
|
||
// EXACTLY to the pre-fix outer-id behavior on a non-wrapped chain (canonical ==
|
||
// outer ⇒ no two envelopes can share a canonical id ⇒ the duplicate-alias path is
|
||
// simply inert), while a real proposervm delivers the distinct inner id that
|
||
// distinguishes a duplicate envelope from a genuine fork. This is the SOLE place the
|
||
// engine reads the canonical id off a VM block.
|
||
func canonicalIDOf(b block.Block) ids.ID {
|
||
if c, ok := b.(canonicalCommitter); ok {
|
||
if id := c.CanonicalID(); id != ids.Empty {
|
||
return id
|
||
}
|
||
}
|
||
return b.ID()
|
||
}
|
||
|
||
// setCanonicalFromVM stamps the canonical execution-commitment fields onto a
|
||
// consensus Block from its VM block — the ONE boundary where the inner commitment
|
||
// enters consensus state. For a non-wrapped block canonicalID == outer id and the
|
||
// roots are Empty (unbound), so the position/cert are byte-compatible with the
|
||
// pre-canonical behavior. Called at every Block construction site (DRY).
|
||
func setCanonicalFromVM(cb *Block, vmBlock block.Block) {
|
||
cb.canonicalID = canonicalIDOf(vmBlock)
|
||
if c, ok := vmBlock.(canonicalCommitter); ok {
|
||
cb.parentCanonicalID = c.ParentCanonicalID()
|
||
cb.execStateRoot = c.ExecutionStateRoot()
|
||
cb.payloadRoot = c.PayloadRoot()
|
||
}
|
||
}
|
||
|
||
// epochHeightLocked returns the P-CHAIN height the block's weighted validator set
|
||
// is pinned to — the SINGLE height used for the set-root commitment, the
|
||
// ⅔-by-stake tally, AND per-voter pubkey resolution (membership, pubkey,
|
||
// set-root, stake ALL read set@H, MEDIUM-1/CRITICAL-1/RESIDUAL-B). It is the
|
||
// block's recorded P-chain height, NOT its value-chain height. Caller holds t.mu.
|
||
func (t *Transitive) epochHeightLocked(pending *PendingBlock) uint64 {
|
||
if pending != nil && pending.ConsensusBlock != nil {
|
||
return pending.ConsensusBlock.pChainHeight
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// 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
|
||
var canonicalID, parentCanonicalID, execStateRoot, payloadRoot ids.ID
|
||
if pending.ConsensusBlock != nil {
|
||
parentID = pending.ConsensusBlock.parentID
|
||
height = pending.ConsensusBlock.height
|
||
canonicalID = pending.ConsensusBlock.canonicalID
|
||
parentCanonicalID = pending.ConsensusBlock.parentCanonicalID
|
||
execStateRoot = pending.ConsensusBlock.execStateRoot
|
||
payloadRoot = pending.ConsensusBlock.payloadRoot
|
||
}
|
||
// canonicalID/parentCanonicalID are left as the block's RAW canonical fields: the
|
||
// real inner id for a proposervm-wrapped block, or ids.Empty for a bare block. The
|
||
// non-wrapped degrade (Empty ⇒ bind the outer id) is resolved in ONE place,
|
||
// canonicalVoteMessageFor, so the signed bytes are identical for every producer of
|
||
// a position (engine or test), for the same block.
|
||
// Stamp the active weighted-validator-set commitment at the block's P-CHAIN
|
||
// EPOCH height (the MEDIUM-1 / CRITICAL-1 fix) — NOT its value-chain height.
|
||
// Every path that builds a position — sign (recordOwnVoteLocked), assemble +
|
||
// verify (assembleCertLocked), incoming-vote/cert verify — routes through
|
||
// here, so they all bind the SAME root for a given block: a cert is pinned to
|
||
// the exact set it was certified under. The epoch height is the proposervm's
|
||
// PChainHeight, the only height that is (i) ≤ the current P-chain height (so
|
||
// platformvm.GetValidatorSet does NOT errUnfinalizedHeight) and (ii) embedded
|
||
// in the signed block so every honest node derives the IDENTICAL set/root.
|
||
// nil source ⟹ Empty root (the fixed-set no-op).
|
||
var setRoot ids.ID
|
||
if t.setRootSource != nil {
|
||
setRoot = t.setRootSource.ValidatorSetRoot(t.epochHeightLocked(pending))
|
||
}
|
||
return VotePosition{
|
||
ChainID: t.chainID,
|
||
Height: height,
|
||
Round: pending.Round,
|
||
BlockID: blockID,
|
||
ParentID: parentID,
|
||
CanonicalID: canonicalID,
|
||
ParentCanonicalID: parentCanonicalID,
|
||
ExecutionStateRoot: execStateRoot,
|
||
PayloadRoot: payloadRoot,
|
||
ValidatorSetRoot: setRoot,
|
||
}
|
||
}
|
||
|
||
// TrackOwnProposalForTest inserts blk as a verified own-proposal pending block —
|
||
// the SAME state buildBlocksLocked establishes for a locally built block — and
|
||
// returns the canonical VotePosition followers must sign. It exists so a test in
|
||
// ANOTHER module (the node's chains package) can drive a REAL VM block (e.g. the
|
||
// node's P-chain-height-stamping wrapper block) through the engine's actual
|
||
// vote→assemble→verify→finalize path. It is exported ONLY for that cross-module
|
||
// test reach; it is not part of the consensus runtime surface.
|
||
//
|
||
// It is NOT a finality shortcut: it records the proposer's own signed accept
|
||
// (recordOwnVoteLocked) and a single self-vote toward the count exactly as
|
||
// production does, and it NEVER calls ForceAccept. A block tracked here finalizes
|
||
// (K>1) only when enough real signed peer votes arrive to assemble a cert that
|
||
// VERIFIES under the wired verifier (and clears the ⅔-stake predicate when a
|
||
// stake source is wired) — the genuine BFT path.
|
||
//
|
||
// The load-bearing line is `pChainHeightOf(blk)`: it captures the block's P-CHAIN
|
||
// epoch height off the VM block through the SAME boundary the production
|
||
// buildBlocksLocked uses, so a test can prove the boundary delivers the real
|
||
// height (not 0) end to end. The returned position's set-root is stamped at that
|
||
// epoch height (blockPositionLocked), so a follower signs — and the verifier
|
||
// resolves pubkeys at — the LIVE set@H.
|
||
func (t *Transitive) TrackOwnProposalForTest(ctx context.Context, blk block.Block, round uint32) VotePosition {
|
||
cb := &Block{
|
||
id: blk.ID(),
|
||
parentID: blk.ParentID(),
|
||
height: blk.Height(),
|
||
timestamp: blk.Timestamp().Unix(),
|
||
data: blk.Bytes(),
|
||
pChainHeight: pChainHeightOf(blk), // the boundary capture under test (b2)
|
||
}
|
||
setCanonicalFromVM(cb, blk) // stamp the inner execution commitment
|
||
_ = t.consensus.AddBlock(ctx, cb)
|
||
_ = t.consensus.ProcessVote(ctx, blk.ID(), true)
|
||
t.mu.Lock()
|
||
pb := &PendingBlock{
|
||
ConsensusBlock: cb,
|
||
VMBlock: blk,
|
||
ProposedAt: time.Now(),
|
||
VoteCount: 1,
|
||
Round: round,
|
||
Decided: false,
|
||
IsOwnProposal: true,
|
||
}
|
||
t.pendingBlocks[blk.ID()] = pb
|
||
t.recordOwnVoteLocked(pb, blk.ID())
|
||
pos := t.blockPositionLocked(pb, blk.ID())
|
||
t.mu.Unlock()
|
||
// Replay any votes a peer parked for this block before we tracked it (a
|
||
// follower could have seen a peer's vote for our own block before our build
|
||
// signal). Drain after unlock — drainBufferedVotes takes t.mu.
|
||
t.drainBufferedVotes(blk.ID())
|
||
return pos
|
||
}
|
||
|
||
// 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)
|
||
// The epoch height pins every per-voter pubkey resolution + the stake tally to
|
||
// the SAME P-chain height the position's set-root commits to (MEDIUM-1).
|
||
epochHeight := t.epochHeightLocked(pending)
|
||
|
||
verified := make([]SignedVote, 0, len(pending.certVotes))
|
||
for _, sv := range pending.certVotes {
|
||
if t.voteVerifier.VerifyVote(sv.NodeID, message, sv.Signature, epochHeight) {
|
||
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.
|
||
// On a stake-weighted chain it must ALSO clear the ⅔-of-stake supermajority
|
||
// (HIGH-3) — the count quorum alone is not finality when stake is unequal, so
|
||
// we keep WAITING (return nil) until enough STAKE has voted, never forcing.
|
||
if t.stakeSource != nil {
|
||
if err := cert.VerifyWeighted(t.voteVerifier, t.stakeSource, epochHeight); err != nil {
|
||
return nil
|
||
}
|
||
} else if err := cert.Verify(t.voteVerifier, epochHeight); err != nil {
|
||
return nil
|
||
}
|
||
pending.cert = cert
|
||
return cert
|
||
}
|
||
|
||
// TryAccept is the ONE entry every finality trigger calls — a vote arrived, a
|
||
// re-poll fired, the pending queue changed, a block was built/verified, a poll
|
||
// timeout ticked. It is the single funnel onto the cert-gated acceptance path:
|
||
//
|
||
// cert, err := <obtain a VerifiedQuorumCert for blockID>
|
||
// if err != nil { return err } // ErrNoVerifiedQC: not final yet, retry later
|
||
// return t.AcceptWithCert(ctx, blockID, cert)
|
||
//
|
||
// A raw α-of-K COUNT is NOT an acceptance authority here. It is a LIVENESS
|
||
// signal: it may BRING us to TryAccept (the poll loop / vote handler call this
|
||
// when consensus signals "enough responses"), but TryAccept finalizes ONLY if a
|
||
// VerifiedQuorumCert can be produced — i.e. the votes assemble into a cert that
|
||
// clears VerifyWeighted's strict >⅔-of-stake gate. If not, TryAccept returns
|
||
// ErrNoVerifiedQC and changes nothing: the block stays pending+undecided and the
|
||
// trigger retries on its next tick. No count, no callback, no "enough voters
|
||
// responded" can finalize without the cert. This is the structural HIGH-3 fix.
|
||
//
|
||
// - K>1: the cert is assembled+verified from the collected SIGNED votes
|
||
// (assembleVerifiedCertLocked → BuildVerifiedQuorumCert semantics). The
|
||
// verified cert is gossiped so followers finalize on the same proof.
|
||
// - K==1: there are no peers; the sole validator's own accept IS the 1-of-1
|
||
// quorum. We wrap it as a 1-of-1 VerifiedQuorumCert so even this path finalizes
|
||
// ONLY through AcceptWithCert → FinalizeBranch (whose per-height gate keeps a
|
||
// K==1 node from finalizing two blocks at one height) — one finalizer.
|
||
func (t *Transitive) TryAccept(ctx context.Context, blockID ids.ID) error {
|
||
t.mu.Lock()
|
||
pending, exists := t.pendingBlocks[blockID]
|
||
if !exists || pending.Decided {
|
||
t.mu.Unlock()
|
||
return nil // nothing to accept (gone or already finalized) — not an error
|
||
}
|
||
// Track the live validator set: re-clamp the committee UP before choosing the single-validator
|
||
// vs multi-validator finality path, so a chain that launched single-validator does not keep
|
||
// finalizing unilaterally after it decentralizes (RED's 1→N fork).
|
||
t.reclampCommitteeLocked()
|
||
singleValidator := t.consensus.K() == 1
|
||
|
||
if singleValidator {
|
||
// 1-of-1 quorum: the sole validator's own accept IS the α-of-K. Build the
|
||
// 1-of-1 verified cert and finalize through the SOLE finalizer (AcceptWithCert
|
||
// → FinalizeBranch), whose per-height gate (a) keeps a K==1 node from
|
||
// finalizing two blocks at one height. No separate force path.
|
||
cert := t.buildSingleValidatorCertLocked(pending, blockID)
|
||
t.mu.Unlock()
|
||
return t.AcceptWithCert(ctx, blockID, cert)
|
||
}
|
||
|
||
// K>1: a verified α-of-K cert is the ONLY authority. Build+verify it from the
|
||
// collected signed votes. nil ⇒ the verified ⅔-stake quorum is not present
|
||
// yet ⇒ ErrNoVerifiedQC ⇒ liveness retry (NOT a finalize).
|
||
cert, ok := t.assembleVerifiedCertLocked(pending, blockID)
|
||
if !ok {
|
||
t.mu.Unlock()
|
||
return ErrNoVerifiedQC
|
||
}
|
||
var certBytes []byte
|
||
if b, err := cert.Cert().MarshalBinary(); err == nil {
|
||
certBytes = b
|
||
}
|
||
chainID := t.chainID
|
||
gossiper := t.certGossiper
|
||
t.mu.Unlock()
|
||
|
||
// Distribute the finality proof so followers finalize on the same verifiable
|
||
// witness (not a fast-follow guess). Best effort — local finality already
|
||
// holds via the verified cert about to be committed.
|
||
if gossiper != nil && certBytes != nil {
|
||
_ = gossiper.GossipCert(chainID, blockID, certBytes)
|
||
}
|
||
|
||
return t.AcceptWithCert(ctx, blockID, cert)
|
||
}
|
||
|
||
// tryFinalizeBlock is a thin compatibility shim onto TryAccept for the
|
||
// peer-quorum triggers (poll-due, vote-handler). It exists so those call sites
|
||
// read as "try to finalize"; all real logic — and the cert gate — is in
|
||
// TryAccept. A consensus COUNT reaching α is what brings us here, but TryAccept
|
||
// finalizes only with a VerifiedQuorumCert; ErrNoVerifiedQC is the normal
|
||
// "wait" answer and is intentionally swallowed (the trigger retries next tick).
|
||
func (t *Transitive) tryFinalizeBlock(ctx context.Context, blockID ids.ID) {
|
||
_ = t.TryAccept(ctx, blockID)
|
||
}
|
||
|
||
// finalizeOwnProposal is the proposer-side trigger after building its own block.
|
||
//
|
||
// THE FREEZE THIS USED TO "FIX" — AND HOW IT IS NOW FIXED WITHOUT SELF-FINALITY:
|
||
// the old version FORCE-ACCEPTED the proposer's own block on its lone self-vote
|
||
// (self-finality — a value could finalize with NO α-of-K agreement, so an
|
||
// equivocating proposer could fork the chain). DELETED for K>1. The freeze is
|
||
// now solved STRUCTURALLY by the vote-distribution topology (integration.go):
|
||
// followers gossip their SIGNED accept votes to ALL validators, the proposer
|
||
// assembles + gossips the cert, and finality comes via the verified cert.
|
||
//
|
||
// This is now just another trigger: it routes to TryAccept like every other
|
||
// trigger. K==1 finalizes via the 1-of-1 cert; K>1 finalizes IFF a verified
|
||
// α-of-K cert exists. Never forces a K>1 block on the lone self-vote.
|
||
func (t *Transitive) finalizeOwnProposal(ctx context.Context, blockID ids.ID) {
|
||
// The own block is now tracked (buildBlocksLocked inserted it before calling
|
||
// here, with the lock released). Replay any votes a peer parked for it before
|
||
// our build signal so they count toward this attempt. Lock-free (drain takes
|
||
// t.mu); every caller invokes this without holding t.mu.
|
||
t.drainBufferedVotes(blockID)
|
||
t.mu.RLock()
|
||
pending, exists := t.pendingBlocks[blockID]
|
||
own := exists && pending.IsOwnProposal && !pending.Decided
|
||
t.mu.RUnlock()
|
||
if !own {
|
||
return
|
||
}
|
||
_ = t.TryAccept(ctx, blockID)
|
||
}
|
||
|
||
// assembleVerifiedCertLocked builds the FINALITY AUTHORITY TOKEN for blockID from
|
||
// the collected signed accept votes, or reports that no verified quorum exists
|
||
// yet. Caller holds t.mu. It delegates the predicate to assembleCertLocked
|
||
// (assemble + signature-verify + VerifyWeighted's strict >⅔-of-stake gate — the
|
||
// SINGLE place the stake predicate lives) and wraps the verified result. ok=false
|
||
// (zero cert) ⇒ the verified ⅔-stake quorum is not present yet ⇒ the caller must
|
||
// NOT finalize (it returns ErrNoVerifiedQC and the trigger retries). There is no
|
||
// other in-engine producer of the token for the multi-validator path, so the
|
||
// count road has no way to manufacture finality.
|
||
func (t *Transitive) assembleVerifiedCertLocked(pending *PendingBlock, blockID ids.ID) (VerifiedQuorumCert, bool) {
|
||
cert := t.assembleCertLocked(pending, blockID)
|
||
if cert == nil {
|
||
return VerifiedQuorumCert{}, false
|
||
}
|
||
// assembleCertLocked has already run VerifyWeighted/Verify before caching the
|
||
// cert, so promotion is safe; wrapVerifiedCert refuses only a nil cert.
|
||
return wrapVerifiedCert(cert)
|
||
}
|
||
|
||
// buildSingleValidatorCertLocked produces the 1-of-1 VerifiedQuorumCert for the
|
||
// K==1 path so the single-validator node finalizes through the SAME sole finalizer
|
||
// (AcceptWithCert → FinalizeBranch) as every other path — one finalization road. The
|
||
// FinalizeBranch inside that finalizer is what commits the decision and enforces the
|
||
// per-height equivocation gate; this function only builds the authorizing token.
|
||
// Caller holds t.mu. On a K==1 chain α==1 and the sole validator's own signed accept
|
||
// (recordOwnVoteLocked, captured at build time) is the entire quorum; assembleCertLocked
|
||
// verifies that single signature and the (trivially satisfied) stake gate. If a
|
||
// verifier/signer is not wired (a pure single-node dev chain with no vote crypto), there
|
||
// is no signature to certify — we authorize the commit with a degenerate non-zero token
|
||
// whose cert carries the position, and FinalizeBranch (the real single-node safety gate:
|
||
// one block per height, contiguous, reorg-on-conflict) does the commit. This degenerate
|
||
// token exists ONLY for K==1 and can never arise for K>1 (TryAccept's K>1 branch never
|
||
// calls here).
|
||
// reclampCommitteeLocked re-clamps the live committee (k, alpha) UP to track the CURRENT
|
||
// validator count, so a chain that launched single-validator does not keep k stuck at 1 after it
|
||
// adds validators (the 1→N decentralization fork: a stuck-k=1 validator would finalize
|
||
// unilaterally via a synthesized 1-of-1 cert OR a single-signer stake cert at alpha=1). It is
|
||
// UP-ONLY: it never shrinks the committee here, so a transient low read (a syncing node, an
|
||
// unresolved set) can never DROP the quorum and enable a unilateral finalize; a genuine shrink is
|
||
// an operator/governance action. No-op without a wired sampler or preset (tests / --dev), or when
|
||
// the set is unresolved (count<1). Called from the finalize decision points (buildBlocksLocked,
|
||
// TryAccept) under t.mu — the same nesting order (t.mu → c.mu) K() already uses, and k is written
|
||
// only here so the K() read + Reclamp cannot race.
|
||
func (t *Transitive) reclampCommitteeLocked() {
|
||
if t.liveValidatorCount == nil || t.presetK <= 0 {
|
||
return
|
||
}
|
||
// RESOLVED-SET GATE (do not trust the sampler until the chain is actually running). A FRESH
|
||
// single-validator chain can transiently over-report its validator count before the set is
|
||
// resolved (the genesis/staking set not yet pruned to the active validator); reclamping K→N on
|
||
// that read would skip the K==1 inline finalize and wedge the chain at block 0. Requiring at
|
||
// least one FINALIZED block (GetFinalizedHeight set) means the chain has already produced +
|
||
// finalized a block under its construction committee, so the live set read is now authoritative.
|
||
// A genuinely single-validator chain (count==1) never reclamps regardless; a chain that
|
||
// decentralizes has long since finalized blocks, so this never blocks the real 1→N transition.
|
||
if _, set := t.consensus.GetFinalizedHeight(); !set {
|
||
return
|
||
}
|
||
count := t.liveValidatorCount()
|
||
if count < 1 {
|
||
return // set not resolved/loaded for this network — keep the construction committee
|
||
}
|
||
newK := t.presetK
|
||
if count < newK {
|
||
newK = count
|
||
}
|
||
if newK <= t.consensus.K() {
|
||
return // UP-ONLY (never auto-shrink)
|
||
}
|
||
newAlpha := bftAlpha(newK) // the BFT ⅔ supermajority — ONE formula (config.TwoThirdsStakeFloor)
|
||
t.consensus.Reclamp(newK, newAlpha)
|
||
if t.log != nil {
|
||
t.log.Warn("committee re-clamped UP to the live validator set (decentralization) — "+
|
||
"single-validator finality is no longer permitted",
|
||
"newK", newK, "newAlpha", newAlpha, "liveCount", count, "presetK", t.presetK)
|
||
}
|
||
}
|
||
|
||
func (t *Transitive) buildSingleValidatorCertLocked(pending *PendingBlock, blockID ids.ID) VerifiedQuorumCert {
|
||
// Prefer the VERIFIED 1-of-1 cert: when vote crypto is wired the proposer
|
||
// recorded its own signed accept (recordOwnVoteLocked), so assembleCertLocked
|
||
// verifies that single signature (and the trivially-met stake gate) and we
|
||
// finalize on a real witness — even on a single-validator chain.
|
||
if cert, ok := t.assembleVerifiedCertLocked(pending, blockID); ok {
|
||
return cert
|
||
}
|
||
// DECENTRALIZATION FORK GUARD (belt over reclampCommitteeLocked). Re-read the LIVE validator
|
||
// count and REFUSE to synthesize a 1-of-1 cert if the set has grown past ONE validator. K is
|
||
// clamped at construction and re-clamped UP by reclampCommitteeLocked before the
|
||
// single-validator decision, so K()==1 should already imply a one-validator set — but a lone
|
||
// validator must NEVER finalize a synthesized 1-of-1 cert on a multi-validator chain: RED's
|
||
// 1→N fork is that validators 2..N reject the unsigned cert (VerifyWeighted / real quorum)
|
||
// while validator-1 serves a DIVERGENT finalized chain over RPC. Returning ZERO makes
|
||
// acceptWithCertCore refuse, so finality waits for a real k-of-N cert (assembled once the
|
||
// peers' votes arrive). count<1 (unwired sampler / --dev / a not-yet-resolved set) keeps the
|
||
// genuine-single-validator + dev path (the n=1 fix) — a fresh chain that knows of no other
|
||
// validator has no fork to create.
|
||
// RESOLVED-SET GATE (mirrors reclampCommitteeLocked): only refuse to synthesize once the chain
|
||
// has FINALIZED at least one block, so a fresh single-validator chain that transiently
|
||
// over-reports its count cannot wedge its own first block by refusing the 1-of-1 cert. Once the
|
||
// set is authoritative, a live count > 1 means this stuck-K==1 engine must NOT finalize
|
||
// unilaterally (the 1→N fork) — return ZERO and let a real quorum finalize.
|
||
if t.liveValidatorCount != nil {
|
||
if _, resolved := t.consensus.GetFinalizedHeight(); resolved {
|
||
if count := t.liveValidatorCount(); count > 1 {
|
||
return VerifiedQuorumCert{}
|
||
}
|
||
}
|
||
}
|
||
// K==1 FALLBACK — synthesize the 1-of-1 finality witness from the position. Both callers
|
||
// gate on K()==1, so this is a genuinely SINGLE-validator engine: the dynamic committee
|
||
// clamp (bftCommittee) sets K to the LIVE validator count, so K()==1 ⟺ exactly one
|
||
// validator (a K>1 chain never reaches here). The sole validator's own accept IS the 1-of-1
|
||
// quorum, so this is NOT fabricating a multi-party agreement — there is no other validator
|
||
// to protect against — and FinalizeBranch's per-height gate (one block per height,
|
||
// contiguous, no branching) remains the real single-node safety.
|
||
//
|
||
// This fallback is taken EVEN WHEN a verifier is wired but the signed self-vote did not
|
||
// assemble into a verified cert — the n=1 DECIDE stall. On a fresh single-validator
|
||
// sovereign L1 (Zoo 200200, Hanzo 36963, Pars 494949) the preset K>1 wires a verifier, then
|
||
// the clamp drops K to 1; the validator set is not yet resolvable at the block's P-chain
|
||
// epoch, so the self-vote's signature cannot be verified against it, assembleVerifiedCert
|
||
// returns false, and the OLD code returned a ZERO cert that acceptWithCertCore refused — so
|
||
// the block re-built every poll and NEVER decided (EVM head frozen). A single validator
|
||
// finalizing its OWN block on its OWN chain needs no external signature witness; requiring
|
||
// one that can never verify wedges the chain. (A real signed 1-of-1 cert is still PREFERRED
|
||
// above when it assembles; making the sovereign-L1 set resolvable so it always does is a
|
||
// separate hardening.)
|
||
pos := t.blockPositionLocked(pending, blockID)
|
||
return VerifiedQuorumCert{qc: &QuorumCert{
|
||
Version: QuorumCertVersion,
|
||
Type: QCFinality,
|
||
Position: pos,
|
||
Threshold: 1,
|
||
}}
|
||
}
|
||
|
||
// AcceptWithCert is the SOLE function that can finalize a block. It is impossible
|
||
// to call without a VerifiedQuorumCert value, and a zero VerifiedQuorumCert
|
||
// (cert==nil) is refused — so the ONLY way to reach VM.Accept is to first hold a
|
||
// cert that cleared the finality predicate (BuildVerifiedQuorumCert /
|
||
// assembleVerifiedCertLocked / the verified incoming-cert path). The old
|
||
// finalizePendingLocked body lives here unchanged; the difference is that it can
|
||
// no longer be reached by any count-only road — the type system enforces it.
|
||
//
|
||
// Idempotent: subsequent calls find pending.Decided=true and no-op.
|
||
//
|
||
// It signals the pipeline to build the next block on success — the right thing
|
||
// for an OUT-OF-BAND finalize (a vote/cert arrived async, or the poll loop fired)
|
||
// where nothing else is driving production. The synchronous in-build-loop path
|
||
// (buildBlocksLocked) instead uses acceptWithCertCore(..., signalNext=false): it
|
||
// is already inside the build loop, so re-signaling would spawn a SECOND
|
||
// concurrent builder and race the VM's block counter (the K=1 burst regression).
|
||
func (t *Transitive) AcceptWithCert(ctx context.Context, blockID ids.ID, cert VerifiedQuorumCert) error {
|
||
return t.acceptWithCertCore(ctx, blockID, cert, true)
|
||
}
|
||
|
||
// acceptWithCertCore is the one finalization body. signalNext controls only
|
||
// whether it wakes the pipeline afterward (see AcceptWithCert). Everything that
|
||
// makes finality safe — the zero-cert refusal, the Decided/idempotency guard,
|
||
// the VM Accept+SetPreference ordering — is identical on both call paths.
|
||
func (t *Transitive) acceptWithCertCore(ctx context.Context, blockID ids.ID, cert VerifiedQuorumCert, signalNext bool) error {
|
||
if cert.IsZero() {
|
||
// No verified witness ⇒ no finality. This is the structural guarantee:
|
||
// even an internal caller cannot finalize by passing a zero cert.
|
||
return ErrNoVerifiedQC
|
||
}
|
||
|
||
// Fast idempotent out: an already-decided (or untracked) block needs no
|
||
// re-finalize. FinalizeBranch is itself idempotent, but skipping it avoids the
|
||
// consensus lock on the hot re-delivery path.
|
||
t.mu.RLock()
|
||
pending, exists := t.pendingBlocks[blockID]
|
||
decided := exists && pending.Decided
|
||
t.mu.RUnlock()
|
||
if !exists || decided {
|
||
return nil
|
||
}
|
||
|
||
// COMMIT FINALITY to the consensus ledger — the single finalize. FinalizeBranch
|
||
// walks the certified branch from the finalized tip up to blockID, advances
|
||
// finalized history, and returns the REORG plan: the path to Accept (ascending
|
||
// height) and the losing-sibling subtrees to prune. On a safety violation —
|
||
// equivocation (ErrHeightAlreadyFinalized), a conflicting/losing branch
|
||
// (ErrConflictsWithFinalizedBranch), or a not-yet-tracked ancestor
|
||
// (ErrAncestorNotTracked, a behind-node DEFER) — NOTHING is applied and the error
|
||
// propagates (HandleIncomingCert surfaces equivocation; a DEFER simply retries).
|
||
// Called WITHOUT t.mu so the consensus lock is never nested under it.
|
||
pos := cert.Cert().Position
|
||
// LOW-1 (defense-in-depth): the cert α-attests the FULL position {BlockID, ParentID,
|
||
// Height, CanonicalID}; build the finalize Cert ENTIRELY from it so the fold can
|
||
// never be fed a Block from one source and Parent/Height/Canonical from another.
|
||
// blockID is only the pending lookup key above — a verified cert's position must
|
||
// name that same outer block, else the trio is inconsistent and we fail closed
|
||
// rather than finalize the wrong block. (When a cert is resolved by CANONICAL id
|
||
// across a differing envelope, HandleIncomingCert finalizes the LOCAL outer id and
|
||
// the cert's canonical, so this equality holds on the resolved target.)
|
||
if pos.BlockID != blockID {
|
||
return fmt.Errorf("cert position block %s != finalize target %s (inconsistent cert trio)", pos.BlockID, blockID)
|
||
}
|
||
plan, err := t.consensus.ApplyCert(Cert{Block: pos.BlockID, Parent: pos.ParentID, Height: pos.Height, Canonical: pos.CanonicalID})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Apply the plan to the VM + engine bookkeeping: VM.Accept the finalized path (fail-closed
|
||
// — stops at the first VM.Accept error), VM.Reject the pruned losers, record engine
|
||
// finality, store the serving cert. highestAccepted is the highest height the VM ACTUALLY
|
||
// applied; the durable floor advances only to it (never past the VM's state).
|
||
highestAccepted, acceptErr := t.applyBranchFinalization(ctx, plan, blockID, cert)
|
||
|
||
// MEDIUM-1: drop the equivocation guard + view machines STRICTLY at/below what the VM
|
||
// accepted — NEVER to pos.Height when the VM applied less (the fail-closed invariant: the
|
||
// decided-floor can never run ahead of the EVM's accepted head, which is exactly the
|
||
// consensus-finalize→VM-accept divergence this fix kills). Only advance if we accepted
|
||
// something.
|
||
if highestAccepted > 0 {
|
||
t.pruneCommittedSlotsBelow(highestAccepted)
|
||
if t.params.ViewChange {
|
||
t.pruneViewsBelow(highestAccepted)
|
||
}
|
||
}
|
||
// MED-6: bound the slashing detector's per-height maps to a sliding window (memory).
|
||
t.pruneSlashingBelowWindow()
|
||
|
||
if acceptErr != nil {
|
||
// FAIL-CLOSED: the VM refused to apply a consensus-finalized block. The floor did NOT
|
||
// advance past the VM's applied state, so consensus and the EVM stay in lock-step. The
|
||
// block stays pending and the finalize retries; the chain HALTS at the un-appliable
|
||
// block rather than DIVERGING (floor ahead of the EVM). Recovery is the VM-side fix
|
||
// (apply the finalized block regardless of emptiness) — then the retry finalizes it.
|
||
return acceptErr
|
||
}
|
||
|
||
// FULL SUCCESS: REORG production onto the certified branch: SetPreference to the new tip
|
||
// keeps the VM building on the block consensus just finalized.
|
||
t.mu.RLock()
|
||
vm := t.vm
|
||
t.mu.RUnlock()
|
||
if vm != nil {
|
||
if err := vm.SetPreference(ctx, blockID); err != nil {
|
||
t.log.Crit("SetPreference failed after Accept — forcing preference",
|
||
"blockID", blockID,
|
||
"error", err)
|
||
t.consensus.ForcePreference(blockID)
|
||
}
|
||
}
|
||
|
||
// Pipeline: block finalized → immediately build next (out-of-band callers
|
||
// only; the in-build-loop caller continues its own loop and passes false).
|
||
if signalNext {
|
||
t.signalPipeline()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// applyBranchFinalization applies a consensus FinalizeBranch plan to the VM and
|
||
// engine bookkeeping. It mirrors avalanchego topological.go's accept/reject split:
|
||
// VM.Accept the finalized path (child.Accept, ascending height) and VM.Reject the
|
||
// pruned losing-sibling subtrees (rejectTransitively). The certified tip carries the
|
||
// cert retained for serving catch-up peers. The engine maps (finalizedByCert,
|
||
// pendingBlocks, bufferedVotes, catchupRequested) are reconciled under t.mu; the VM
|
||
// Accept/Reject calls run OUTSIDE the lock, Accept-before-Reject (the avalanchego
|
||
// order: accept the preferred child, then reject the conflicting siblings).
|
||
//
|
||
// finalizedByCert is written ONLY here (via the sole finalizer acceptWithCertCore),
|
||
// so engine finality is exactly "FinalizeBranch committed this block", never the
|
||
// count-driven consensus liveness flag.
|
||
// It is FAIL-CLOSED (the 2026-07 fix): VM.Accept is called BEFORE the engine commits a
|
||
// block's finality, and its error is CHECKED, not swallowed. If VM.Accept returns an error
|
||
// (e.g. the EVM refuses to apply the block), that block and every higher one in the plan are
|
||
// NOT marked finalized and the returned highestAccepted stops at the last block the VM
|
||
// actually applied — so the caller advances the durable decided-floor ONLY to the VM's
|
||
// applied state and NEVER past it. This kills the divergence where a swallowed VM.Accept
|
||
// error let the consensus finalized floor run ahead of the EVM's accepted head (the
|
||
// consensus-finalize→VM-accept disconnect: floor at 427 while the EVM stuck at 415). Returns
|
||
// the highest height the VM accepted and the first VM.Accept error (nil ⇒ full success).
|
||
func (t *Transitive) applyBranchFinalization(ctx context.Context, plan Plan, certifiedTip ids.ID, cert VerifiedQuorumCert) (highestAccepted uint64, acceptErr error) {
|
||
// Snapshot the finalized path (ascending) WITHOUT committing finality yet.
|
||
type pathBlock struct {
|
||
id ids.ID
|
||
vmb block.Block
|
||
height uint64
|
||
}
|
||
t.mu.RLock()
|
||
path := make([]pathBlock, 0, len(plan.Accept))
|
||
for _, id := range plan.Accept {
|
||
pending, ok := t.pendingBlocks[id]
|
||
if !ok || pending.Decided {
|
||
// Untracked or already applied: treat as accepted for floor purposes (the ledger
|
||
// is the truth) but nothing to Accept on the VM.
|
||
var h uint64
|
||
if ok && pending.ConsensusBlock != nil {
|
||
h = pending.ConsensusBlock.height
|
||
}
|
||
path = append(path, pathBlock{id: id, vmb: nil, height: h})
|
||
continue
|
||
}
|
||
var h uint64
|
||
if pending.ConsensusBlock != nil {
|
||
h = pending.ConsensusBlock.height
|
||
}
|
||
path = append(path, pathBlock{id: id, vmb: pending.VMBlock, height: h})
|
||
}
|
||
t.mu.RUnlock()
|
||
|
||
// Accept ascending; commit each block's finality ONLY after the VM applied it. Stop (fail
|
||
// closed) at the first VM.Accept error — the floor will advance only to the last success.
|
||
for _, pb := range path {
|
||
if pb.vmb != nil {
|
||
if err := pb.vmb.Accept(ctx); err != nil {
|
||
acceptErr = fmt.Errorf("VM.Accept(%s height=%d) failed — refusing to advance finality past the "+
|
||
"VM's applied state (fail-closed): %w", pb.id, pb.height, err)
|
||
break
|
||
}
|
||
}
|
||
t.mu.Lock()
|
||
if pending, ok := t.pendingBlocks[pb.id]; ok && !pending.Decided {
|
||
t.finalizedByCert[pb.id] = struct{}{}
|
||
pending.Decided = true
|
||
t.blocksAccepted++
|
||
delete(t.pendingBlocks, pb.id)
|
||
delete(t.bufferedVotes, pb.id)
|
||
delete(t.catchupRequested, pb.id)
|
||
} else {
|
||
t.finalizedByCert[pb.id] = struct{}{}
|
||
}
|
||
t.mu.Unlock()
|
||
if pb.height > highestAccepted {
|
||
highestAccepted = pb.height
|
||
}
|
||
}
|
||
|
||
// Reject the losing-sibling subtrees + retain the serving cert ONLY on a full, clean
|
||
// accept. A partial accept (VM.Accept failed) leaves the reorg incomplete — do NOT prune
|
||
// the losers (they may still be the live branch) and do NOT serve a cert for a tip the VM
|
||
// never applied.
|
||
if acceptErr != nil {
|
||
return highestAccepted, acceptErr
|
||
}
|
||
var toReject []block.Block
|
||
t.mu.Lock()
|
||
for _, id := range plan.Reject {
|
||
pending, ok := t.pendingBlocks[id]
|
||
if !ok || pending.Decided {
|
||
continue
|
||
}
|
||
pending.Decided = true
|
||
t.blocksRejected++
|
||
delete(t.pendingBlocks, id)
|
||
delete(t.bufferedVotes, id)
|
||
delete(t.catchupRequested, id)
|
||
if pending.VMBlock != nil {
|
||
toReject = append(toReject, pending.VMBlock)
|
||
}
|
||
}
|
||
if qc := cert.Cert(); qc != nil {
|
||
if b, err := qc.MarshalBinary(); err == nil {
|
||
t.storeServedCertLocked(certifiedTip, b)
|
||
}
|
||
}
|
||
t.mu.Unlock()
|
||
for _, vmb := range toReject {
|
||
_ = vmb.Reject(ctx)
|
||
}
|
||
return highestAccepted, nil
|
||
}
|
||
|
||
// slashingRetentionHeights is how many heights below the finalized tip the
|
||
// slashing detector retains vote/block records for. Equivocation evidence is
|
||
// only useful near the tip (a fork is attempted at or above the last finalized
|
||
// height); older records cannot prove a NEW double-vote and are pruned to bound
|
||
// memory. 1024 heights is ample for cross-validator timing skew at any block
|
||
// time while keeping the maps O(window·validators).
|
||
const slashingRetentionHeights = uint64(1024)
|
||
|
||
// pruneSlashingBelowWindow drops slashing records older than the retention
|
||
// window below the finalized height. No-op when no detector is wired or when the
|
||
// chain has not advanced past the window.
|
||
func (t *Transitive) pruneSlashingBelowWindow() {
|
||
t.mu.RLock()
|
||
detector := t.slashingDetector
|
||
t.mu.RUnlock()
|
||
if detector == nil {
|
||
return
|
||
}
|
||
fh, set := t.consensus.GetFinalizedHeight()
|
||
if !set || fh <= slashingRetentionHeights {
|
||
return
|
||
}
|
||
detector.PruneBelow(fh - slashingRetentionHeights)
|
||
}
|
||
|
||
// DrainAccepted attempts to finalize any pending block consensus has SIGNALLED
|
||
// as accepted. Called from the ForwardVMNotifications loop after each Notify.
|
||
//
|
||
// consensus.IsAccepted is the LIVENESS trigger only (α-of-K responded — worth
|
||
// attempting). The finality decision is made by TryAccept, which finalizes ONLY
|
||
// with a VerifiedQuorumCert (the >⅔-stake gate) and otherwise returns
|
||
// ErrNoVerifiedQC and changes nothing. This closes the previously count-ONLY
|
||
// finalize road here: a block drained from this loop now finalizes through the
|
||
// exact same cert-gated path (AcceptWithCert) as every other trigger — no count
|
||
// can VM.Accept without the cert.
|
||
func (t *Transitive) DrainAccepted(ctx context.Context) {
|
||
t.mu.RLock()
|
||
candidates := make([]ids.ID, 0, len(t.pendingBlocks))
|
||
for id, pending := range t.pendingBlocks {
|
||
if !pending.Decided && t.consensus.IsAccepted(id) {
|
||
candidates = append(candidates, id)
|
||
}
|
||
}
|
||
t.mu.RUnlock()
|
||
|
||
for _, id := range candidates {
|
||
_ = t.TryAccept(ctx, id)
|
||
}
|
||
}
|
||
|
||
func (t *Transitive) buildBlocksLocked(ctx context.Context) error {
|
||
if t.vm == nil {
|
||
return nil
|
||
}
|
||
|
||
for {
|
||
if t.pendingBuildBlocks <= 0 {
|
||
break
|
||
}
|
||
// UNLOCK-BEFORE-CALL-OUT: BuildBlock is an external VM call — release t.mu around it, then
|
||
// re-acquire to process (buildBlocksLocked is entered and returns with t.mu held). The loop
|
||
// re-checks pendingBuildBlocks after re-lock, so a concurrent change is handled.
|
||
t.mu.Unlock()
|
||
vmBlock, err := t.vm.BuildBlock(ctx)
|
||
t.mu.Lock()
|
||
if err != nil {
|
||
t.log.Error("BuildBlock failed, will retry next tick",
|
||
"error", err,
|
||
"pendingBuildBlocks", t.pendingBuildBlocks)
|
||
// Do NOT decrement pendingBuildBlocks — the request is still
|
||
// outstanding and will be retried on the next Notify or pipeline tick.
|
||
return nil
|
||
}
|
||
t.pendingBuildBlocks--
|
||
|
||
t.blocksBuilt++
|
||
|
||
// A rebuild of an ALREADY-TRACKED block is our own UNDECIDED proposal that
|
||
// the VM re-offered (mempool still non-empty, this height not yet finalized).
|
||
// avalanchego never silently drops such a rebuild — its repoll keeps
|
||
// re-querying the still-processing preferred block until it decides (snowman
|
||
// engine.go repoll, quiescing only at Consensus.NumProcessing()==0). Mirror
|
||
// that on the build path: RE-SOLICIT the block's votes instead of dropping the
|
||
// signal, so a peer that missed the first PushQuery is re-asked IMMEDIATELY
|
||
// (not only on the slower rePollAllPending backoff — the zero-margin 4-of-5
|
||
// mainnet condition needs the prompt re-ask). This re-sends the SAME block and
|
||
// the SAME position: it can never manufacture a vote or change WHICH block
|
||
// finalizes (finality is still the α-of-K cert), so it is a pure liveness
|
||
// retry. OWN proposals only — a gossiped (non-own) block keeps the rePoll
|
||
// attempt cap, since re-soliciting a block whose voters are behind its parent
|
||
// is spam that never recovers it (see rePollAllPending).
|
||
if pb, exists := t.pendingBlocks[vmBlock.ID()]; exists {
|
||
// UNLOCK-BEFORE-CALL-OUT: decide under the lock, re-solicit (a network send) AFTER
|
||
// releasing it. Re-solicit is a K>1 liveness retry only — a K==1 chain has no peers and
|
||
// finalizes inline. Re-sends the SAME block/position: never manufactures a vote.
|
||
reSolicit := pb.IsOwnProposal && !pb.Decided && t.proposer != nil && t.consensus.K() > 1
|
||
reqBlockID, reqBlockData := vmBlock.ID(), vmBlock.Bytes()
|
||
t.mu.Unlock()
|
||
if reSolicit {
|
||
t.proposer.RequestVotes(ctx, VoteRequest{BlockID: reqBlockID, BlockData: reqBlockData})
|
||
}
|
||
t.mu.Lock()
|
||
continue
|
||
}
|
||
|
||
consensusBlock := &Block{
|
||
id: vmBlock.ID(),
|
||
parentID: vmBlock.ParentID(),
|
||
height: vmBlock.Height(),
|
||
timestamp: vmBlock.Timestamp().Unix(),
|
||
data: vmBlock.Bytes(),
|
||
pChainHeight: pChainHeightOf(vmBlock), // epoch for the weighted set (MEDIUM-1)
|
||
}
|
||
setCanonicalFromVM(consensusBlock, vmBlock) // stamp the inner execution commitment
|
||
|
||
// Verify BEFORE consensus — prevents accepting invalid blocks in K=1 mode
|
||
// where self-vote causes immediate acceptance. If Verify fails, the block
|
||
// is never added to consensus, so IsAccepted cannot return true for it.
|
||
t.mu.Unlock()
|
||
if err := vmBlock.Verify(ctx); err != nil {
|
||
t.mu.Lock()
|
||
continue
|
||
}
|
||
|
||
// Now add to consensus and self-vote.
|
||
addErr := t.consensus.AddBlock(ctx, consensusBlock)
|
||
if addErr == nil {
|
||
_ = t.consensus.ProcessVote(ctx, vmBlock.ID(), true)
|
||
_ = t.consensus.Poll(ctx, map[ids.ID]int{vmBlock.ID(): 1})
|
||
}
|
||
t.mu.Lock()
|
||
|
||
if addErr != nil {
|
||
continue
|
||
}
|
||
|
||
// Track the block as a pending own proposal — ALWAYS, including the K=1
|
||
// single-node case. We no longer VM.Accept inline here: finalization for
|
||
// every block (K=1 or K>1) goes through the SOLE cert-gated finalizer via
|
||
// TryAccept below, so there is exactly one acceptance road. In K=1 TryAccept
|
||
// commits the 1-of-1 quorum (ForceAccept) and finalizes through
|
||
// AcceptWithCert; the call is synchronous (see below) so it runs before the
|
||
// next BuildBlock.
|
||
pb := &PendingBlock{
|
||
ConsensusBlock: consensusBlock,
|
||
VMBlock: vmBlock,
|
||
ProposedAt: time.Now(),
|
||
VoteCount: 1,
|
||
Decided: false,
|
||
IsOwnProposal: true,
|
||
}
|
||
t.pendingBlocks[vmBlock.ID()] = pb
|
||
// VOTE EMISSION.
|
||
//
|
||
// K==1 (sole validator, no siblings ever): the proposer's own accept IS the
|
||
// 1-of-1 quorum — record its signed self-vote now so the 1-of-1 cert assembles.
|
||
//
|
||
// K>1: DO NOT bind this node's one per-height signature to its own freshly-built
|
||
// block. On a fresh-net storm many validators build competing siblings at one
|
||
// height; if each self-votes its OWN block at build, all 5 lock to distinct
|
||
// blocks, the α-of-K vote splits 5 ways, and NOTHING finalizes (the net-wide
|
||
// stall). Instead the vote is emitted for the DETERMINISTICALLY CONVERGED winner
|
||
// at this height (convergenceVoter) — which may be this node's block or a peer's
|
||
// lower-canonical sibling — so every honest node signs the SAME block. The
|
||
// confidence driver still counts the own proposal (ProcessVote above); only the
|
||
// binding SIGNATURE is deferred to convergence.
|
||
//
|
||
// Track the live validator set FIRST: re-clamp the committee UP so a chain that launched
|
||
// single-validator switches to the real k-of-N quorum path the moment it decentralizes,
|
||
// rather than continuing to self-vote+synthesize a 1-of-1 cert (RED's 1→N fork).
|
||
t.reclampCommitteeLocked()
|
||
if t.consensus.K() == 1 {
|
||
t.recordOwnVoteLocked(pb, vmBlock.ID())
|
||
}
|
||
|
||
// UNLOCK-BEFORE-CALL-OUT (the global invariant — see the Transitive.mu doc). Capture
|
||
// everything the call-outs need UNDER t.mu, then RELEASE it and do EVERY call-out — the
|
||
// network Propose/RequestVotes, VM.SetPreference, and the finalizer — with t.mu NOT held. A
|
||
// call-out that reenters t.mu self-deadlocks (the removed selfVoter class); network sends
|
||
// must also never sit under the engine lock. buildSingleValidatorCertLocked reads
|
||
// pendingBlocks/position so its cert is built here (locked); its finalize runs unlocked.
|
||
proposerWired := t.proposer != nil
|
||
blockID := vmBlock.ID()
|
||
singleValidator := t.consensus.K() == 1
|
||
var proposal BlockProposal
|
||
if proposerWired {
|
||
proposal = BlockProposal{
|
||
BlockID: blockID,
|
||
BlockData: vmBlock.Bytes(),
|
||
Height: vmBlock.Height(),
|
||
ParentID: vmBlock.ParentID(),
|
||
}
|
||
}
|
||
var singleCert VerifiedQuorumCert
|
||
if singleValidator {
|
||
singleCert = t.buildSingleValidatorCertLocked(pb, blockID)
|
||
}
|
||
vm := t.vm
|
||
t.mu.Unlock()
|
||
|
||
// ---- ALL CALL-OUTS, t.mu RELEASED ----
|
||
if proposerWired {
|
||
// Gossip the block so non-validating FOLLOWERS receive it. Solicit peer votes ONLY on a
|
||
// multi-validator chain — a K==1 chain has no peers and finalizes via the inline
|
||
// finalizer below (the removed single-node self-vote shortcut was the t.mu-reentrant
|
||
// deadlock).
|
||
t.proposer.Propose(ctx, proposal)
|
||
if !singleValidator {
|
||
t.proposer.RequestVotes(ctx, VoteRequest{BlockID: blockID, BlockData: proposal.BlockData})
|
||
}
|
||
}
|
||
if singleValidator {
|
||
// The sole validator's accept IS the 1-of-1 quorum — finalize inline through the sole
|
||
// cert-gated finalizer. signalNext=false: the build loop drives the next build itself
|
||
// (re-signaling would spawn a concurrent builder and gap the VM block counter).
|
||
_ = t.acceptWithCertCore(ctx, blockID, singleCert, false)
|
||
} else if proposerWired {
|
||
// K>1: STORM BOUND — steer the VM build target to the just-built tip so proposervm's
|
||
// WaitForEvent advances to the NEXT height instead of re-returning "build THIS height"
|
||
// (the mainnet 511-rebuild spin). A build hint only; finality is still the α-of-K cert.
|
||
// This node does NOT self-vote its own block here (the convergence loop casts the single
|
||
// vote for the settled winner); finalize only if a verified cert is already assemblable.
|
||
if vm != nil {
|
||
if tip := t.PreferredBuildTip(); tip != ids.Empty {
|
||
_ = vm.SetPreference(ctx, tip)
|
||
}
|
||
}
|
||
t.finalizeOwnProposal(ctx, blockID)
|
||
}
|
||
t.mu.Lock()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Transport (network layer interface)
|
||
// -----------------------------------------------------------------------------
|
||
|
||
// Transport handles message transport.
|
||
type Transport[ID comparable] interface {
|
||
Send(ctx context.Context, to string, msg interface{}) error
|
||
Receive(ctx context.Context) (interface{}, error)
|
||
}
|