mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
NewRuntime stored the caller's Logger as given, so a Runtime built without one held a nil interface. Twenty-four sites in engine/chain read that logger with a bare IsZero(); fourteen others ask `!= nil &&` first. Two of the twenty-four are the first line of HandleIncomingVote and HandleIncomingCert, which is to say a remote input reached a nil interface method call before anything validated it. The engine already settles this for itself — NewWithConfig falls back to log.Noop() when no logger is set — so the runtime constructor now does the same thing in the same way. Runtime.config is unexported and written once, at construction, so past NewRuntime a logger is always present and all twenty-four sites are safe by construction rather than by twenty-four remembered guards. Noop discards what it is given and reports IsZero, so an unset logger stays exactly as silent as it was; nothing else changes. Co-authored-by: Hanzo Dev <[email protected]>
1304 lines
59 KiB
Go
1304 lines
59 KiB
Go
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
|
||
// See the file LICENSE for licensing terms.
|
||
|
||
package chain
|
||
|
||
import (
|
||
"context"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/luxfi/consensus/config"
|
||
"github.com/luxfi/consensus/engine/chain/block"
|
||
"github.com/luxfi/ids"
|
||
"github.com/luxfi/log"
|
||
)
|
||
|
||
// ValidatorSampler provides access to the validator set for peer sampling.
|
||
// This is the minimal interface needed by consensus - avoids importing full validator package.
|
||
type ValidatorSampler interface {
|
||
// Sample returns k random validator NodeIDs from the network.
|
||
Sample(networkID ids.ID, k int) ([]ids.NodeID, error)
|
||
// Count returns the number of validators in the network.
|
||
Count(networkID ids.ID) int
|
||
}
|
||
|
||
// NetworkConfig holds parameters for integrating consensus with the node network.
|
||
type NetworkConfig struct {
|
||
// ChainID is the ID of this chain (used for chain-scoped messages like Put, PullQuery)
|
||
ChainID ids.ID
|
||
// NetworkID is the ID of the network whose validators secure this chain
|
||
// For primary network chains (P/X/C), this equals constants.PrimaryNetworkID
|
||
// For L1 chains, this is the L1's validator set ID
|
||
NetworkID ids.ID
|
||
// NodeID is this node's identifier (for excluding self from samples)
|
||
NodeID ids.NodeID
|
||
// Validators provides access to the validator set for peer sampling.
|
||
// If nil, the engine broadcasts to all peers (less efficient).
|
||
Validators ValidatorSampler
|
||
// Logger for consensus events. Optional: NewRuntime answers an unset one
|
||
// with log.Noop(), so a Runtime always holds a logger and the handlers can
|
||
// reach for it without asking whether it is there.
|
||
Logger log.Logger
|
||
// Gossiper broadcasts messages to validators
|
||
Gossiper Gossiper
|
||
// VM implements BlockBuilder for block creation
|
||
VM BlockBuilder
|
||
// Params are optional consensus parameters. If nil, DefaultParams() is used.
|
||
// For small validator sets (e.g., 5 nodes), use LocalParams() which has
|
||
// K=5, Beta=4 - appropriate thresholds for the validator count.
|
||
Params *config.Parameters
|
||
|
||
// Quorum-cert finality (multi-validator). The node supplies these to enable
|
||
// α-of-K cert-witnessed finality:
|
||
// - VoteVerifier MUST be non-nil for any K>1 chain (the engine refuses to
|
||
// Start a multi-validator engine without it). Verifies vote + cert
|
||
// signatures against the chain's validator key set.
|
||
// - VoteSigner signs THIS node's accept votes (so its signature joins the
|
||
// cert when it votes). Required for this node to contribute to a cert.
|
||
// - CryptoWitnessSource (optional) upgrades engine certs to
|
||
// quasar.WeightedQuorumCert when the chain's PQ weighted validator set is
|
||
// plumbed. nil keeps the engine-level cert as the witness.
|
||
//
|
||
// For a single-validator chain (K==1, e.g. --dev) leave these nil.
|
||
VoteVerifier VoteVerifier
|
||
VoteSigner VoteSigner
|
||
|
||
// StakeSource (optional) makes finality a ⅔-by-STAKE supermajority instead
|
||
// of a raw voter count (HIGH-3). REQUIRED for a PoS value chain with unequal
|
||
// stake; nil keeps count-α finality (correct only under equal stake enforced
|
||
// at admission). The node supplies a source backed by the chain's validator
|
||
// set weights.
|
||
StakeSource StakeSource
|
||
|
||
// QuasarObserver (optional) is notified when the EXPORT (Quasar, ⅔-by-stake) frontier
|
||
// advances. The node wires it to push the export-final height into the VM so the EVM
|
||
// `finalized`/`safe` block tags and the warp export gate read the Quasar tip — NEVER the
|
||
// reorgable Nova/accept tip (the semantic-collapse the two-tier split exists to prevent).
|
||
// Fires strictly after the block's Nova accept, monotonically. nil = no export surface.
|
||
QuasarObserver func(canonical ids.ID, height uint64)
|
||
|
||
// ValidatorSetRoot (optional) binds every vote/cert to the active weighted
|
||
// validator set at the block's height (the MEDIUM fix), so the ⅔-by-stake
|
||
// predicate is ENFORCED at the cert-position epoch: a cert gathered under one
|
||
// epoch's set cannot be re-verified against another epoch's set (its
|
||
// signatures were over the bound root). Supply it on a chain whose validator
|
||
// set/stake changes across epochs (alongside StakeSource); nil binds Empty
|
||
// (the correct no-op for a fixed validator set).
|
||
ValidatorSetRoot ValidatorSetRootSource
|
||
|
||
// Catchup (optional) is the node's ancestor-fetch transport, wired into the
|
||
// engine's runtime auto-recovery (see Catchup / Runtime.requestCatchup). With
|
||
// it, a follower that falls behind — missing a block's PARENT (out-of-order
|
||
// gossip) or its BYTES (a vote outran the block) — self-heals by fetching the
|
||
// missing block and rejoining the frontier. nil keeps the legacy no-self-heal
|
||
// behaviour (a behind follower is stranded).
|
||
Catchup Catchup
|
||
|
||
// VoteGuard (optional) is the DURABLE per-(height,epoch) non-equivocation guard
|
||
// (HIGH-1). A signing validator supplies one — opened by the node from the chain's
|
||
// data dir (chain.OpenVoteGuard) so the fail-closed-on-corrupt decision lives at
|
||
// the node boundary — so a per-height binding survives a crash and the node cannot
|
||
// sign a conflicting sibling at a height it already committed to before a restart.
|
||
// nil runs the guard memory-only (Start warns a signer that has none).
|
||
VoteGuard VoteGuardStore
|
||
|
||
// Certs (optional) is the DURABLE home for the finality certs this node assembles,
|
||
// opened by the node from the chain's data dir (chain.OpenCerts) alongside the vote
|
||
// guard. A peer that fell behind can only rejoin by being served the cert for each
|
||
// gap block — the network will not re-vote a decided height — so this store decides
|
||
// how far back a restarted fleet can still help a straggler. nil keeps certs
|
||
// memory-only, which strands any peer whose gap predates our last restart.
|
||
Certs CertStore
|
||
}
|
||
|
||
// Gossiper abstracts the network layer for consensus message broadcasting.
|
||
// This minimal interface avoids importing the full node/network package.
|
||
//
|
||
// Gossiper is the network-level interface. BlockProposer (in engine.go)
|
||
// is the consensus-level interface. The gossiperAdapter bridges them.
|
||
type Gossiper interface {
|
||
// GossipPut broadcasts a Put message with block data to validators.
|
||
// Returns the number of validators the message was sent to.
|
||
GossipPut(chainID ids.ID, networkID ids.ID, blockData []byte) int
|
||
// SendPullQuery sends a PullQuery to specific validators requesting votes.
|
||
SendPullQuery(chainID ids.ID, networkID ids.ID, blockID ids.ID, validators []ids.NodeID) int
|
||
// SendPushQuery sends a PushQuery (block data + vote request) to validators.
|
||
// Unlike PullQuery (which only sends blockID), PushQuery includes the block bytes
|
||
// so peers can immediately verify and respond with their vote.
|
||
// Returns the number of validators the message was sent to.
|
||
SendPushQuery(chainID ids.ID, networkID ids.ID, blockData []byte, validators []ids.NodeID) int
|
||
// SendVote sends a vote response back to the proposer node.
|
||
// Vote response back to the proposer
|
||
// This is called after fast-follow acceptance to notify the proposer
|
||
// that this node has accepted the block, enabling the proposer to
|
||
// reach vote threshold and finalize its own copy.
|
||
SendVote(chainID ids.ID, toNodeID ids.NodeID, blockID ids.ID) error
|
||
}
|
||
|
||
// QuorumGossiper is the vote/cert distribution topology required for α-of-K
|
||
// finality. Followers broadcast their SIGNED votes to ALL validators, and any
|
||
// node that collects alpha distinct signed votes assembles and gossips the
|
||
// cert. So finality does not depend on any one node's inbound Chits: a vote
|
||
// that reaches only the proposer leaves the round resting on whether that one
|
||
// node hears enough, and this topology does not.
|
||
//
|
||
// A Gossiper that does not implement QuorumGossiper runs in legacy single-
|
||
// validator / no-cert mode (K==1). Multi-validator finality requires it.
|
||
type QuorumGossiper interface {
|
||
// BroadcastVote sends this node's SIGNED accept vote for blockID to ALL
|
||
// validators on the network (not just the proposer). voteBytes is the
|
||
// encoded signed vote (node id + signature over the canonical vote
|
||
// message). Returns the number of validators reached.
|
||
BroadcastVote(chainID ids.ID, networkID ids.ID, blockID ids.ID, voteBytes []byte) int
|
||
// GossipCert broadcasts an assembled finality cert (encoded
|
||
// WeightedQuorumCert / engine QuorumCert) to ALL validators so they can
|
||
// finalize blockID on a verifiable α-of-K proof. Returns validators reached.
|
||
GossipCert(chainID ids.ID, networkID ids.ID, blockID ids.ID, certBytes []byte) int
|
||
}
|
||
|
||
// Runtime wraps Transitive with network integration and VM notification handling.
|
||
// Use NewRuntime to create - this is the "one right way" to set up consensus.
|
||
type Runtime struct {
|
||
*Transitive
|
||
config NetworkConfig
|
||
|
||
// Validator sampling for k-peer polls
|
||
validators ValidatorSampler
|
||
nodeID ids.NodeID
|
||
|
||
// fastFollowMu serializes fast-follow block acceptance to prevent
|
||
// duplicate gossip deliveries from racing on the accept path.
|
||
fastFollowMu sync.Mutex
|
||
// certMu serializes certificate ingestion. A valid cert commits the ledger
|
||
// before VM.Accept completes; without a single ingestion lane, a burst of
|
||
// identical peer deliveries can all clear the stale-height check and perform
|
||
// the same signature verification concurrently. Only one can advance
|
||
// finality: the winner finalizes, and queued duplicates then hit the cheap
|
||
// finalized-height guard and return without re-verifying or re-accepting,
|
||
// so a burst of identical deliveries costs one verification rather than
|
||
// one each.
|
||
certMu sync.Mutex
|
||
// fastFollowHeight tracks the highest block height accepted via fast-follow.
|
||
// We use height instead of parent ID matching because:
|
||
// 1. VM.LastAccepted() is stale after Accept() calls
|
||
// 2. Block-producing nodes don't update the tracker when they build blocks
|
||
// Height-based acceptance is safe because blocks are already verified.
|
||
fastFollowHeight uint64
|
||
}
|
||
|
||
// bftCommittee scales a preset sample size k down to the live validator count and
|
||
// returns the BFT-supermajority quorum α = ⌊2k/3⌋+1 for the resulting committee.
|
||
// It only ever shrinks an oversized committee (count < k) — a preset that already
|
||
// fits (k ≤ count) is reported unchanged (clamped=false) so its hand-tuned α is
|
||
// preserved. The α formula reproduces every preset exactly: K4→α3, K11→α8,
|
||
// K20→α14, K21→α15, guaranteeing the result clears the BFT α-floor (2α−k ≥ f+1)
|
||
// while staying reachable (α ≤ k). This is the one mechanism that keeps a network
|
||
// from booting with an unsatisfiable quorum (α > live validators), which wedges
|
||
// finality permanently: every block verifies but the α-of-K cert never assembles.
|
||
func bftCommittee(presetK, count int) (newK, alpha int, clamped bool) {
|
||
if count <= 0 || presetK <= count {
|
||
// No sampler (tests / --dev), or the preset committee already fits the live
|
||
// set: keep the hand-tuned (K, α) verbatim (clamped=false).
|
||
return presetK, 0, false
|
||
}
|
||
// 0 < count < presetK: fewer validators are currently resolved than the preset
|
||
// declares. Clamp K DOWN to the live set so an oversized preset stays finalizable
|
||
// (K=21→5), BUT never below the minimal Byzantine-fault-tolerant committee.
|
||
//
|
||
// SELF-FINALITY FLOOR (the ROOT of the transient-K=1 fork). validators.Manager.Count
|
||
// reads len(m.validators[net]), which UNDER-reports during a restart window before the
|
||
// P-chain has finished replaying the staker set — it transiently returns 1. The old
|
||
// clamp turned that transient count=1 into K=1/α=1, and a K==1 engine synthesizes a
|
||
// 1-of-1 finality token that BYPASSES the ⅔-by-stake gate: the lone live node
|
||
// self-finalizes divergent blocks and forks the chain against the validators that were
|
||
// merely unresolved, each of which comes back holding a different history. Flooring K at
|
||
// the minimal BFT committee (K=4, α=3, f=1) means the α-of-K COUNT gate ALWAYS demands
|
||
// a real BFT quorum (α≥3) that a single node can never reach — so a chain whose live
|
||
// set is transiently (or genuinely) below the floor HALTS fail-closed (the cert never
|
||
// assembles) instead of self-finalizing, and reclampCommitteeLocked grows K back up
|
||
// toward presetK as the real validators resolve (K=4→5). The ⅔-by-stake VerifyWeighted
|
||
// gate remains the finality authority on top; this count floor is the INDEPENDENT
|
||
// defense the stake gate cannot give when an epoch set transiently resolves to a
|
||
// lone-node subset (100% of a 1-member set trivially clears ⅔). A genuine
|
||
// single-validator chain sets presetK≤1 and never enters this branch (K stays 1 → it
|
||
// finalizes correctly on its own accept, with no peer to fork against).
|
||
floorK := minBFTCommittee // K=4/α=3: 2α−K = 2 ≥ f+1 = 2 with f = ⌊(K-1)/3⌋ = 1 (quorum.go)
|
||
if presetK < floorK {
|
||
floorK = presetK // a sub-BFT preset (dev K=3) keeps its own size as the floor
|
||
}
|
||
newK = count
|
||
if newK < floorK {
|
||
newK = floorK
|
||
}
|
||
return newK, bftAlpha(newK), true
|
||
}
|
||
|
||
// bftAlpha is the BFT accept quorum (the integer α COUNT) for a committee of `count` members: the
|
||
// smallest integer strictly greater than ⅔ of the members, ⌊2·count/3⌋+1. It reuses the SINGLE
|
||
// ⅔-supermajority definition (config.TwoThirdsStakeFloor) so the formula lives in exactly one place
|
||
// — the count quorum here and the stake quorum inside VerifyWeighted are literally the same rational
|
||
// threshold, never two hand-inlined copies that could drift.
|
||
func bftAlpha(count int) int {
|
||
if count <= 0 {
|
||
return 0
|
||
}
|
||
return int(config.TwoThirdsStakeFloor(uint64(count))) + 1
|
||
}
|
||
|
||
// effectiveCommittee returns the live BFT committee (n, α) for FINALITY at epochHeight, sized from
|
||
// the populated ⅔-by-stake StakeSource — the real validator set — NOT the sample size
|
||
// (t.consensus.K()/Alpha()). A large preset (MainnetParams K=21/α=15, sized for a ≥21-validator
|
||
// network) inflates the sample far above a small live set; α distinct votes are then unreachable
|
||
// (e.g. α=15 from 5 validators), so BOTH the α-of-K cert assembly (assembleCertLocked) and the
|
||
// round-scoped view-change POL freeze — every block verifies but no quorum cert ever assembles (the
|
||
// 2026-07-09 mainnet C-Chain finality stall). This happens whenever the construction-time bftCommittee
|
||
// clamp was skipped because the current-map validator Manager read 0 at construction (the same empty
|
||
// set that gives the proposervm windower pChainHeight=0), and reclampCommitteeLocked is UP-ONLY so it
|
||
// can never shrink 21→5. Reading the count from the SAME height-indexed set the ⅔-by-stake cert
|
||
// verifies against keeps the count-quorum and the stake-quorum the IDENTICAL set (deterministic
|
||
// across nodes → no fork), and α = bftAlpha(count) is the ⅔ supermajority. It only ever SHRINKS an
|
||
// oversized sample (0 < count < K); an unresolved set (count ≤ 0) or a preset that already fits keeps
|
||
// the configured (K, α). bftAlpha(count) satisfies 2α−n>f for every count ≥ 4, so the shrunk
|
||
// committee is fork-safe by construction (n=5→α=4: 2·4−5 = 3 > f = 1).
|
||
func (t *Transitive) effectiveCommittee(epochHeight uint64) (n, alpha int) {
|
||
n, alpha = t.consensus.K(), t.consensus.Alpha()
|
||
if t.stakeSource == nil {
|
||
return n, alpha
|
||
}
|
||
// Reuse the SINGLE committee-sizing formula (bftCommittee): it clamps K DOWN to the live count
|
||
// AND floors at the minimal BFT committee (K=4/α=3), so a transient/degenerate low count can
|
||
// NEVER produce a lone-reachable α (the 1085013 self-finality fork the count floor prevents). An
|
||
// unresolved set (count ≤ 0) or a preset that already fits leaves the configured (K, α) — which
|
||
// the round-scoped 2α−n>f gate still guards.
|
||
if k, a, clamped := bftCommittee(n, t.stakeSource.SignerCount(epochHeight)); clamped {
|
||
return k, a
|
||
}
|
||
return n, alpha
|
||
}
|
||
|
||
// NewRuntime creates a fully wired consensus runtime ready for production use.
|
||
//
|
||
// This is the single, canonical way to create a chain consensus runtime for node integration.
|
||
// It:
|
||
// 1. Creates the Transitive engine with default parameters
|
||
// 2. Wires the network gossiper as the BlockProposer
|
||
// 3. Registers the VM for block building
|
||
// 4. Returns a ready-to-start runtime
|
||
//
|
||
// Usage in manager.go:
|
||
//
|
||
// runtime := chain.NewRuntime(chain.NetworkConfig{
|
||
// ChainID: chainParams.ID,
|
||
// NetworkID: chainParams.ChainID, // PrimaryNetworkID for P/X/C
|
||
// Logger: m.Log,
|
||
// Gossiper: &networkGossiper{net: m.Net, msgCreator: m.MsgCreator},
|
||
// VM: vm.(chain.BlockBuilder),
|
||
// })
|
||
// if err := runtime.Start(ctx, true); err != nil { return err }
|
||
// go runtime.ForwardVMNotifications(toEngine)
|
||
func NewRuntime(cfg NetworkConfig) *Runtime {
|
||
// A logger is optional to supply but never absent to use: the incoming vote,
|
||
// cert and block handlers each reach for it on their first line, before any
|
||
// validation. Answering an unset one here, once, is what lets every later
|
||
// site read it without first asking whether it is there. Noop is the same
|
||
// singleton the engine settles on in NewWithConfig, and it discards what it
|
||
// is given, so an unset logger stays as silent as the caller meant it to be.
|
||
if cfg.Logger == nil {
|
||
cfg.Logger = log.Noop()
|
||
}
|
||
|
||
// Use provided params or default
|
||
params := config.DefaultParams()
|
||
if cfg.Params != nil {
|
||
params = *cfg.Params
|
||
}
|
||
|
||
// Dynamic committee sizing (liveness, one mechanism): a static preset K can
|
||
// exceed the live validator count. TestnetParams (K=11, α=8) on a 5-validator
|
||
// network demands 8 affirmative votes from 5 nodes — unreachable — so NO block
|
||
// ever finalizes: each block verifies but the α-of-K quorum cert never
|
||
// assembles and the chain wedges. Scale K down to the live set and α to the BFT
|
||
// supermajority ⌊2K/3⌋+1 — the exact relation every preset already encodes
|
||
// (K4→α3, K11→α8, K20→α14, K21→α15) — so any validator count yields a
|
||
// satisfiable, BFT-valid (K, α) with no per-network preset tuning to drift. The
|
||
// clamp only ever shrinks an oversized committee (params.K > count); a preset
|
||
// that already fits (K ≤ count) is untouched. The ⅔-STAKE cert
|
||
// (WithStakeWeighting, below) still layers weighted BFT safety on top of this
|
||
// count floor, so a smaller committee never weakens the supermajority guarantee.
|
||
// presetK is the TARGET committee size BEFORE the construction-time clamp — the engine
|
||
// re-clamps UP toward it as the validator set grows (reclampCommitteeLocked), so a chain that
|
||
// launches single-validator does not stay stuck at K=1 after it decentralizes (RED's 1→N fork).
|
||
presetK := params.K
|
||
validatorCount := -1
|
||
if cfg.Validators != nil {
|
||
validatorCount = cfg.Validators.Count(cfg.NetworkID)
|
||
if k, alpha, clamped := bftCommittee(params.K, validatorCount); clamped {
|
||
params.K = k
|
||
params.AlphaPreference = alpha
|
||
params.AlphaConfidence = alpha
|
||
}
|
||
}
|
||
|
||
engine := NewWithParams(params)
|
||
// Wire the live-committee tracking so the engine re-clamps K UP and gates 1-of-1 synthesis on
|
||
// the CURRENT validator count (not the frozen construction count). Without a sampler (tests /
|
||
// --dev) this stays unwired and the committee is fixed at construction (presetK unused).
|
||
engine.presetK = presetK
|
||
if cfg.Validators != nil {
|
||
sampler, netID := cfg.Validators, cfg.NetworkID
|
||
engine.liveValidatorCount = func() int { return sampler.Count(netID) }
|
||
}
|
||
|
||
// Wire α-of-K quorum-cert finality for multi-validator chains. The engine
|
||
// refuses to Start a K>1 engine without a verifier (fail-closed), so a
|
||
// production multi-validator chain MUST supply cfg.VoteVerifier. The cert
|
||
// gossiper bridges the network Gossiper's cert-distribution to the engine.
|
||
if cfg.VoteVerifier != nil {
|
||
var certGossiper CertGossiper
|
||
if cfg.Gossiper != nil {
|
||
certGossiper = &gossiperCertBridge{
|
||
gossiper: cfg.Gossiper,
|
||
chainID: cfg.ChainID,
|
||
networkID: cfg.NetworkID,
|
||
}
|
||
}
|
||
WithQuorumCert(cfg.ChainID, cfg.NodeID, cfg.VoteVerifier, certGossiper, cfg.VoteSigner)(engine)
|
||
// Stake-weighted finality (HIGH-3): when the node supplies validator
|
||
// weights, a cert must clear a ⅔-of-stake supermajority, not just the
|
||
// α-of-K count.
|
||
if cfg.StakeSource != nil {
|
||
WithStakeWeighting(cfg.StakeSource)(engine)
|
||
}
|
||
// Epoch binding (MEDIUM): pin every vote/cert to the active weighted set
|
||
// at the block's height so the stake predicate is enforced at the
|
||
// cert-position epoch (a cross-epoch cert fails verification).
|
||
if cfg.ValidatorSetRoot != nil {
|
||
WithValidatorSetRoot(cfg.ValidatorSetRoot)(engine)
|
||
}
|
||
}
|
||
|
||
// Export-frontier observer: the node wires this to push the EXPORT (Quasar, ⅔-by-stake)
|
||
// height into the VM so the EVM `finalized`/`safe` tags and warp export gate read the Quasar
|
||
// tip, never the reorgable Nova/accept tip. nil on a chain with no export surface.
|
||
if cfg.QuasarObserver != nil {
|
||
WithQuasarObserver(cfg.QuasarObserver)(engine)
|
||
}
|
||
|
||
// Durable non-equivocation guard (HIGH-1): persist each (height,epoch)→canonical
|
||
// binding before this node signs, and reload it on startup, so a crash between
|
||
// signing and finalizing cannot forget it and permit a fork. The node opens the
|
||
// store (fail-closed on a corrupt file) and passes it here; nil keeps the guard
|
||
// memory-only.
|
||
if cfg.VoteGuard != nil {
|
||
WithVoteGuard(cfg.VoteGuard)(engine)
|
||
}
|
||
|
||
// Keep the finality certs this node assembles on disk, so a peer that fell behind
|
||
// can still be served the height it needs after we restart. nil keeps them in
|
||
// memory only, where they last exactly as long as this process.
|
||
if cfg.Certs != nil {
|
||
WithCerts(cfg.Certs)(engine)
|
||
}
|
||
|
||
rt := &Runtime{
|
||
Transitive: engine,
|
||
config: cfg,
|
||
validators: cfg.Validators,
|
||
nodeID: cfg.NodeID,
|
||
}
|
||
|
||
// Wire runtime auto-recovery (ONE mechanism, two triggers): the engine holds
|
||
// the fetch GATE (claimCatchupLocked) and the missing-PARENT trigger
|
||
// (followVerifiedBlock → requestCatchup); the engine's missing-BYTES trigger
|
||
// (handleVote buffering a vote for an untracked block) signals through
|
||
// requestMissing into the SAME requestCatchup transport. WithCatchup gives the
|
||
// engine the transport handle for its gate's nil-check; requestMissing supplies
|
||
// the networkID-bearing round-trip the engine itself cannot make.
|
||
WithCatchup(cfg.Catchup)(engine)
|
||
engine.requestMissing = rt.requestCatchup
|
||
|
||
// Wire the convergence voter: the engine defers this node's one per-height accept
|
||
// signature to the Runtime, which owns the sign+broadcast path and emits it for the
|
||
// deterministically-converged winner at each height (see followVerifiedBlock /
|
||
// buildBlocksLocked). This is what makes honest nodes place their single vote on the
|
||
// SAME block per height, so a fresh-net storm converges to one α-of-K cert.
|
||
engine.convergenceVoter = rt
|
||
|
||
// Log validator set status for debugging
|
||
hasLogger := cfg.Logger != nil && !cfg.Logger.IsZero()
|
||
if validatorCount >= 0 {
|
||
if hasLogger {
|
||
// presetK/presetAlpha are the SAMPLE preset (e.g. MainnetParams 21/15), NOT the
|
||
// finality committee. The α-of-K cert + view-change size to the LIVE set at runtime
|
||
// (effectiveCommittee/bftCommittee); the engine logs the effective (K,α) on each
|
||
// committee re-clamp. Do not read presetK as the quorum.
|
||
cfg.Logger.Info("consensus engine initialized with validator set",
|
||
log.Stringer("networkID", cfg.NetworkID),
|
||
log.Int("validatorCount", validatorCount),
|
||
log.Int("presetK", params.K),
|
||
log.Int("presetAlpha", params.AlphaPreference),
|
||
log.String("note", "finality committee sized to the live validator set at runtime; see committee-clamp log for effective K/alpha"))
|
||
}
|
||
} else {
|
||
if hasLogger {
|
||
cfg.Logger.Warn("consensus engine initialized WITHOUT validator set - will broadcast to all peers",
|
||
log.Stringer("networkID", cfg.NetworkID))
|
||
}
|
||
}
|
||
|
||
// Wire the proposer (adapts Gossiper to BlockProposer interface). There is NO single-node
|
||
// self-voter shortcut: a K==1 chain has no peers to solicit, so it finalizes SOLELY through the
|
||
// inline build-path finalizer (buildBlocksLocked → buildSingleValidatorCertLocked →
|
||
// acceptWithCertCore) — one finalize path, matching "a lone validator's own
|
||
// decision finalizes locally." The removed shortcut called engine.ReceiveVote from inside
|
||
// RequestVotes while buildBlocksLocked held t.mu, self-deadlocking on the non-reentrant
|
||
// t.mu.RLock (the live n=1 freeze), and delivered a redundant vote that could sit uncounted in
|
||
// handleVote's not-yet-tracked buffer. Both hazards are gone with the path removed.
|
||
// Wire the runtime logger into the engine. NewWithParams leaves the engine on
|
||
// its log.Noop() default, which silently discards every internal decision the
|
||
// engine makes (verify drops, AddBlock rejections, build-loop stalls). The
|
||
// gossiperProposer below already receives cfg.Logger; the engine must too.
|
||
engine.SetLogger(cfg.Logger)
|
||
|
||
engine.SetProposer(&gossiperProposer{
|
||
gossiper: cfg.Gossiper,
|
||
chainID: cfg.ChainID,
|
||
networkID: cfg.NetworkID,
|
||
logger: cfg.Logger,
|
||
validators: cfg.Validators,
|
||
nodeID: cfg.NodeID,
|
||
k: params.K,
|
||
})
|
||
|
||
// Set the VM for block building
|
||
if cfg.VM != nil {
|
||
engine.SetVM(cfg.VM)
|
||
}
|
||
|
||
// STALE-LOCK MIGRATION is deliberately NOT invoked here. It is operator-gated at the
|
||
// node boundary (LUX_CONSENSUS_MIGRATE_STALE_LOCKS=inspect | apply:<floor>), after
|
||
// NewRuntime and before Start, with an explicit floor target that self-disarms once
|
||
// the chain recovers. An unconditional every-boot invocation would prune GENUINE
|
||
// crash-restart locks the VM store cannot resolve by inner id — reopening the
|
||
// crash-restart double-precommit window (≥3 correlated crash-restarts under a
|
||
// partition could then double-finalize).
|
||
|
||
return rt
|
||
}
|
||
|
||
// SampleValidators returns k random validator NodeIDs for the network.
|
||
// This is used by the consensus engine for k-sampling polls.
|
||
// Returns nil if no validator sampler is configured (falls back to broadcast).
|
||
func (rt *Runtime) SampleValidators(k int) ([]ids.NodeID, error) {
|
||
if rt.validators == nil {
|
||
return nil, nil // Will broadcast to all
|
||
}
|
||
return rt.validators.Sample(rt.config.NetworkID, k)
|
||
}
|
||
|
||
// ValidatorCount returns the number of validators in the network.
|
||
// Returns 0 if no validator sampler is configured.
|
||
func (rt *Runtime) ValidatorCount() int {
|
||
if rt.validators == nil {
|
||
return 0
|
||
}
|
||
return rt.validators.Count(rt.config.NetworkID)
|
||
}
|
||
|
||
// FinalizedLedger returns the in-process consensus finalized tip id and height, and
|
||
// whether ANY block has been finalized yet (set=false on an un-seeded / empty-genesis
|
||
// node before its first finalize).
|
||
//
|
||
// This is the SINGLE advancing source of truth for "where the node's accepted chain
|
||
// actually is" — the SAME per-height ledger the bootstrap contiguity check trusts
|
||
// (bootstrap_accept.go reads consensus.GetFinalizedHeight directly). It ADVANCES as
|
||
// blocks finalize via FinalizeBranch. It must NOT be confused with the VM's
|
||
// LastAccepted, which a fire-and-forget Accept can leave FROZEN at the boot snapshot —
|
||
// the staleness this engine already designs around (see fastFollowHeight above: "1.
|
||
// VM.LastAccepted() is stale after Accept() calls"). The node's bootstrap caught-up /
|
||
// height signals read THIS, not the VM cache, so a frozen VM.LastAccepted can never make
|
||
// a converging node re-descend forever and false-stall.
|
||
func (rt *Runtime) FinalizedLedger() (tip ids.ID, height uint64, set bool) {
|
||
if rt.Transitive == nil || rt.Transitive.consensus == nil {
|
||
return ids.Empty, 0, false
|
||
}
|
||
h, ok := rt.Transitive.consensus.GetFinalizedHeight()
|
||
if !ok {
|
||
return ids.Empty, 0, false
|
||
}
|
||
return rt.Transitive.consensus.GetFinalizedTip(), h, true
|
||
}
|
||
|
||
// FinalizedBlockAtHeight returns the block finalized at height h in the in-process
|
||
// per-height ledger, with ok=false when the node has not finalized that height THIS
|
||
// session (a height below the boot seed is never re-seeded, so it reads absent).
|
||
//
|
||
// It is the authoritative in-process fork-sibling oracle, and the node's acceptance
|
||
// check asks it rather than a VM height index. That is a choice about where finality
|
||
// is decided: this ledger is what finality means here,
|
||
// while a VM's height index answers what that VM has accepted, and the two can
|
||
// disagree while a decision is in flight. When the ledger
|
||
// knows h it rejects a stored-but-losing sibling (canonical != id); when it does not
|
||
// (a height below the boot seed), the node degrades to the height-bound anchor
|
||
// (h > finalizedHeight ⇒ not accepted), which the ⅔-by-stake frontier naming (C1) backs.
|
||
func (rt *Runtime) FinalizedBlockAtHeight(h uint64) (ids.ID, bool) {
|
||
if rt.Transitive == nil || rt.Transitive.consensus == nil {
|
||
return ids.Empty, false
|
||
}
|
||
return rt.Transitive.consensus.FinalizedBlockAtHeight(h)
|
||
}
|
||
|
||
// ForwardVMNotifications reads from the VM's toEngine channel and forwards
|
||
// PendingTxs notifications to trigger block building through consensus.
|
||
//
|
||
// Call this as a goroutine after Start():
|
||
//
|
||
// go runtime.ForwardVMNotifications(toEngine)
|
||
//
|
||
// The goroutine exits when the channel is closed.
|
||
func (rt *Runtime) ForwardVMNotifications(toEngine <-chan block.Message) {
|
||
if !rt.config.Logger.IsZero() {
|
||
rt.config.Logger.Info("starting VM notification forwarder for Lux consensus",
|
||
log.Stringer("chainID", rt.config.ChainID))
|
||
}
|
||
|
||
for msg := range toEngine {
|
||
// Translate block.MessageType → engine.MessageType
|
||
// block.PendingTxs = 1 (iota+1), core.PendingTxs = 0
|
||
// block.StateSyncDone = 2, core.StateSyncDone = 1
|
||
var engineMsgType MessageType
|
||
switch msg.Type {
|
||
case block.PendingTxs:
|
||
engineMsgType = PendingTxs
|
||
case block.StateSyncDone:
|
||
engineMsgType = StateSyncDone
|
||
default:
|
||
if !rt.config.Logger.IsZero() {
|
||
rt.config.Logger.Warn("unknown VM message type, dropping",
|
||
log.Uint32("type", uint32(msg.Type)))
|
||
}
|
||
continue
|
||
}
|
||
|
||
if !rt.config.Logger.IsZero() {
|
||
rt.config.Logger.Debug("received VM notification, forwarding to consensus engine",
|
||
log.Uint32("vmType", uint32(msg.Type)),
|
||
log.Uint32("engineType", uint32(engineMsgType)))
|
||
}
|
||
|
||
ctx := context.Background()
|
||
if err := rt.Notify(ctx, Message{Type: engineMsgType}); err != nil {
|
||
if rt.config.Logger != nil && !rt.config.Logger.IsZero() {
|
||
rt.config.Logger.Warn("failed to notify consensus engine",
|
||
log.Uint32("type", uint32(engineMsgType)),
|
||
log.Err(err))
|
||
}
|
||
}
|
||
|
||
// In single-node mode, drain any accepted blocks and call VM.Accept().
|
||
// Notify → buildBlocksLocked → AddBlock + ProcessVote + Poll may have
|
||
// accepted blocks synchronously. The engine tracks them in pendingBlocks
|
||
// with Decided=false until we finalize here.
|
||
rt.drainAcceptedBlocks(ctx)
|
||
}
|
||
|
||
if !rt.config.Logger.IsZero() {
|
||
rt.config.Logger.Info("VM notification forwarder stopped")
|
||
}
|
||
}
|
||
|
||
// drainAcceptedBlocks finalizes any blocks that consensus has accepted.
|
||
// This is called after each VM notification to ensure accepted blocks have
|
||
// their VM.Accept() called promptly (especially important in single-node mode
|
||
// where blocks are accepted synchronously during buildBlocksLocked).
|
||
func (rt *Runtime) drainAcceptedBlocks(ctx context.Context) {
|
||
rt.Transitive.DrainAccepted(ctx)
|
||
}
|
||
|
||
// gossiperCertBridge adapts the engine's CertGossiper (GossipCert(chainID,
|
||
// blockID, bytes)) to the network Gossiper's QuorumGossiper.GossipCert
|
||
// (chainID, networkID, blockID, bytes). If the configured Gossiper does not
|
||
// implement QuorumGossiper, cert gossip is a no-op (the engine still finalizes
|
||
// locally on the verified cert; followers reach finality via their own votes).
|
||
type gossiperCertBridge struct {
|
||
gossiper Gossiper
|
||
chainID ids.ID
|
||
networkID ids.ID
|
||
}
|
||
|
||
var _ CertGossiper = (*gossiperCertBridge)(nil)
|
||
|
||
func (b *gossiperCertBridge) GossipCert(_ ids.ID, blockID ids.ID, certBytes []byte) error {
|
||
if qg, ok := b.gossiper.(QuorumGossiper); ok {
|
||
qg.GossipCert(b.chainID, b.networkID, blockID, certBytes)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// gossiperProposer adapts a Gossiper to the BlockProposer interface.
|
||
// This bridges the network layer (Gossiper) to the consensus layer (BlockProposer).
|
||
type gossiperProposer struct {
|
||
gossiper Gossiper
|
||
chainID ids.ID
|
||
networkID ids.ID
|
||
logger log.Logger
|
||
validators ValidatorSampler // For k-peer sampling
|
||
nodeID ids.NodeID // This node's ID (to exclude from samples)
|
||
k int // Sample size from consensus params
|
||
}
|
||
|
||
var _ BlockProposer = (*gossiperProposer)(nil)
|
||
|
||
// Propose broadcasts a block proposal to validators via the network gossiper.
|
||
func (p *gossiperProposer) Propose(ctx context.Context, proposal BlockProposal) error {
|
||
if p.gossiper == nil {
|
||
if p.logger != nil && !p.logger.IsZero() {
|
||
p.logger.Warn("cannot propose block - gossiper is nil",
|
||
log.Stringer("blockID", proposal.BlockID))
|
||
}
|
||
return nil // Not fatal - local acceptance still works
|
||
}
|
||
|
||
sentTo := p.gossiper.GossipPut(p.chainID, p.networkID, proposal.BlockData)
|
||
if p.logger != nil && !p.logger.IsZero() {
|
||
p.logger.Info("proposed block to validators",
|
||
log.Stringer("blockID", proposal.BlockID),
|
||
log.Uint64("height", proposal.Height),
|
||
log.Int("sentTo", sentTo))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// RequestVotes asks specific validators to vote on a block.
|
||
// If req.Validators is nil and we have a ValidatorSampler, sample k validators.
|
||
// This implements Lux k-sampling: select k validators and request votes.
|
||
//
|
||
// Single-node mode (K=1, only validator is self): the proposer delivers a
|
||
// self-vote via the SelfVoter callback instead of polling the network.
|
||
// This is the standard path for --dev mode (local single-validator networks).
|
||
func (p *gossiperProposer) RequestVotes(ctx context.Context, req VoteRequest) error {
|
||
if p.gossiper == nil {
|
||
if p.logger != nil && !p.logger.IsZero() {
|
||
p.logger.Warn("cannot request votes - gossiper is nil",
|
||
log.Stringer("blockID", req.BlockID))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Determine which validators to query
|
||
validators := req.Validators
|
||
if validators == nil && p.validators != nil && p.k > 0 {
|
||
// Sample k validators from the validator set (excluding self)
|
||
sampled, err := p.validators.Sample(p.networkID, p.k)
|
||
if err != nil {
|
||
if p.logger != nil && !p.logger.IsZero() {
|
||
p.logger.Warn("failed to sample validators, falling back to broadcast",
|
||
log.Stringer("blockID", req.BlockID),
|
||
log.Int("k", p.k),
|
||
log.Err(err))
|
||
}
|
||
// Fall back to broadcast (nil validators)
|
||
} else {
|
||
// Filter out self from sample
|
||
filtered := make([]ids.NodeID, 0, len(sampled))
|
||
for _, nodeID := range sampled {
|
||
if nodeID != p.nodeID {
|
||
filtered = append(filtered, nodeID)
|
||
}
|
||
}
|
||
validators = filtered
|
||
|
||
// NO single-node self-vote shortcut: a K==1 chain finalizes via the inline build-path
|
||
// finalizer (buildBlocksLocked), not by soliciting/self-delivering a vote here (see
|
||
// NewRuntime). When the sample resolves to NO peers (single validator, or every sampled
|
||
// node was self) there is nobody to poll — return without sending, so RequestVotes is a
|
||
// true no-op rather than a query to an empty recipient set.
|
||
if len(filtered) == 0 {
|
||
return nil
|
||
}
|
||
if p.logger != nil && !p.logger.IsZero() {
|
||
p.logger.Debug("sampled k validators for poll",
|
||
log.Stringer("blockID", req.BlockID),
|
||
log.Int("k", p.k),
|
||
log.Int("sampled", len(validators)),
|
||
log.Int("totalValidators", p.validators.Count(p.networkID)))
|
||
}
|
||
}
|
||
}
|
||
|
||
var sentTo int
|
||
if len(req.BlockData) > 0 {
|
||
// PushQuery: send block bytes + vote request in one message.
|
||
// Peers can immediately verify and respond with their vote.
|
||
sentTo = p.gossiper.SendPushQuery(p.chainID, p.networkID, req.BlockData, validators)
|
||
} else {
|
||
// Fallback to PullQuery (blockID only) if no block data available.
|
||
sentTo = p.gossiper.SendPullQuery(p.chainID, p.networkID, req.BlockID, validators)
|
||
}
|
||
if p.logger != nil && !p.logger.IsZero() {
|
||
p.logger.Debug("requested votes from validators",
|
||
log.Stringer("blockID", req.BlockID),
|
||
log.Int("requested", len(validators)),
|
||
log.Int("sentTo", sentTo),
|
||
log.Bool("pushQuery", len(req.BlockData) > 0))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// HandleIncomingBlock processes a block received from network gossip.
|
||
// For follower nodes receiving blocks from the proposer, this uses a "fast-follow"
|
||
// pattern where verified blocks extending the accepted chain are accepted immediately.
|
||
//
|
||
// This is necessary because in the current architecture, votes are only sent back
|
||
// to the proposer (not gossiped to all validators). So followers would never reach
|
||
// the vote threshold on their own. Instead, followers trust that:
|
||
// 1. The proposer collected enough votes before gossiping the block
|
||
// 2. The block verifies correctly against their state
|
||
// 3. The block extends their current chain tip
|
||
//
|
||
// Returns the parsed block if successful, nil otherwise.
|
||
func (rt *Runtime) HandleIncomingBlock(ctx context.Context, blockData []byte, fromNodeID ids.NodeID) (block.Block, error) {
|
||
if rt.config.VM == nil {
|
||
return nil, nil
|
||
}
|
||
|
||
// Parse the block
|
||
blk, err := rt.config.VM.ParseBlock(ctx, blockData)
|
||
if err != nil {
|
||
if !rt.config.Logger.IsZero() {
|
||
rt.config.Logger.Debug("failed to parse incoming block",
|
||
log.Stringer("from", fromNodeID),
|
||
log.Err(err))
|
||
}
|
||
return nil, err
|
||
}
|
||
|
||
// Verify the block
|
||
if err := blk.Verify(ctx); err != nil {
|
||
if !rt.config.Logger.IsZero() {
|
||
rt.config.Logger.Debug("incoming block failed verification",
|
||
log.Stringer("blockID", blk.ID()),
|
||
log.Stringer("from", fromNodeID),
|
||
log.Err(err))
|
||
}
|
||
return blk, err
|
||
}
|
||
|
||
if !rt.config.Logger.IsZero() {
|
||
rt.config.Logger.Info("received and verified block from gossip",
|
||
log.Stringer("blockID", blk.ID()),
|
||
log.Uint64("height", blk.Height()),
|
||
log.Stringer("from", fromNodeID))
|
||
}
|
||
|
||
// QUORUM-GATED FOLLOW:
|
||
//
|
||
// A follower MUST NOT Accept a gossiped block on mere arrival. Arrival is
|
||
// not agreement, and an equivocating proposer can put two blocks for one
|
||
// height in front of two followers. Instead the follower:
|
||
// 1. has already VERIFIED the block (above),
|
||
// 2. tracks it for consensus and casts its OWN signed accept vote,
|
||
// 3. BROADCASTS that signed vote to ALL validators (topology fix), and
|
||
// 4. Accepts ONLY when it holds a verified α-of-K QuorumCert
|
||
// (handleIncomingCert, or a cert it assembles from gossiped votes).
|
||
//
|
||
// No commit without an α-of-K witness, so two followers cannot commit
|
||
// different blocks at one height; and votes reach every validator, so
|
||
// finality does not hinge on one node's inbound Chits.
|
||
blockID := blk.ID()
|
||
rt.fastFollowMu.Lock()
|
||
defer rt.fastFollowMu.Unlock()
|
||
|
||
incomingHeight := blk.Height()
|
||
|
||
// Dedup: skip blocks at or below our tracked finalized height.
|
||
if incomingHeight <= rt.fastFollowHeight {
|
||
if !rt.config.Logger.IsZero() {
|
||
rt.config.Logger.Debug("follow: block at/below tracked height, skipping",
|
||
log.Stringer("blockID", blockID),
|
||
log.Uint64("incomingHeight", incomingHeight),
|
||
log.Uint64("trackedHeight", rt.fastFollowHeight))
|
||
}
|
||
return blk, nil
|
||
}
|
||
|
||
// Track the verified block in consensus + pending and record OUR signed
|
||
// accept vote toward its cert. castAndBroadcastVote returns after the vote
|
||
// is queued for all validators. The block is NOT accepted here — finality
|
||
// awaits the cert.
|
||
rt.followVerifiedBlock(ctx, blk, fromNodeID)
|
||
return blk, nil
|
||
}
|
||
|
||
// followVerifiedBlock tracks a verified gossip block and casts+broadcasts this
|
||
// node's signed accept vote to ALL validators. It does NOT Accept the block:
|
||
// acceptance is gated on a verified α-of-K QuorumCert (handleIncomingCert).
|
||
//
|
||
// The caller holds rt.fastFollowMu.
|
||
func (rt *Runtime) followVerifiedBlock(ctx context.Context, blk block.Block, fromNodeID ids.NodeID) {
|
||
blockID := blk.ID()
|
||
childEpoch := pChainHeightOf(blk) // epoch for the weighted set (MEDIUM-1)
|
||
|
||
// RECEIVE-SIDE EPOCH GATE (HIGH-1, predicate a — monotonicity): refuse to
|
||
// track or vote for a gossiped block whose stamped P-chain epoch height
|
||
// REGRESSES below its parent's recorded epoch. The proposer's build-side
|
||
// max(currentH, parentH) is proposer-only; a Byzantine proposer skips it and
|
||
// stamps a stale H_old (a past epoch where its departed coalition held ≥⅔,
|
||
// signed with leaked old keys) to finalize a fresh block against a validator
|
||
// set the current set never approved (safety break). The parent's epoch is read
|
||
// from the engine's tracked-block ledger (EpochHeightOf) — the authoritative
|
||
// "epoch we recorded for the parent". Enforcing childEpoch ≥ parentEpoch here,
|
||
// against the block's REAL parent (parentID is the inner block's own parent,
|
||
// which the cert also binds — the attacker cannot decouple them), means a
|
||
// chain's epoch can only move forward, so the far-past attack is closed and
|
||
// safety reduces to current-set BFT. The recency UPPER bound (absurd-future H)
|
||
// is enforced at the node boundary that holds the live P-chain height
|
||
// (pChainHeightVM.ParseBlock), so the two predicates jointly bound the epoch to
|
||
// [parentEpoch, localCurrentH+slack]. A missing parent (not yet tracked) leaves
|
||
// nothing to regress against and is admitted: the far-past attack needs a stale
|
||
// epoch BELOW the parent's, which is only meaningful once the parent is tracked;
|
||
// an orphan with no tracked parent cannot extend finalized history anyway.
|
||
// One predicate, both doors. Open-coding it here left the gossip door checking
|
||
// only a TRACKED parent while the catch-up door grew a finalized-epoch floor
|
||
// for the untracked case; the same block admitted here then reached VM.Accept.
|
||
if childEpoch, anchor, bad := rt.epochRegresses(blk); bad {
|
||
if rt.config.Logger != nil && !rt.config.Logger.IsZero() {
|
||
rt.config.Logger.Warn("follow: REFUSED block — P-chain epoch regresses below its anchor (far-past epoch attack)",
|
||
log.Stringer("blockID", blockID),
|
||
log.Stringer("parentID", blk.ParentID()),
|
||
log.Uint64("childEpoch", childEpoch),
|
||
log.Uint64("anchorEpoch", anchor),
|
||
log.Stringer("from", fromNodeID))
|
||
}
|
||
return
|
||
}
|
||
|
||
// AUTO-RECOVERY (the behind-follower self-heal): if this block's PARENT is one
|
||
// we do not have — not Empty, not our finalized tip, not tracked/known — then
|
||
// we are BEHIND. The child is an orphan we cannot finalize (the per-height
|
||
// guard requires parent==finalizedTip) and re-polling it would be pure spam to
|
||
// peers who are ahead. Instead fetch the missing chain via the catch-up seam;
|
||
// the fetched ancestors arrive back through HandleIncomingBlock, fill the gap,
|
||
// and the frontier reconciles — no manual snapshot reset. The fetch is
|
||
// idempotent + throttled in the engine (claimCatchupLocked). We still track the
|
||
// orphan below so it finalizes the moment its parent lands.
|
||
rt.requestCatchup(blk.ParentID(), fromNodeID)
|
||
|
||
consensusBlock := &Block{
|
||
id: blockID,
|
||
parentID: blk.ParentID(),
|
||
height: blk.Height(),
|
||
timestamp: blk.Timestamp().Unix(),
|
||
data: blk.Bytes(),
|
||
pChainHeight: childEpoch,
|
||
}
|
||
setCanonicalFromVM(consensusBlock, blk) // stamp the inner execution commitment
|
||
|
||
// Add to consensus tracking (idempotent: AddBlock errors if already present).
|
||
_ = rt.Transitive.consensus.AddBlock(ctx, consensusBlock)
|
||
|
||
rt.Transitive.mu.Lock()
|
||
// Only presence matters: an already-tracked block keeps the entry it has,
|
||
// and a new one gets a fresh PendingBlock. The looked-up value was never
|
||
// read (ineffassign), so it is not bound.
|
||
if _, exists := rt.Transitive.pendingBlocks[blockID]; !exists {
|
||
rt.Transitive.pendingBlocks[blockID] = &PendingBlock{
|
||
ConsensusBlock: consensusBlock,
|
||
VMBlock: blk,
|
||
ProposedAt: time.Now(),
|
||
VoteCount: 0,
|
||
Decided: false,
|
||
IsOwnProposal: false,
|
||
}
|
||
}
|
||
signer := rt.Transitive.voteSigner
|
||
verifier := rt.Transitive.voteVerifier
|
||
rt.Transitive.mu.Unlock()
|
||
|
||
// ARRIVAL-ORDER EPOCH BOUND. This block is now a TRACKED parent. Any orphan child
|
||
// admitted earlier — before this parent existed, so with nothing to regress against
|
||
// — must be re-read against it now, and dropped if its epoch fell below. The gate
|
||
// above bounds a child against a parent already tracked; this bounds a parent
|
||
// against children already tracked, closing the door the sender opens by delivering
|
||
// the child first (see epochRegresses).
|
||
rt.enforceChildEpochBound(blockID)
|
||
|
||
// THE SELF-HEAL DRAIN: this block is now tracked, so replay any votes a peer
|
||
// parked for it before its bytes arrived (the gossip race handleVote buffered).
|
||
// Each parked vote re-enters handleVote via the channel — now with the block
|
||
// tracked — and is signature-verified exactly as a live vote. This is what
|
||
// turns the former wedge (vote-before-block dropped forever) into finality:
|
||
// the fetched/late block lands here and its buffered α-of-K votes complete the
|
||
// quorum. Drain after unlock — drainBufferedVotes takes the engine lock.
|
||
rt.Transitive.drainBufferedVotes(blockID)
|
||
|
||
// CONVERGENCE (the upstream voter.go: SetPreference(Consensus.Preference())
|
||
// after every poll): steer the inner VM to build on the engine's preferred BUILD
|
||
// tip — the deepest verified block — now that this gossiped block is tracked. Without
|
||
// it the VM keeps building on the last FINALIZED block, so when a proposer is down
|
||
// every validator builds its own competing sibling at the same height, the α-of-K
|
||
// votes split across the siblings, no cert assembles, and the chain HALTS. Steering
|
||
// to the verified tip makes validators build H+1 on top of one verified block at H
|
||
// and converge. Build hint only (Preference is not a finality decision); best effort.
|
||
// SINGLE-STORE INVARIANT (defect #2): steer only at a tip the VM HOLDS.
|
||
// A validator that fell behind has a DAG tip ABOVE its own frontier; steering the
|
||
// proposervm at an unheld id leaves it on its prior preference (at worst lastAccepted)
|
||
// while BuildBlock spams "failed to fetch preferred block" and the node never builds
|
||
// again (see node/vms/proposervm/vm.go SetPreference, defect #1). The resolution lives in
|
||
// Transitive.HeldBuildTip so every steer site answers it the same way; the catch-up
|
||
// path (#3/#5) pulls the gap and a later receipt re-runs this steer once the tip lands.
|
||
if tip := rt.Transitive.HeldBuildTip(ctx, rt.config.VM, ids.Empty); tip != ids.Empty {
|
||
_ = rt.config.VM.SetPreference(ctx, tip)
|
||
}
|
||
|
||
// Single-validator / no-signer engines do not gossip votes; nothing to do.
|
||
if signer == nil || verifier == nil {
|
||
return
|
||
}
|
||
|
||
// This node's accept vote is NOT cast on receipt. Binding our one per-height
|
||
// signature to the FIRST-SEEN block is exactly what split the vote across siblings and
|
||
// stalled the fresh-net storm. The block is now TRACKED (above); the convergence loop
|
||
// (RunSettlePass) casts this node's single vote for the settled, converged winner at
|
||
// this height — the lowest-canonical live sibling — so every honest node signs the
|
||
// SAME block and one α-of-K cert forms. Receipt only tracks + drains buffered votes +
|
||
// steers the build tip.
|
||
_ = verifier
|
||
}
|
||
|
||
// enforceChildEpochBound re-reads the receive-side epoch bound for a block that has
|
||
// just gained tracked status. A child admitted before this parent was tracked carried
|
||
// no parent to regress against; now that the parent is tracked at its own epoch, any
|
||
// such child sitting BELOW it is dropped — from the consensus tree and from the
|
||
// engine's pending indices — before a cert can resolve its set-root, pubkeys, or
|
||
// stake tally at the stale epoch. It is the arrival-ORDER door of the one epoch bound
|
||
// followVerifiedBlock and AcceptCatchupBlock already guard on arrival: the sender can
|
||
// choose to deliver the child first, but not to keep a tracked child below its tracked
|
||
// parent.
|
||
func (rt *Runtime) enforceChildEpochBound(parentID ids.ID) {
|
||
dropped := rt.Transitive.consensus.DropEpochRegressedChildren(parentID)
|
||
if len(dropped) == 0 {
|
||
return
|
||
}
|
||
t := rt.Transitive
|
||
t.mu.Lock()
|
||
for _, id := range dropped {
|
||
t.dropPendingBlockLocked(id)
|
||
delete(t.bufferedVotes, id)
|
||
delete(t.catchupRequested, id)
|
||
}
|
||
t.mu.Unlock()
|
||
}
|
||
|
||
// emitConvergedVote casts this node's SINGLE per-height accept vote for the
|
||
// deterministically-converged winner at the (height, parentID) fork slot: the
|
||
// lowest-canonical live sibling (convergedWinnerAtHeightLocked) — which may be a DIFFERENT
|
||
// block than any this node built or first-saw. Every honest node with the same tracked set
|
||
// selects the identical winner, so their one-vote-per-height signatures converge onto ONE
|
||
// block and exactly one α-of-K cert forms. It is called only from RunSettlePass, i.e. after
|
||
// the slot's settle window has elapsed (the sibling set has gossiped in). The height-only
|
||
// vote-once guard (reserveSlotForSign) makes it idempotent: at most one signature per height.
|
||
func (rt *Runtime) emitConvergedVote(_ context.Context, height uint64, parentID ids.ID) {
|
||
t := rt.Transitive
|
||
if t.voteSigner == nil || t.voteVerifier == nil {
|
||
return // single-validator / no vote crypto: finality via the 1-of-1 path, no gossip
|
||
}
|
||
// TARGET SELECTION. Normal path: the deterministically-converged winner at this fork
|
||
// slot. CLONE-RECOVERY path (v4 vote-guard fallback): if we have ALREADY bound our
|
||
// signature at this height, our target is the DURABLE committedSlot[H] canonical, and we
|
||
// re-sign it IFF our own vote is missing from its cert set. That is the frozen-clone
|
||
// artifact — after a mass-restart from a mid-vote snapshot, committedSlot[H] is re-seeded
|
||
// but certVotes is EMPTY, because what is persisted is the binding and not the
|
||
// signature. An unconditional "already signed → return" would suppress this node's vote
|
||
// for that height forever, and the height would never reach α-of-K. Re-sign the DURABLE
|
||
// binding ONLY (never a fresh
|
||
// winner — HARD RULE); reserveSlotForSign below admits the idempotent same-canonical
|
||
// re-sign and REFUSES any conflicting sibling, so this can never double-finalize.
|
||
bound, alreadySigned := t.committedCanonical(height)
|
||
|
||
t.mu.Lock()
|
||
var winnerID ids.ID
|
||
var pending *PendingBlock
|
||
if alreadySigned {
|
||
// CLONE-RECOVERY re-sign. `bound` is the DURABLE committedSlot[H] canonical, which
|
||
// is the INNER execution id (reserveSlotForSign stores slotCanonical(pos)), while
|
||
// pendingBlocks is OUTER-keyed. For a proposervm-wrapped block inner != outer, so
|
||
// `bound` is not a key into pendingBlocks and looking it up there finds nothing —
|
||
// the two ids coincide only on bare blocks. Resolve the tracked OUTER wrapper
|
||
// carrying that canonical and re-sign THAT: still the durable binding, so
|
||
// reserveSlotForSign's conflicting-sibling refusal below keeps it
|
||
// equivocation-safe.
|
||
pending, winnerID = t.pendingByCanonicalLocked(bound)
|
||
} else {
|
||
// Every live sibling counts, abandoned or not. Abandonment is a per-node
|
||
// clock decision, so excluding those siblings would let two nodes pick a
|
||
// different lowest-canonical winner and their converged prevotes would never
|
||
// align. Counting all of them keeps the winner globally identical, so α
|
||
// converged votes land on one block and a single cert forms. No vote is
|
||
// manufactured and the α-of-K cert still gates acceptance.
|
||
w, _, ok := t.convergedWinnerAtHeightLocked(height, parentID)
|
||
if !ok {
|
||
t.mu.Unlock()
|
||
return
|
||
}
|
||
winnerID = w
|
||
pending = t.pendingBlocks[winnerID]
|
||
}
|
||
if pending == nil || pending.Decided {
|
||
t.mu.Unlock()
|
||
return
|
||
}
|
||
// Suppress the normal re-emit: if we already signed AND our vote is present in the cert
|
||
// set, there is nothing to do. Only a MISSING self-vote at an already-bound height (the
|
||
// clone artifact) falls through to re-contribute it.
|
||
if _, selfVoted := pending.certVotes[t.nodeID]; alreadySigned && selfVoted {
|
||
t.mu.Unlock()
|
||
return
|
||
}
|
||
pos := t.blockPositionLocked(pending, winnerID)
|
||
signer := t.voteSigner
|
||
nodeID := t.nodeID
|
||
chainID := t.chainID
|
||
t.mu.Unlock()
|
||
|
||
// One signature per HEIGHT (height-only non-equivocation). A conflicting canonical at
|
||
// an already-signed height is refused here — the atomic backstop for the race where
|
||
// two EmitVote calls target this height concurrently.
|
||
if !t.reserveSlotForSign(pos.Height, slotCanonical(pos)) {
|
||
return
|
||
}
|
||
|
||
message := CanonicalVoteMessage(pos)
|
||
sig, err := signer.SignVote(message)
|
||
if err != nil {
|
||
if !rt.config.Logger.IsZero() {
|
||
rt.config.Logger.Warn("converge: failed to sign converged accept vote",
|
||
log.Stringer("blockID", winnerID), log.Err(err))
|
||
}
|
||
return
|
||
}
|
||
ownVote := Vote{
|
||
BlockID: winnerID,
|
||
NodeID: nodeID,
|
||
Accept: true,
|
||
SignedAt: time.Now(),
|
||
Signature: sig,
|
||
ParentID: pos.ParentID,
|
||
Round: pos.Round,
|
||
}
|
||
rt.Transitive.ReceiveVote(ownVote)
|
||
if qg, ok := rt.config.Gossiper.(QuorumGossiper); ok {
|
||
if voteBytes, encErr := encodeSignedVote(nodeID, sig); encErr == nil {
|
||
qg.BroadcastVote(chainID, rt.config.NetworkID, winnerID, voteBytes)
|
||
}
|
||
}
|
||
}
|
||
|
||
// RunSettlePass implements ConvergenceVoter. It sweeps every undecided, not-yet-signed
|
||
// (height, parent) fork slot whose settle window has elapsed and casts the converged
|
||
// winner's vote. This is the mechanism that breaks a fresh-net storm: no node bound its
|
||
// vote at build/receipt; one settle window later this pass makes every honest node sign
|
||
// the SAME lowest-canonical winner, so a single α-of-K cert assembles and the height
|
||
// finalizes. The convergence loop drives it on a fast tick.
|
||
func (rt *Runtime) RunSettlePass(ctx context.Context) {
|
||
t := rt.Transitive
|
||
if t.voteSigner == nil || t.voteVerifier == nil {
|
||
return
|
||
}
|
||
t.mu.Lock()
|
||
slots := t.snapshotVotableSlotsLocked()
|
||
t.mu.Unlock()
|
||
for _, s := range slots {
|
||
rt.emitConvergedVote(ctx, s.height, s.parentID)
|
||
}
|
||
}
|
||
|
||
// epochForHeight resolves the P-chain epoch height for a tracked block at `height` (for
|
||
// prevote pubkey resolution). 0 when the height is not tracked or the chain is fixed-set.
|
||
func (rt *Runtime) epochForHeight(height uint64) uint64 {
|
||
t := rt.Transitive
|
||
t.mu.RLock()
|
||
defer t.mu.RUnlock()
|
||
for _, pb := range t.pendingBlocks {
|
||
if pb.ConsensusBlock != nil && pb.ConsensusBlock.height == height {
|
||
return t.epochHeightLocked(pb)
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// requestCatchup is the ONE catch-up TRANSPORT: "I am missing block `missingID`
|
||
// — fetch it from `from`." It is the single mechanism for runtime auto-recovery,
|
||
// shared by BOTH self-heal triggers:
|
||
// - a gossiped child whose PARENT we lack (followVerifiedBlock), and
|
||
// - a vote for a block whose BYTES we lack (the engine's requestMissing hook,
|
||
// wired to this in NewRuntime).
|
||
//
|
||
// The engine owns idempotency + rate-limiting (claimCatchupLocked: one fetch per
|
||
// missing ID per catchupCooldown, suppressed once the block is tracked/known), so
|
||
// this method only supplies the networkID the engine does not hold and performs
|
||
// the RequestAncestors round-trip. The fetched block arrives back through
|
||
// HandleIncomingBlock, where it is tracked and its buffered votes drained. nil
|
||
// Catchup / Empty missingID ⇒ no-op (claimCatchupLocked returns false). The gate
|
||
// is claimed UNDER the engine lock; the transport call is made WITHOUT it (it may
|
||
// touch the network).
|
||
func (rt *Runtime) requestCatchup(missingID ids.ID, from ids.NodeID) {
|
||
rt.Transitive.mu.Lock()
|
||
claim := rt.Transitive.claimCatchupLocked(missingID)
|
||
rt.Transitive.mu.Unlock()
|
||
if !claim {
|
||
return
|
||
}
|
||
_ = rt.config.Catchup.RequestAncestors(rt.config.ChainID, rt.config.NetworkID, missingID, from)
|
||
}
|
||
|
||
// OnImportComplete must be called after admin_importChain (RLP import) completes.
|
||
// This reconciles the consensus engine's state with the VM's actual state after import.
|
||
//
|
||
// The problem this solves:
|
||
// - RLP import updates the EVM state database directly
|
||
// - But the consensus engine still thinks lastAccepted is the old block
|
||
// - This causes transactions to timeout (engine builds on wrong parent)
|
||
// - And causes "chains not bootstrapped" errors on node restart
|
||
//
|
||
// This method:
|
||
// 1. Queries VM.LastAccepted() to get the current chain tip after import
|
||
// 2. Updates consensus.finalizedTip to match
|
||
// 3. Updates VM preference to build on the new tip
|
||
// 4. Marks consensus as bootstrapped
|
||
//
|
||
// Usage in EVM admin API after successful import:
|
||
//
|
||
// if err := rt.OnImportComplete(ctx); err != nil {
|
||
// log.Warn("failed to sync consensus after import", "error", err)
|
||
// }
|
||
//
|
||
// This is idempotent - safe to call even if import didn't change state.
|
||
func (rt *Runtime) OnImportComplete(ctx context.Context) error {
|
||
logger := rt.config.Logger
|
||
hasLogger := logger != nil && !logger.IsZero()
|
||
|
||
if rt.config.VM == nil {
|
||
if hasLogger {
|
||
logger.Warn("OnImportComplete: VM is nil, cannot sync state")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Step 1: Query VM for current last accepted block
|
||
lastAcceptedID, err := rt.config.VM.LastAccepted(ctx)
|
||
if err != nil {
|
||
if hasLogger {
|
||
logger.Warn("OnImportComplete: failed to get last accepted from VM",
|
||
log.Err(err))
|
||
}
|
||
return err
|
||
}
|
||
|
||
// Step 2: Get block details (height) for consensus state
|
||
var height uint64
|
||
if lastAcceptedID != ids.Empty {
|
||
blk, err := rt.config.VM.GetBlock(ctx, lastAcceptedID)
|
||
if err != nil {
|
||
if hasLogger {
|
||
logger.Warn("OnImportComplete: failed to get block details",
|
||
log.Stringer("blockID", lastAcceptedID),
|
||
log.Err(err))
|
||
}
|
||
// Continue with height 0 - consensus can recover
|
||
} else {
|
||
height = blk.Height()
|
||
}
|
||
}
|
||
|
||
// Step 3: Update VM preference to build on current tip
|
||
// This is critical: without this, the VM's Preferred() returns old block
|
||
// while GetLastAccepted() returns the imported block, causing state mismatch
|
||
if err := rt.config.VM.SetPreference(ctx, lastAcceptedID); err != nil {
|
||
if hasLogger {
|
||
logger.Warn("OnImportComplete: failed to set VM preference",
|
||
log.Stringer("blockID", lastAcceptedID),
|
||
log.Err(err))
|
||
}
|
||
// Non-fatal: continue with consensus sync
|
||
}
|
||
|
||
// Step 4: Sync consensus engine state
|
||
if err := rt.Transitive.SyncState(ctx, lastAcceptedID, height); err != nil {
|
||
if hasLogger {
|
||
logger.Warn("OnImportComplete: failed to sync consensus state",
|
||
log.Stringer("blockID", lastAcceptedID),
|
||
log.Uint64("height", height),
|
||
log.Err(err))
|
||
}
|
||
return err
|
||
}
|
||
|
||
if hasLogger {
|
||
logger.Info("OnImportComplete: consensus state synced with VM",
|
||
log.Stringer("lastAcceptedID", lastAcceptedID),
|
||
log.Uint64("height", height))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// SyncStateFromVM queries the VM for its current state and syncs the consensus
|
||
// engine to match. This is a lower-level version of OnImportComplete that can
|
||
// be called without a full Runtime (e.g., from standalone syncer usage).
|
||
//
|
||
// Returns the synced block ID and height, or error.
|
||
func SyncStateFromVM(ctx context.Context, vm BlockBuilder, consensus *Transitive) (ids.ID, uint64, error) {
|
||
if vm == nil {
|
||
return ids.Empty, 0, nil
|
||
}
|
||
|
||
// Get last accepted from VM
|
||
lastAcceptedID, err := vm.LastAccepted(ctx)
|
||
if err != nil {
|
||
return ids.Empty, 0, err
|
||
}
|
||
|
||
// Get height
|
||
var height uint64
|
||
if lastAcceptedID != ids.Empty {
|
||
blk, err := vm.GetBlock(ctx, lastAcceptedID)
|
||
if err == nil {
|
||
height = blk.Height()
|
||
}
|
||
}
|
||
|
||
// Set preference (non-fatal if this fails)
|
||
_ = vm.SetPreference(ctx, lastAcceptedID)
|
||
|
||
// Sync consensus
|
||
if consensus != nil {
|
||
if err := consensus.SyncState(ctx, lastAcceptedID, height); err != nil {
|
||
return lastAcceptedID, height, err
|
||
}
|
||
}
|
||
|
||
return lastAcceptedID, height, nil
|
||
}
|
||
|
||
// AppliedHead reports the height the VM has actually APPLIED, which is the height
|
||
// catch-up has to aim at.
|
||
//
|
||
// FinalizedLedger reports what CONSENSUS has certified, and the two diverge: the
|
||
// consensus ledger and the VM persist separately, so a node that stops uncleanly can
|
||
// come back with certified finality above the block its VM last committed. Finality
|
||
// then cannot advance — the fail-closed guard correctly refuses to move past applied
|
||
// state — while catch-up, steering by the ledger, asks peers for the height ABOVE the
|
||
// gap and never fetches the blocks the VM is missing. The ledger can lead the VM by
|
||
// thousands of blocks, and in that state every incoming cert is refused for naming a
|
||
// parent the VM has not applied ("expected accepted block to have parent …:H but got
|
||
// …:H+gap") while no descent runs, because from the ledger's side certs are arriving
|
||
// normally.
|
||
//
|
||
// A block can only be applied on top of the one before it, so whichever of the two is
|
||
// LOWER is what the node actually needs next.
|
||
func (rt *Runtime) AppliedHead(ctx context.Context) (ids.ID, uint64, error) {
|
||
return rt.localLastAccepted(ctx)
|
||
}
|