mirror of
https://github.com/luxfi/node.git
synced 2026-10-30 15:24:05 +00:00
Local, unreleased; go.mod still pins luxfi/consensus v1.36.94. Co-authored-by: Hanzo Dev <[email protected]>
5689 lines
250 KiB
Go
5689 lines
250 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||
// See the file LICENSE for licensing terms.
|
||
|
||
package chains
|
||
|
||
import (
|
||
nodeconsensus "github.com/luxfi/node/consensus"
|
||
// xvm "github.com/luxfi/node/vms/xvm" // Unused
|
||
"context"
|
||
"crypto"
|
||
"encoding/binary"
|
||
"errors"
|
||
"fmt"
|
||
"github.com/go-json-experiment/json"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
gatomic "sync/atomic"
|
||
"time"
|
||
|
||
"github.com/luxfi/database"
|
||
"github.com/luxfi/node/server/http"
|
||
"github.com/luxfi/node/service/health"
|
||
"github.com/luxfi/node/service/metrics"
|
||
"github.com/luxfi/vm"
|
||
"github.com/luxfi/vm/chains/atomic"
|
||
|
||
// "github.com/luxfi/database/zapdb" // Unused
|
||
dbmanager "github.com/luxfi/database/manager"
|
||
"github.com/luxfi/runtime"
|
||
|
||
// "github.com/luxfi/database/meterdb" // Unused
|
||
// "github.com/luxfi/database/prefixdb" // Unused
|
||
"github.com/luxfi/ids"
|
||
"github.com/luxfi/node/mesh"
|
||
"github.com/luxfi/node/mesh/peer"
|
||
"github.com/luxfi/node/message"
|
||
"github.com/luxfi/node/proto/p2p"
|
||
// vmpb "github.com/luxfi/node/proto/vm" // Removed - using vm.Ready instead
|
||
"github.com/luxfi/warp"
|
||
|
||
// "github.com/luxfi/consensus/engine/dag/bootstrap/queue" // Unused
|
||
// "github.com/luxfi/consensus/engine/dag/state" // Unused
|
||
// "github.com/luxfi/consensus/engine/vertex" // Unused
|
||
|
||
// "github.com/luxfi/consensus/core/tracker"
|
||
consensusconfig "github.com/luxfi/consensus/config"
|
||
consensuschain "github.com/luxfi/consensus/engine/chain"
|
||
"github.com/luxfi/consensus/engine/chain/statesync"
|
||
"github.com/luxfi/consensus/engine/chain/summary"
|
||
consensusdag "github.com/luxfi/consensus/engine/dag"
|
||
"github.com/luxfi/vm/chain"
|
||
|
||
// "github.com/luxfi/vm/chain/syncer"
|
||
"github.com/luxfi/consensus/networking/handler"
|
||
// "github.com/luxfi/consensus/core/router" // Deprecated - using local ChainRouter interface instead
|
||
// "github.com/luxfi/consensus/networking/sender" // Unused after dead code cleanup
|
||
"github.com/luxfi/consensus/networking/timeout"
|
||
"github.com/luxfi/constants"
|
||
"github.com/luxfi/container/buffer"
|
||
"github.com/luxfi/crypto/bls"
|
||
"github.com/luxfi/crypto/mldsa"
|
||
"github.com/luxfi/filesystem/perms"
|
||
"github.com/luxfi/log"
|
||
"github.com/luxfi/math/set"
|
||
utilmetric "github.com/luxfi/metric"
|
||
"github.com/luxfi/node/network"
|
||
"github.com/luxfi/node/staking"
|
||
"github.com/luxfi/node/trace"
|
||
"github.com/luxfi/node/upgrade"
|
||
"github.com/luxfi/node/vms"
|
||
validators "github.com/luxfi/validators"
|
||
version "github.com/luxfi/version"
|
||
"github.com/luxfi/vm/fx"
|
||
|
||
"github.com/luxfi/utxo/bls12381fx"
|
||
"github.com/luxfi/utxo/ed25519fx"
|
||
"github.com/luxfi/utxo/mldsafx"
|
||
"github.com/luxfi/utxo/nftfx"
|
||
|
||
"github.com/luxfi/node/vms/proposervm"
|
||
"github.com/luxfi/utxo/propertyfx"
|
||
"github.com/luxfi/utxo/schnorrfx"
|
||
"github.com/luxfi/utxo/secp256k1fx"
|
||
"github.com/luxfi/utxo/secp256r1fx"
|
||
"github.com/luxfi/utxo/slhdsafx"
|
||
|
||
timetracker "github.com/luxfi/node/mesh/tracker"
|
||
)
|
||
|
||
const (
|
||
ChainLabel = "chain"
|
||
|
||
defaultChannelSize = 1
|
||
initialQueueSize = 3
|
||
|
||
// vmStartupTimeout bounds each VM startup/lifecycle operation (Initialize,
|
||
// Linearize, SetState, CreateHandlers, router AddChain, state-sync). It must
|
||
// be generous enough to cover a cold EVM "Regenerate historical state"
|
||
// pass after an unclean shutdown, which runs for minutes on a C-Chain
|
||
// with real history and which the previous hardcoded 30s budget blew —
|
||
// the context was cancelled mid-init, so the VM was marked failed and its
|
||
// C-Chain route never registered (a 404 until the next restart).
|
||
// Bounded so a genuinely hung VM still surfaces rather than hanging forever.
|
||
vmStartupTimeout = 10 * time.Minute
|
||
|
||
luxNamespace = constants.PlatformName + utilmetric.NamespaceSeparator + "lux"
|
||
handlerNamespace = constants.PlatformName + utilmetric.NamespaceSeparator + "handler"
|
||
meterchainvmNamespace = constants.PlatformName + utilmetric.NamespaceSeparator + "meterchainvm"
|
||
meterdagvmNamespace = constants.PlatformName + utilmetric.NamespaceSeparator + "meterdagvm"
|
||
proposervmNamespace = constants.PlatformName + utilmetric.NamespaceSeparator + "proposervm"
|
||
p2pNamespace = constants.PlatformName + utilmetric.NamespaceSeparator + "p2p"
|
||
chainNamespace = constants.PlatformName + utilmetric.NamespaceSeparator + "linear"
|
||
stakeNamespace = constants.PlatformName + utilmetric.NamespaceSeparator + "stake"
|
||
)
|
||
|
||
// ChainRouter is the interface for routing messages to chains.
|
||
// This is defined here to avoid circular imports with the node package.
|
||
type ChainRouter interface {
|
||
AddChain(ctx context.Context, chainID ids.ID, handler handler.Handler)
|
||
}
|
||
|
||
var (
|
||
// corely shared VM DB prefix
|
||
VMDBPrefix = []byte("vm")
|
||
|
||
// Bootstrapping prefixes for LinearizableVMs
|
||
VertexDBPrefix = []byte("vertex")
|
||
VertexBootstrappingDBPrefix = []byte("vertex_bs")
|
||
TxBootstrappingDBPrefix = []byte("tx_bs")
|
||
BlockBootstrappingDBPrefix = []byte("interval_block_bs")
|
||
|
||
// Bootstrapping prefixes for ChainVMs
|
||
ChainBootstrappingDBPrefix = []byte("interval_bs")
|
||
|
||
errUnknownVMType = errors.New("the vm should have type lux.DAGVM or chain.ChainVM")
|
||
errCreatePlatformVM = errors.New("attempted to create a chain running the PlatformVM")
|
||
errNotBootstrapped = errors.New("chains not bootstrapped")
|
||
|
||
// fxs lists every Feature eXtension factory the node knows how to load
|
||
// when a chain genesis references it. Must stay in sync with the X-Chain
|
||
// FxIDs[] block in genesis/builder/builder.go: any fx ID present there
|
||
// MUST be registered here, otherwise chain init fails with "fx ... not
|
||
// found" at startup. Keep the order and grouping mirroring genesis to
|
||
// make drift obvious in review.
|
||
fxs = map[ids.ID]fx.Factory{
|
||
// Legacy / classical
|
||
secp256k1fx.ID: &secp256k1fx.Factory{},
|
||
nftfx.ID: &nftfx.Factory{},
|
||
propertyfx.ID: &propertyfx.Factory{},
|
||
// Post-quantum (FIPS 203/204/205 family)
|
||
mldsafx.ID: &mldsafx.Factory{},
|
||
slhdsafx.ID: &slhdsafx.Factory{},
|
||
// EdDSA / Schnorr / NIST P-256
|
||
ed25519fx.ID: &ed25519fx.Factory{},
|
||
secp256r1fx.ID: &secp256r1fx.Factory{},
|
||
schnorrfx.ID: &schnorrfx.Factory{},
|
||
// Pairing-friendly curve
|
||
bls12381fx.ID: &bls12381fx.Factory{},
|
||
}
|
||
|
||
_ Manager = (*manager)(nil)
|
||
)
|
||
|
||
// quasarExportVM is the OPTIONAL two-tier-consensus (v1.36) export sink a VM may
|
||
// expose: the consensus engine pushes each Quasar (⅔-by-stake) EXPORT-FINAL
|
||
// frontier advance in, and re-seeds from the VM's durable height on boot, so the
|
||
// VM's `finalized`/`safe` tags and cross-chain export gate track ⅔-stake
|
||
// finality instead of the reorgable Nova accept tip. NOT part of chain.ChainVM —
|
||
// generic VMs never implement it and run Nova-only. For a plugin VM the concrete
|
||
// implementation is in another process; the rpcchainvm client carries these
|
||
// across the boundary and reports whether the plugin advertised the capability
|
||
// via SupportsQuasarExport (see the wiring in createChain).
|
||
type quasarExportVM interface {
|
||
SetLastQuasarFinalized(uint64)
|
||
LastQuasarHeight() uint64
|
||
}
|
||
|
||
// Manager manages the chains running on this node.
|
||
// It can:
|
||
// - Create a chain
|
||
// - Add a registrant. When a chain is created, each registrant calls
|
||
// RegisterChain with the new chain as the argument.
|
||
// - Manage the aliases of chains
|
||
type Manager interface {
|
||
ids.Aliaser
|
||
|
||
// Queues a chain to be created in the future after chain creator is unblocked.
|
||
// This is only called from the P-chain thread to create other chains
|
||
// Queued chains are created only after P-chain is bootstrapped.
|
||
// This assumes only chains in tracked chains are queued.
|
||
QueueChainCreation(ChainParameters)
|
||
|
||
// Add a registrant [r]. Every time a chain is
|
||
// created, [r].RegisterChain([new chain]) is called.
|
||
AddRegistrant(Registrant)
|
||
|
||
// Given an alias, return the ID of the chain associated with that alias
|
||
Lookup(string) (ids.ID, error)
|
||
|
||
// Returns true iff the chain with the given ID exists and is finished bootstrapping
|
||
IsBootstrapped(ids.ID) bool
|
||
|
||
// Starts the chain creator with the initial platform chain parameters, must
|
||
// be called once.
|
||
StartChainCreator(platformChain ChainParameters) error
|
||
|
||
// GetChains returns info about all locally running chains.
|
||
GetChains() []ChainInfo
|
||
|
||
// RetryPendingChains re-queues chains that were waiting for the specified VM.
|
||
// This is called when a VM is hot-loaded via admin.loadVMs.
|
||
RetryPendingChains(vmID ids.ID) int
|
||
|
||
Shutdown()
|
||
}
|
||
|
||
// ChainParameters defines the chain being created
|
||
type ChainParameters struct {
|
||
// The ID of the blockchain being created.
|
||
ID ids.ID
|
||
// ID of the Net that validates this blockchain.
|
||
ChainID ids.ID
|
||
// The genesis data of this blockchain's ledger.
|
||
GenesisData []byte
|
||
// The ID of the vm this blockchain is running.
|
||
VMID ids.ID
|
||
// The IDs of the feature extensions this blockchain is running.
|
||
FxIDs []ids.ID
|
||
// Invariant: Only used when [ID] is the P-chain ID.
|
||
CustomBeacons validators.Manager
|
||
// Name of the chain (used for HTTP routing alias, e.g., /v1/chain/zoo/rpc)
|
||
Name string
|
||
}
|
||
|
||
// ChainInfo is the public view of a locally running chain.
|
||
// Returned by Manager.GetChains() and exposed via info.GetChains API.
|
||
type ChainInfo struct {
|
||
ID ids.ID `json:"id"`
|
||
Name string `json:"name"`
|
||
VMID ids.ID `json:"vmID"`
|
||
Bootstrapped bool `json:"bootstrapped"`
|
||
}
|
||
|
||
type chainInfo struct {
|
||
Name string
|
||
VMID ids.ID
|
||
Runtime *runtime.Runtime
|
||
VM interface{} // Use interface{} since VM implementations vary
|
||
Handler handler.Handler
|
||
Engine Engine // Added to handle Start/Stop operations
|
||
}
|
||
|
||
// Engine represents a consensus engine
|
||
type Engine interface {
|
||
Start(context.Context, bool) error
|
||
StopWithError(context.Context, error) error
|
||
Context() context.Context
|
||
}
|
||
|
||
// validatorStateWrapper wraps validators.State to implement interfaces.ValidatorState
|
||
|
||
// noopValidatorState provides a no-op implementation of validators.State for non-staking nodes
|
||
type noopValidatorState struct{}
|
||
|
||
func (n *noopValidatorState) GetValidatorSet(ctx context.Context, height uint64, chainID ids.ID) (map[ids.NodeID]*validators.GetValidatorOutput, error) {
|
||
return make(map[ids.NodeID]*validators.GetValidatorOutput), nil
|
||
}
|
||
|
||
func (n *noopValidatorState) GetCurrentValidators(ctx context.Context, height uint64, chainID ids.ID) (map[ids.NodeID]*validators.GetValidatorOutput, error) {
|
||
return make(map[ids.NodeID]*validators.GetValidatorOutput), nil
|
||
}
|
||
|
||
func (n *noopValidatorState) GetCurrentHeight(ctx context.Context) (uint64, error) {
|
||
return 0, nil
|
||
}
|
||
|
||
func (n *noopValidatorState) GetMinimumHeight(ctx context.Context) (uint64, error) {
|
||
return 0, nil
|
||
}
|
||
|
||
func (n *noopValidatorState) GetChainID(netID ids.ID) (ids.ID, error) {
|
||
return ids.Empty, nil
|
||
}
|
||
|
||
func (n *noopValidatorState) GetNetworkID(chainID ids.ID) (ids.ID, error) {
|
||
return ids.Empty, nil
|
||
}
|
||
|
||
func (n *noopValidatorState) GetWarpValidatorSets(ctx context.Context, heights []uint64, chainIDs []ids.ID) (map[ids.ID]map[uint64]*validators.WarpSet, error) {
|
||
result := make(map[ids.ID]map[uint64]*validators.WarpSet)
|
||
for _, chainID := range chainIDs {
|
||
result[chainID] = make(map[uint64]*validators.WarpSet)
|
||
for _, height := range heights {
|
||
result[chainID][height] = &validators.WarpSet{
|
||
Height: height,
|
||
Validators: make(map[ids.NodeID]*validators.WarpValidator),
|
||
}
|
||
}
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func (n *noopValidatorState) GetWarpValidatorSet(ctx context.Context, height uint64, chainID ids.ID) (*validators.WarpSet, error) {
|
||
return &validators.WarpSet{
|
||
Height: height,
|
||
Validators: make(map[ids.NodeID]*validators.WarpValidator),
|
||
}, nil
|
||
}
|
||
|
||
// getValidatorState returns the validator state or a no-op implementation if nil
|
||
func getValidatorState(state validators.State) validators.State {
|
||
if state != nil {
|
||
return state
|
||
}
|
||
return &noopValidatorState{}
|
||
}
|
||
|
||
// quorumValidatorStateLive reports whether a HEIGHT-INDEXED validator state has
|
||
// actually been published for a K>1 quorum chain. A nil state means the P-Chain
|
||
// never published its validators.State into the manager — getValidatorState would
|
||
// then supply the no-op State, whose GetValidatorSet is empty at every height, so
|
||
// the ⅔-by-stake tally is 0 and VerifyWeighted fails closed FOREVER (a silent
|
||
// permanent finality stall). This predicate is the fail-closed guard: a K>1
|
||
// chain whose state is not live must REFUSE TO START loudly rather than run
|
||
// with the no-op State. It is a pure function so the guard is unit-
|
||
// testable without building a whole chain (quorum_guard_node_test.go).
|
||
func quorumValidatorStateLive(state validators.State) bool {
|
||
return state != nil
|
||
}
|
||
|
||
// createWarpSigner creates a warp.Signer from a bls.Signer
|
||
func createWarpSigner(sk bls.Signer, networkID uint32, chainID ids.ID) warp.Signer {
|
||
if sk == nil {
|
||
return nil
|
||
}
|
||
return warp.NewSigner(sk, networkID, chainID)
|
||
}
|
||
|
||
// ChainConfig is configuration settings for the current execution.
|
||
// [Config] is the user-provided config blob for the chain.
|
||
// [Upgrade] is a chain-specific blob for coordinating upgrades.
|
||
type ChainConfig struct {
|
||
Config []byte
|
||
Upgrade []byte
|
||
}
|
||
|
||
type ManagerConfig struct {
|
||
SybilProtectionEnabled bool
|
||
|
||
// ConsensusOverrides are the consensus parameters the operator set
|
||
// explicitly; nil (or all-nil fields) leaves the networkID default alone.
|
||
ConsensusOverrides *ConsensusOverrides
|
||
StakingTLSSigner crypto.Signer
|
||
StakingTLSCert *staking.Certificate
|
||
// Strict-PQ proposer identity. When StakingMLDSASigner is non-nil, chains wrap
|
||
// their proposervm with ML-DSA-65 block signing so the signed block's
|
||
// Proposer() matches the ML-DSA-keyed validator set. Nil ⇒ classical TLS-leaf.
|
||
StakingMLDSASigner *mldsa.PrivateKey
|
||
StakingMLDSAPub []byte
|
||
// ProposerWindowDuration overrides the proposervm proposer-slot spacing (0 ⇒
|
||
// the 5s mainnet default). Small local/dev nets set it low for fast cadence.
|
||
ProposerWindowDuration time.Duration
|
||
// ProposerMinBlockDelay overrides the proposervm minimum block delay (0 ⇒ the
|
||
// 1s default). High-throughput / DEX nets set it low (e.g. 1ms).
|
||
ProposerMinBlockDelay time.Duration
|
||
StakingBLSKey bls.Signer
|
||
TracingEnabled bool
|
||
// Must not be used unless [TracingEnabled] is true as this may be nil.
|
||
Tracer trace.Tracer
|
||
Log log.Logger
|
||
LogFactory log.Factory
|
||
VMManager vms.Manager // Manage mappings from vm ID --> vm
|
||
BlockAcceptorGroup nodeconsensus.AcceptorGroup
|
||
TxAcceptorGroup nodeconsensus.AcceptorGroup
|
||
VertexAcceptorGroup nodeconsensus.AcceptorGroup
|
||
DB database.Database
|
||
MsgCreator message.OutboundMsgBuilder // message creator, shared with network
|
||
Router ChainRouter // Routes incoming messages to the appropriate chain
|
||
Net mesh.Network // Sends consensus messages to other validators
|
||
Validators validators.Manager // Validators validating on this chain
|
||
NodeID ids.NodeID // The ID of this node
|
||
NetworkID uint32 // ID of the network this node is connected to
|
||
Server server.Server // Handles HTTP API calls
|
||
AtomicMemory *atomic.Memory
|
||
UTXOAssetID ids.ID
|
||
SkipBootstrap bool // Skip bootstrapping and start processing immediately
|
||
EnableAutomining bool // Enable automining in POA mode
|
||
XChainID ids.ID // ID of the X-Chain,
|
||
CChainID ids.ID // ID of the C-Chain,
|
||
DChainID ids.ID // ID of the D-Chain (DEX),
|
||
CriticalChains set.Set[ids.ID] // Chains that can't exit gracefully
|
||
|
||
// MChainID is the ID of the M-Chain (MPC), whose committed state holds the
|
||
// ownership attestations that entitle a node to a restricted chain. Empty when
|
||
// this network's genesis has no M-Chain, in which case no entitlement can be
|
||
// verified and every restricted chain refuses.
|
||
MChainID ids.ID
|
||
// RestrictedChains are the chains a node may activate only when it is entitled
|
||
// — that is, when the M-Chain holds an ownership attestation naming this node.
|
||
// A nil/empty set means no chain is restricted, so every chain behaves exactly
|
||
// as before. Critical chains are never restricted regardless of this set.
|
||
// Derived in node.go from the single VMs registry; see
|
||
// authorizeChainActivation.
|
||
RestrictedChains set.Set[ids.ID]
|
||
// NeedConsent are the chains an operator must name before this node runs
|
||
// them. Not a security control — anyone may name a chain — it records that
|
||
// running the chain is a decision rather than a default, because the chain
|
||
// costs something validating the primary network did not ask for:
|
||
// co-location with a matcher, an HSM, a GPU, a custody role.
|
||
//
|
||
// Consented are the chains the operator did name (--chains). A chain in
|
||
// NeedConsent and absent from Consented is declined even when
|
||
// --track-all-chains queued it: the activation authority is
|
||
// authorizeChainActivation, not the platformvm tracking set.
|
||
//
|
||
// Both are derived in node.go from the single VMs registry. They compose
|
||
// AND-wise with RestrictedChains where a chain is both.
|
||
NeedConsent set.Set[ids.ID]
|
||
Consented set.Set[ids.ID]
|
||
// ConsentNames names each consent-requiring chain, so a decline can say
|
||
// which chain the operator did not name rather than printing an id they
|
||
// cannot act on.
|
||
ConsentNames map[ids.ID]string
|
||
|
||
TimeoutManager timeout.Manager // Manages request timeouts when sending messages to other validators
|
||
Health health.Registerer
|
||
NetConfigs map[ids.ID]network.Config // ID -> NetConfig
|
||
ChainConfigs map[string]ChainConfig // alias -> ChainConfig
|
||
// ShutdownNodeFunc allows the chain manager to issue a request to shutdown the node
|
||
ShutdownNodeFunc func(exitCode int)
|
||
MeterVMEnabled bool // Should each VM be wrapped with a MeterVM
|
||
|
||
Metrics metrics.MultiGatherer
|
||
MeterDBMetrics metrics.MultiGatherer
|
||
|
||
FrontierPollFrequency time.Duration
|
||
ConsensusAppConcurrency int
|
||
|
||
// Max Time to spend fetching a container and its
|
||
// ancestors when responding to a GetAncestors
|
||
BootstrapMaxTimeGetAncestors time.Duration
|
||
// Max number of containers in an ancestors message sent by this node.
|
||
BootstrapAncestorsMaxContainersSent int
|
||
// This node will only consider the first [AncestorsMaxContainersReceived]
|
||
// containers in an ancestors message it receives.
|
||
BootstrapAncestorsMaxContainersReceived int
|
||
|
||
Upgrades upgrade.Config
|
||
|
||
// Tracks CPU/disk usage caused by each peer.
|
||
ResourceTracker timetracker.ResourceTracker
|
||
|
||
StateSyncBeacons []ids.NodeID
|
||
|
||
ChainDataDir string
|
||
|
||
Nets *Nets
|
||
|
||
// SecurityProfile is the chain-wide ChainSecurityProfile resolved
|
||
// from the genesis pin during node bootstrap (F102). The chain
|
||
// manager forwards a ProfileID + ProfileHash pin to every VM
|
||
// Initialize call via the VM config bytes so the rpcchainvm plugin
|
||
// (the C-Chain EVM) inherits the chain-wide posture without
|
||
// re-resolving genesis. Closes red-team finding F118.
|
||
//
|
||
// Nil under classical-compat or pre-locked-profile chains; the
|
||
// stamping pass is a no-op in that case.
|
||
SecurityProfile *consensusconfig.ChainSecurityProfile
|
||
}
|
||
|
||
// lettersFor returns the single-letter routes a chain name earns, in both
|
||
// cases. "M-Chain" gives {"M", "m"}; a name that is not <letter>-Chain gives
|
||
// nothing.
|
||
//
|
||
// Derived rather than listed because a list goes stale silently: the map this
|
||
// replaced named six letters while eleven chains were running, and nothing
|
||
// about a missing entry looked like a missing entry.
|
||
// isPrimary reports whether this chain belongs to the primary network, which
|
||
// is what entitles it to a reserved name.
|
||
func (m *manager) isPrimary(p ChainParameters) bool {
|
||
// ChainParameters.ChainID is the net that VALIDATES this chain, despite
|
||
// the name. The primary network's own id is what entitles a chain to a
|
||
// reserved name.
|
||
return p.ChainID == constants.PrimaryNetworkID
|
||
}
|
||
|
||
func lettersFor(name string) map[string]struct{} {
|
||
out := map[string]struct{}{}
|
||
if len(name) < 2 || name[1] != '-' {
|
||
return out
|
||
}
|
||
if !strings.EqualFold(name[2:], "chain") {
|
||
return out
|
||
}
|
||
c := name[:1]
|
||
if c[0] < 'A' || (c[0] > 'Z' && c[0] < 'a') || c[0] > 'z' {
|
||
return out
|
||
}
|
||
out[strings.ToUpper(c)] = struct{}{}
|
||
out[strings.ToLower(c)] = struct{}{}
|
||
return out
|
||
}
|
||
|
||
// reserved reports whether a path segment belongs to the primary network and
|
||
// may not be claimed by any other chain.
|
||
//
|
||
// Without this a chain may name itself "m" and register at /v1/chain/m while
|
||
// M-Chain sits at /v1/chain/M. HTTP paths are case-sensitive, so those are two
|
||
// routes, and a reader looking at either would not be able to tell which is
|
||
// the register that signs. The names of the primary network's chains are not
|
||
// available to anyone else, in any case, under any of the forms a route can
|
||
// take: the letter, the full name, and the VM suffix people reach for.
|
||
func reserved(segment string) bool {
|
||
seg := strings.ToLower(segment)
|
||
|
||
// Every letter, in every form a route can take. A chain is reached by its
|
||
// letter, and people reach for the other three when they know the chain by
|
||
// its full name or its VM.
|
||
for c := byte('a'); c <= 'z'; c++ {
|
||
l := string(c)
|
||
if seg == l || seg == l+"-chain" || seg == l+"chain" || seg == l+"vm" {
|
||
return true
|
||
}
|
||
}
|
||
|
||
// And the VMs by name. `mpc` is what M-Chain does and `mpcvm` is what
|
||
// implements it, so both are names a reader would take for the register
|
||
// that signs — which is exactly why neither may belong to anything else.
|
||
for _, vm := range vmNames {
|
||
if seg == vm || seg == vm+"vm" || seg == vm+"-chain" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// vmNames are the primary network's VMs, by the name each is known by. The
|
||
// suffixed forms are derived in reserved() rather than listed, so adding one
|
||
// here reserves every way of writing it.
|
||
var vmNames = []string{
|
||
"mpc", "bridge", "oracle", "relay", "dex", "graph",
|
||
"identity", "key", "quantum", "zk", "ai", "platform", "x", "evm",
|
||
}
|
||
|
||
type manager struct {
|
||
// Note: The string representation of a chain's ID is also considered to be an alias of the chain
|
||
// That is, [chainID].String() is an alias for the chain, too
|
||
ids.Aliaser
|
||
ManagerConfig
|
||
|
||
// ChainDBManager handles per-chain database instances
|
||
chainDBManager *ChainDBManager
|
||
|
||
// Those notified when a chain is created
|
||
registrants []Registrant
|
||
|
||
// queue that holds chain create requests
|
||
chainsQueue buffer.BlockingDeque[ChainParameters]
|
||
// unblocks chain creator to start processing the queue
|
||
unblockChainCreatorCh chan struct{}
|
||
// shutdown the chain creator goroutine if the queue hasn't started to be
|
||
// processed.
|
||
chainCreatorShutdownCh chan struct{}
|
||
chainCreatorExited sync.WaitGroup
|
||
|
||
// stallWindow is how long a chain may make no progress before monitorBootstrap reports on
|
||
// it. Zero ⇒ 5 minutes. A test shortens it to reach the report without sitting out a real
|
||
// window, the same way the sync driver's own attempt bound and connect deadline are pinned.
|
||
stallWindow time.Duration
|
||
|
||
// pendingVMChains tracks chains waiting for VMs to be loaded (for hot-loading).
|
||
// Key: VM ID that the chain needs
|
||
// Value: List of chain parameters waiting for this VM
|
||
pendingVMChainsLock sync.RWMutex
|
||
pendingVMChains map[ids.ID][]ChainParameters
|
||
|
||
// restrictedChainsLock guards the entitlement check's defer state.
|
||
// pendingRestrictedChains holds chains parked because the M-Chain was not yet
|
||
// bootstrapped when their activation was attempted; they are re-queued once the
|
||
// M-Chain converges (retryPendingRestrictedChains). restrictedAttempts caps
|
||
// re-attempts per chain so a never-converging M-Chain cannot park a chain
|
||
// forever. See manager_authz.go.
|
||
restrictedChainsLock sync.Mutex
|
||
pendingRestrictedChains []ChainParameters
|
||
restrictedAttempts map[ids.ID]int
|
||
|
||
chainsLock sync.Mutex
|
||
// Key: Chain's ID
|
||
// Value: The chain
|
||
chains map[ids.ID]*chainInfo
|
||
|
||
// pChainBootstrapped flips true the instant monitorBootstrap declares the P-CHAIN's initial
|
||
// sync COMPLETE (its handler's BootstrapComplete fired — the P-chain fetch+executed to the
|
||
// network frontier, so it has replayed every staker tx and m.Validators now holds the TRUE
|
||
// FULL primary-network set). A native non-platform chain reads it (blockHandler.primaryNetworkReady)
|
||
// to gate its OWN bootstrap frontier-trust: until it is true the staked beacon set is a PARTIAL
|
||
// (mid-replay) set, a non-representative stake denominator that would let a degenerate at-or-below
|
||
// responder subset false-complete the node at its stale height. It is the authoritative
|
||
// "primary-network validator set is fully loaded" signal — distinct from
|
||
// manager.IsBootstrapped, which returns true the instant the P-chain merely EXISTS in m.chains
|
||
// (right after createChain launched its bootstrap goroutine — far too early).
|
||
pChainBootstrapped gatomic.Bool
|
||
|
||
// chain++ related interface to allow validators retrieval
|
||
validatorState validators.State
|
||
|
||
luxGatherer metrics.MultiGatherer // chainID
|
||
handlerGatherer metrics.MultiGatherer // chainID
|
||
meterChainVMGatherer metrics.MultiGatherer // chainID
|
||
meterGRAPHVMGatherer metrics.MultiGatherer // chainID
|
||
proposervmGatherer metrics.MultiGatherer // chainID
|
||
p2pGatherer metrics.MultiGatherer // chainID
|
||
linearGatherer metrics.MultiGatherer // chainID
|
||
stakeGatherer metrics.MultiGatherer // chainID
|
||
vmGatherer map[ids.ID]metrics.MultiGatherer // vmID -> chainID
|
||
}
|
||
|
||
// New returns a new Manager
|
||
func New(config *ManagerConfig) (Manager, error) {
|
||
luxGatherer := metrics.NewLabelGatherer(ChainLabel)
|
||
if err := config.Metrics.Register(luxNamespace, luxGatherer); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
handlerGatherer := metrics.NewLabelGatherer(ChainLabel)
|
||
if err := config.Metrics.Register(handlerNamespace, handlerGatherer); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
meterChainVMGatherer := metrics.NewLabelGatherer(ChainLabel)
|
||
if err := config.Metrics.Register(meterchainvmNamespace, meterChainVMGatherer); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
meterGRAPHVMGatherer := metrics.NewLabelGatherer(ChainLabel)
|
||
if err := config.Metrics.Register(meterdagvmNamespace, meterGRAPHVMGatherer); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
proposervmGatherer := metrics.NewLabelGatherer(ChainLabel)
|
||
if err := config.Metrics.Register(proposervmNamespace, proposervmGatherer); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
p2pGatherer := metrics.NewLabelGatherer(ChainLabel)
|
||
if err := config.Metrics.Register(p2pNamespace, p2pGatherer); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
linearGatherer := metrics.NewLabelGatherer(ChainLabel)
|
||
if err := config.Metrics.Register(chainNamespace, linearGatherer); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
stakeGatherer := metrics.NewLabelGatherer(ChainLabel)
|
||
if err := config.Metrics.Register(stakeNamespace, stakeGatherer); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Initialize chain database manager using single global ZapDB with prefix isolation
|
||
// All chains share one database - G-Chain (dgraph) can index the entire database for GraphQL queries
|
||
chainDBManager := NewChainDBManager(ChainDBManagerConfig{
|
||
DB: config.DB,
|
||
Log: config.Log,
|
||
})
|
||
|
||
return &manager{
|
||
Aliaser: ids.NewAliaser(),
|
||
ManagerConfig: *config,
|
||
chainDBManager: chainDBManager,
|
||
chains: make(map[ids.ID]*chainInfo),
|
||
chainsQueue: buffer.NewUnboundedBlockingDeque[ChainParameters](initialQueueSize),
|
||
unblockChainCreatorCh: make(chan struct{}),
|
||
chainCreatorShutdownCh: make(chan struct{}),
|
||
pendingVMChains: make(map[ids.ID][]ChainParameters),
|
||
restrictedAttempts: make(map[ids.ID]int),
|
||
|
||
luxGatherer: luxGatherer,
|
||
handlerGatherer: handlerGatherer,
|
||
meterChainVMGatherer: meterChainVMGatherer,
|
||
meterGRAPHVMGatherer: meterGRAPHVMGatherer,
|
||
proposervmGatherer: proposervmGatherer,
|
||
p2pGatherer: p2pGatherer,
|
||
linearGatherer: linearGatherer,
|
||
stakeGatherer: stakeGatherer,
|
||
vmGatherer: make(map[ids.ID]metrics.MultiGatherer),
|
||
}, nil
|
||
}
|
||
|
||
// QueueChainCreation queues a chain creation request
|
||
// Invariant: Tracked Net must be checked before calling this function
|
||
func (m *manager) QueueChainCreation(chainParams ChainParameters) {
|
||
m.Log.Info("QueueChainCreation called",
|
||
log.String("vmID", chainParams.VMID.String()),
|
||
log.String("EVMID", constants.EVMID.String()),
|
||
log.Bool("vmIDEqualsEVMID", chainParams.VMID == constants.EVMID),
|
||
)
|
||
|
||
// Register blockchain→chain mapping with the network layer so gossip
|
||
// can resolve which validator set to use for this blockchain's blocks.
|
||
if chainParams.ChainID != constants.PrimaryNetworkID && m.Net != nil {
|
||
m.Net.RegisterBlockchainNetwork(chainParams.ID, chainParams.ChainID)
|
||
}
|
||
|
||
if sb, _ := m.Nets.GetOrCreate(chainParams.ChainID); !sb.AddChain(chainParams.ID) {
|
||
m.Log.Debug("skipping chain creation",
|
||
log.String("reason", "chain already staged"),
|
||
log.Stringer("chainID", chainParams.ChainID),
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Stringer("vmID", chainParams.VMID),
|
||
)
|
||
return
|
||
}
|
||
|
||
if ok := m.chainsQueue.PushRight(chainParams); !ok {
|
||
m.Log.Warn("skipping chain creation",
|
||
log.String("reason", "couldn't enqueue chain"),
|
||
log.Stringer("chainID", chainParams.ChainID),
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Stringer("vmID", chainParams.VMID),
|
||
)
|
||
}
|
||
}
|
||
|
||
// createChain creates and starts the chain
|
||
//
|
||
// Note: it is expected for the net to already have the chain registered as
|
||
// bootstrapping before this function is called
|
||
func (m *manager) createChain(chainParams ChainParameters) {
|
||
m.Log.Info("creating chain",
|
||
log.Stringer("chainID", chainParams.ChainID),
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Stringer("vmID", chainParams.VMID),
|
||
)
|
||
|
||
sb, _ := m.Nets.GetOrCreate(chainParams.ChainID)
|
||
|
||
// Entitlement check: a chain in RestrictedChains may only be activated by a node
|
||
// the M-Chain holds an ownership attestation for. Unrestricted and critical
|
||
// chains short-circuit to authorized, so this is a no-op for P/C/X/Q/… and
|
||
// whenever RestrictedChains is empty.
|
||
if authorized, ready := m.authorizeChainActivation(chainParams.ID); !ready {
|
||
// The M-Chain is not bootstrapped yet, so entitlement cannot be read. Park
|
||
// the chain out of the queue and retry once the M-Chain converges; do not
|
||
// skip permanently. The attempt cap bounds this.
|
||
if m.deferRestrictedChain(chainParams) {
|
||
m.Log.Info("deferring restricted chain activation until M-Chain bootstrapped",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Stringer("vmID", chainParams.VMID),
|
||
)
|
||
return
|
||
}
|
||
// Cap exhausted: the M-Chain never became queryable. Opt out cleanly,
|
||
// exactly like an unentitled chain — mark bootstrapped, no failing health
|
||
// check.
|
||
m.Log.Warn("opting out of restricted chain: M-Chain did not bootstrap in time",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Stringer("vmID", chainParams.VMID),
|
||
)
|
||
sb.Bootstrapped(chainParams.ID)
|
||
return
|
||
} else if !authorized {
|
||
// Clean opt-out: this node is not entitled. Mirror the
|
||
// VM-plugin-not-loaded skip exactly — mark the slot bootstrapped and
|
||
// return WITHOUT a failing health check, so the node stays healthy while
|
||
// declining to validate a chain it is not authorized for. The specific
|
||
// reason was already logged by authorizeChainActivation.
|
||
m.Log.Info("chain activation not authorized: no ownership attestation entitles this node",
|
||
log.Stringer("chainID", chainParams.ChainID),
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Stringer("vmID", chainParams.VMID),
|
||
)
|
||
sb.Bootstrapped(chainParams.ID)
|
||
return
|
||
}
|
||
|
||
// Note: buildChain builds all chain's relevant objects (notably engine and handler)
|
||
// but does not start their operations. Starting of the handler (which could potentially
|
||
// issue some internal messages), is delayed until chain dispatching is started and
|
||
// the chain is registered in the manager. This ensures that no message generated by handler
|
||
// upon start is dropped.
|
||
chain, err := m.buildChain(chainParams, sb)
|
||
if chain == nil && err == nil {
|
||
m.Log.Info("chain skipped", log.Stringer("chainID", chainParams.ID))
|
||
return
|
||
}
|
||
|
||
if err != nil {
|
||
// Special handling for X-Chain in single validator mode
|
||
// Allow the node to continue without X-Chain when it fails with VM type error
|
||
// X-Chain ID: w68fJWq2nmQYuEKvbKRrKvDXB8xGnzuVGpoosXF3YV2N3G6nY
|
||
xChainID, _ := ids.FromString("w68fJWq2nmQYuEKvbKRrKvDXB8xGnzuVGpoosXF3YV2N3G6nY")
|
||
isXChain := chainParams.ID == xChainID
|
||
isVMTypeError := err == errUnknownVMType
|
||
skipBootstrapMode := m.SkipBootstrap
|
||
|
||
// If X-Chain fails with VM type error in single validator mode, just log and continue
|
||
if isXChain && isVMTypeError && skipBootstrapMode {
|
||
chainAlias := m.PrimaryAliasOrDefault(chainParams.ID)
|
||
m.Log.Warn("X-Chain creation failed in single validator mode - continuing without X-Chain",
|
||
log.Stringer("chainID", chainParams.ChainID),
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.String("chainAlias", chainAlias),
|
||
log.Stringer("vmID", chainParams.VMID),
|
||
log.String("errorString", fmt.Sprintf("%v", err)),
|
||
log.Err(err),
|
||
)
|
||
|
||
// Register a health check that indicates X-Chain is not running
|
||
healthCheckErr := fmt.Errorf("X-Chain not running in single validator mode: %w", err)
|
||
err := m.Health.RegisterHealthCheck(
|
||
chainAlias,
|
||
health.CheckerFunc(func(context.Context) (interface{}, error) {
|
||
return nil, healthCheckErr
|
||
}),
|
||
chainParams.ChainID.String(),
|
||
)
|
||
if err != nil {
|
||
m.Log.Error("failed to register X-Chain health check",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.String("chainAlias", chainAlias),
|
||
log.Err(err),
|
||
)
|
||
}
|
||
return
|
||
}
|
||
|
||
if m.CriticalChains.Contains(chainParams.ID) {
|
||
// Shut down if we fail to create a required chain (i.e. X, P or C)
|
||
// unless it's X-Chain with VM type error in single validator mode (handled above)
|
||
m.Log.Error("error creating required chain",
|
||
log.Stringer("chainID", chainParams.ChainID),
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Stringer("vmID", chainParams.VMID),
|
||
log.String("errorString", fmt.Sprintf("%v", err)),
|
||
log.String("errorType", fmt.Sprintf("%T", err)),
|
||
log.Err(err),
|
||
)
|
||
go m.ShutdownNodeFunc(1)
|
||
return
|
||
}
|
||
|
||
chainAlias := m.PrimaryAliasOrDefault(chainParams.ID)
|
||
|
||
// Plugin-not-loaded is a deliberate skip, not a failure.
|
||
//
|
||
// Each validator opts into chains by loading their VM plugin: a
|
||
// sovereign L1 that runs its own EVM as a chain on the P-Chain
|
||
// ships no C-Chain plugin at all. When the chain manager hits a
|
||
// chain whose VM isn't registered, that's the validator's "no I
|
||
// don't validate this one" signal — log info, mark the slot
|
||
// bootstrapped, and return WITHOUT registering a failing health
|
||
// check. The chain stays in pending state for hot-load if the
|
||
// plugin shows up later (e.g. via lpm install).
|
||
//
|
||
// The old behavior (always register a failing health check) made
|
||
// /v1/health return 503 for any opted-out chain, which made
|
||
// kubelet liveness probes kill the validator pod. That made
|
||
// chain participation effectively all-or-nothing per validator.
|
||
// Now: validators participate per-plugin, opt-in.
|
||
if errors.Is(err, vms.ErrNotFound) {
|
||
m.Log.Info("chain VM plugin not loaded — opting out of this chain",
|
||
log.Stringer("chainID", chainParams.ChainID),
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.String("chainAlias", chainAlias),
|
||
log.Stringer("vmID", chainParams.VMID),
|
||
)
|
||
sb.Bootstrapped(chainParams.ID)
|
||
return
|
||
}
|
||
|
||
m.Log.Warn("non-critical chain failed to initialize",
|
||
log.Stringer("chainID", chainParams.ChainID),
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.String("chainAlias", chainAlias),
|
||
log.Stringer("vmID", chainParams.VMID),
|
||
log.Err(err),
|
||
)
|
||
|
||
// Non-critical chain failed for a real reason (not missing
|
||
// plugin). Mark it as bootstrapped so it doesn't block the
|
||
// node-level health check, but DO register a per-chain failing
|
||
// health check so operators see something is wrong with a chain
|
||
// they did opt into.
|
||
sb.Bootstrapped(chainParams.ID)
|
||
|
||
healthCheckErr := fmt.Errorf("failed to create chain on net %s: %w", chainParams.ChainID, err)
|
||
err := m.Health.RegisterHealthCheck(
|
||
chainAlias,
|
||
health.CheckerFunc(func(context.Context) (interface{}, error) {
|
||
return nil, healthCheckErr
|
||
}),
|
||
chainParams.ChainID.String(),
|
||
)
|
||
if err != nil {
|
||
m.Log.Error("failed to register failing health check",
|
||
log.Stringer("chainID", chainParams.ChainID),
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.String("chainAlias", chainAlias),
|
||
log.Stringer("vmID", chainParams.VMID),
|
||
log.Err(err),
|
||
)
|
||
}
|
||
return
|
||
}
|
||
|
||
m.chainsLock.Lock()
|
||
m.chains[chainParams.ID] = chain
|
||
m.chainsLock.Unlock()
|
||
|
||
m.stateChainIdentity(chainParams)
|
||
|
||
// Associate the newly created chain with its default alias
|
||
if err := m.Alias(chainParams.ID, chainParams.ID.String()); err != nil {
|
||
m.Log.Error("failed to alias the new chain with itself",
|
||
log.Stringer("chainID", chainParams.ChainID),
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Stringer("vmID", chainParams.VMID),
|
||
log.Err(err),
|
||
)
|
||
}
|
||
|
||
// Notify those who registered to be notified when a new chain is created
|
||
m.notifyRegistrants(chain.Name, chain.Runtime, chain.VM)
|
||
|
||
// Register HTTP handlers for this chain if the VM supports it
|
||
m.Log.Info("checking if VM implements CreateHandlers",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.String("vmType", fmt.Sprintf("%T", chain.VM)),
|
||
)
|
||
if vm, ok := chain.VM.(interface {
|
||
CreateHandlers(context.Context) (map[string]http.Handler, error)
|
||
}); ok {
|
||
m.Log.Info("VM implements CreateHandlers, calling it",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
)
|
||
ctx, cancel := context.WithTimeout(context.Background(), vmStartupTimeout)
|
||
defer cancel()
|
||
handlers, err := vm.CreateHandlers(ctx)
|
||
if err != nil {
|
||
m.Log.Error("failed to create HTTP handlers",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Err(err),
|
||
)
|
||
} else {
|
||
// Register each handler with the HTTP server
|
||
for endpoint, handler := range handlers {
|
||
chainAlias := chainParams.ID.String()
|
||
// For C-Chain, also register under the "C" alias
|
||
if chainParams.ID == m.CChainID {
|
||
chainAlias = "C"
|
||
}
|
||
|
||
// AddRoute builds the full path as /v1/<base><endpoint>, so a
|
||
// base of "chain/C" and an endpoint of "/rpc" is
|
||
// /v1/chain/c/rpc.
|
||
//
|
||
// One segment, [constants.ChainAliasPrefix]. "chain" is what
|
||
// these are: C-Chain, X-Chain, M-Chain. Clients reach the same
|
||
// address through [server.Chain].
|
||
route := func(base string) {
|
||
endpoints := []string{endpoint}
|
||
if endpoint == "/rpc" {
|
||
endpoints = append(endpoints, "")
|
||
}
|
||
for _, ep := range endpoints {
|
||
if err := m.Server.AddRoute(handler, constants.ChainAliasPrefix+"/"+base, ep); err != nil {
|
||
m.Log.Debug("chain route exists or skipped",
|
||
log.String("base", base),
|
||
log.String("endpoint", ep),
|
||
log.Err(err),
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
route(chainAlias)
|
||
if chainAlias != chainParams.ID.String() {
|
||
route(chainParams.ID.String())
|
||
}
|
||
|
||
// Also register with chain name alias for user-friendly routing (e.g., /v1/chain/zoo/rpc)
|
||
// A chain outside the primary network may not register under
|
||
// a name the primary network's chains answer to. The letters
|
||
// and their derivations are the register's own identity, and
|
||
// a second chain reachable at a name that looks like M-Chain's
|
||
// is a chain a reader cannot tell apart from the one that
|
||
// signs.
|
||
if chainParams.Name != "" && reserved(strings.ToLower(chainParams.Name)) && !m.isPrimary(chainParams) {
|
||
m.Log.Warn("refusing a reserved chain name",
|
||
log.String("name", chainParams.Name),
|
||
log.Stringer("chainID", chainParams.ID),
|
||
)
|
||
} else if chainParams.Name != "" {
|
||
nameLower := strings.ToLower(chainParams.Name)
|
||
route(nameLower)
|
||
m.Log.Info("Registered HTTP handler with chain name",
|
||
log.String("chainName", nameLower),
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.String("base", nameLower),
|
||
log.String("endpoint", endpoint),
|
||
)
|
||
|
||
// Register the chain's letter, in both cases.
|
||
//
|
||
// The letter is derived from the name rather than looked
|
||
// up: a hand-written map listed six letters while the
|
||
// network ran eleven chains, so M, B, A, Z, G, K and F had
|
||
// no letter route at all and the list had no way of
|
||
// noticing. `M-Chain` yields `M`, and that is the whole
|
||
// rule.
|
||
//
|
||
// Both cases because HTTP paths are case-sensitive and a
|
||
// reader does not expect them to be. `/v1/chain/m/rpc`
|
||
// answering while `/v1/chain/M/rpc` does not is a
|
||
// distinction nobody asked for.
|
||
for alias := range lettersFor(chainParams.Name) {
|
||
if alias != "" {
|
||
route(alias)
|
||
m.Log.Info("Registered HTTP handler with chain alias",
|
||
log.String("alias", alias),
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.String("endpoint", endpoint),
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
m.Log.Info("Registered HTTP handler",
|
||
log.String("chainAlias", chainAlias),
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.String("prefix", constants.ChainAliasPrefix),
|
||
log.String("endpoint", endpoint),
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Register chain with the router for message routing
|
||
if m.ManagerConfig.Router != nil {
|
||
routeCtx, routeCancel := context.WithTimeout(context.Background(), vmStartupTimeout)
|
||
defer routeCancel()
|
||
m.ManagerConfig.Router.AddChain(routeCtx, chainParams.ID, chain.Handler)
|
||
}
|
||
|
||
// Register bootstrapped health checks after P chain has been added to
|
||
// chains.
|
||
//
|
||
// Note: Registering this after the chain has been tracked prevents a race
|
||
// condition between the health check and adding the first chain to
|
||
// the manager.
|
||
if chainParams.ID == constants.PlatformChainID {
|
||
if err := m.registerBootstrappedHealthChecks(); err != nil {
|
||
if chain.Engine != nil {
|
||
stopCtx, stopCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer stopCancel()
|
||
chain.Engine.StopWithError(stopCtx, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Log prominent chain creation success message with endpoints
|
||
vmName := constants.VMName(chainParams.VMID)
|
||
chainAlias := m.PrimaryAliasOrDefault(chainParams.ID)
|
||
m.Log.Info("╔══════════════════════════════════════════════════════════════════╗")
|
||
m.Log.Info("║ CHAIN CREATED SUCCESSFULLY ║",
|
||
log.String("vmName", vmName),
|
||
log.String("chainAlias", chainAlias),
|
||
)
|
||
m.Log.Info("║ Chain ID:", log.Stringer("chainID", chainParams.ID))
|
||
m.Log.Info("║ VM ID:", log.Stringer("vmID", chainParams.VMID))
|
||
m.Log.Info("║ Network ID:", log.Stringer("chainID", chainParams.ChainID))
|
||
m.Log.Info("║ Endpoints available at:")
|
||
m.Log.Info("║ → " + server.Chain("", chainParams.ID.String()))
|
||
if chainAlias != chainParams.ID.String() {
|
||
m.Log.Info("║ → " + server.Chain("", chainAlias))
|
||
}
|
||
m.Log.Info("╚══════════════════════════════════════════════════════════════════╝")
|
||
|
||
// Tell the chain to start processing messages.
|
||
// If the X, P, or C Chain panics, do not attempt to recover
|
||
if chain.Engine != nil {
|
||
startCtx, startCancel := context.WithTimeout(context.Background(), vmStartupTimeout)
|
||
defer startCancel()
|
||
chain.Engine.Start(startCtx, !m.CriticalChains.Contains(chainParams.ID))
|
||
|
||
// Start a goroutine to monitor bootstrap completion and notify the chain.
|
||
// This is required because the health check (m.Nets.Bootstrapping()) reports
|
||
// chains as not bootstrapped until sb.Bootstrapped(chainID) is called. Gate on
|
||
// the HANDLER's real initial-sync completion (BootstrapComplete), not the
|
||
// engine's "am I started" flag — the latter returned true immediately at the
|
||
// local last-accepted height, so a behind/empty node was marked bootstrapped
|
||
// before fetching anything.
|
||
go m.monitorBootstrap(chain.Engine, chain.Handler, sb, chainParams.ID)
|
||
} else {
|
||
// DAG chains (X-Chain, Q-Chain) manage their own consensus and don't have
|
||
// a standard Engine. Mark them as bootstrapped immediately since the DAG
|
||
// engine was already started in createDAG.
|
||
m.Log.Info("DAG chain has no standard engine, marking as bootstrapped immediately",
|
||
log.Stringer("chainID", chainParams.ID))
|
||
sb.Bootstrapped(chainParams.ID)
|
||
}
|
||
}
|
||
|
||
// Create a chain
|
||
func (m *manager) buildChain(chainParams ChainParameters, sb network.Net) (*chainInfo, error) {
|
||
if chainParams.ID != constants.PlatformChainID && chainParams.VMID == constants.PlatformVMID {
|
||
return nil, errCreatePlatformVM
|
||
}
|
||
// primaryAlias will be used by the chains created below
|
||
primaryAlias := m.PrimaryAliasOrDefault(chainParams.ID)
|
||
|
||
// Create this chain's data directory
|
||
chainDataDir := filepath.Join(m.ChainDataDir, chainParams.ID.String())
|
||
if err := os.MkdirAll(chainDataDir, perms.ReadWriteExecute); err != nil {
|
||
return nil, fmt.Errorf("error while creating chain data directory %w", err)
|
||
}
|
||
|
||
// Create the log and context of the chain. Every collaborator built below takes
|
||
// THIS logger, so a line is attributable without its emitter naming the chain.
|
||
chainLog := speaksFor(m.Log, chainParams.ID)
|
||
|
||
// Create metrics gatherer for this chain
|
||
// The EVM expects metric.MultiGatherer, not a legacy registry type
|
||
chainLog.Info("Creating metrics gatherer", log.String("primaryAlias", primaryAlias))
|
||
chainMetricsGatherer := metrics.NewMultiGatherer()
|
||
|
||
// Create a registry and register it with the gatherer
|
||
chainMetricsReg, err := metrics.MakeAndRegister(chainMetricsGatherer, primaryAlias)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to create chain metrics: %w", err)
|
||
}
|
||
|
||
// Also register with the global gatherer for metrics collection
|
||
if err := m.linearGatherer.Register(primaryAlias, chainMetricsReg); err != nil {
|
||
chainLog.Warn("Failed to register chain metrics with global gatherer",
|
||
log.String("primaryAlias", primaryAlias),
|
||
log.Err(err),
|
||
)
|
||
}
|
||
chainLog.Info("Metrics gatherer created",
|
||
log.String("primaryAlias", primaryAlias),
|
||
log.Bool("isNil", chainMetricsGatherer == nil),
|
||
)
|
||
|
||
// The chain context carries the node's BLS public key as raw bytes, not as
|
||
// a *bls.PublicKey. Nothing downstream of here reads it yet, so it stays nil
|
||
// rather than being serialized on every chain build.
|
||
var pubKeyBytes []byte
|
||
if m.StakingBLSKey != nil && m.StakingBLSKey.PublicKey() != nil {
|
||
pubKeyBytes = nil
|
||
}
|
||
|
||
// Create warp signer for this chain using the node's BLS key
|
||
warpSigner := createWarpSigner(m.StakingBLSKey, m.NetworkID, chainParams.ID)
|
||
|
||
// Create per-chain shared memory for cross-chain atomic operations.
|
||
// This is required by the EVM's atomic VM for import/export transactions.
|
||
var chainSharedMemory atomic.SharedMemory
|
||
if m.AtomicMemory != nil {
|
||
chainSharedMemory = m.AtomicMemory.NewSharedMemory(chainParams.ID)
|
||
}
|
||
|
||
chainRuntime := &runtime.Runtime{
|
||
NetworkID: m.NetworkID,
|
||
ChainID: chainParams.ID,
|
||
NodeID: m.NodeID,
|
||
PublicKey: pubKeyBytes,
|
||
|
||
XChainID: m.XChainID,
|
||
CChainID: m.CChainID,
|
||
UTXOAssetID: m.UTXOAssetID,
|
||
ChainDataDir: chainDataDir,
|
||
|
||
BCLookup: m,
|
||
ValidatorState: getValidatorState(m.validatorState),
|
||
SharedMemory: chainSharedMemory,
|
||
Metrics: chainMetricsGatherer,
|
||
Log: chainLog,
|
||
WarpSigner: warpSigner,
|
||
}
|
||
|
||
// Get a factory for the vm we want to use on our chain
|
||
chainLog.Info("Getting VM factory", log.Stringer("vmID", chainParams.VMID))
|
||
vmFactory, err := m.VMManager.GetFactory(context.Background(), chainParams.VMID)
|
||
if err != nil {
|
||
// Check if this is a VM not found error - if so, add to pending chains for hot-loading
|
||
if errors.Is(err, vms.ErrNotFound) {
|
||
m.pendingVMChainsLock.Lock()
|
||
m.pendingVMChains[chainParams.VMID] = append(m.pendingVMChains[chainParams.VMID], chainParams)
|
||
m.pendingVMChainsLock.Unlock()
|
||
chainLog.Warn("VM not found - chain queued for hot-loading",
|
||
log.Stringer("vmID", chainParams.VMID),
|
||
log.Stringer("chainID", chainParams.ID),
|
||
)
|
||
return nil, fmt.Errorf("VM %s not found (chain queued for hot-loading): %w", chainParams.VMID, err)
|
||
}
|
||
chainLog.Error("Failed to get VM factory", log.Stringer("vmID", chainParams.VMID), log.Err(err))
|
||
return nil, fmt.Errorf("error while getting vmFactory: %w", err)
|
||
}
|
||
chainLog.Info("Got VM factory successfully")
|
||
|
||
// Create the chain
|
||
vmImpl, err := vmFactory.New(chainLog)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("error while creating vm for chain %s: %w", chainParams.ID, err)
|
||
}
|
||
|
||
chainFxs := make([]*vm.Fx, len(chainParams.FxIDs))
|
||
for i, fxID := range chainParams.FxIDs {
|
||
fxFactory, ok := fxs[fxID]
|
||
if !ok {
|
||
return nil, fmt.Errorf("fx %s not found", fxID)
|
||
}
|
||
|
||
chainFxs[i] = &vm.Fx{
|
||
ID: fxID,
|
||
Fx: fxFactory.New(),
|
||
}
|
||
}
|
||
|
||
chainLog.Info("DEBUG: About to check VM type", log.Stringer("chainID", chainParams.ID), log.String("vmType", fmt.Sprintf("%T", vmImpl)))
|
||
var createdChain *chainInfo
|
||
switch vmTyped := vmImpl.(type) {
|
||
// DAG VM support - for X-Chain and Q-Chain
|
||
case interface{ GetEngine() consensusdag.Engine }:
|
||
chainLog.Info("detected DAG VM with GetEngine()",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
)
|
||
createdChain, err = m.createDAG(chainRuntime, chainParams, vmTyped, chainFxs)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("error creating DAG chain: %w", err)
|
||
}
|
||
case chain.ChainVM:
|
||
beacons := m.Validators
|
||
isPlatformChain := chainParams.ID == constants.PlatformChainID
|
||
if isPlatformChain {
|
||
beacons = chainParams.CustomBeacons
|
||
}
|
||
|
||
// expectsStakedBeacons gates the EMPTY-beacon-set behavior of the bootstrap frontier
|
||
// quorum (see blockHandler.expectsStakedBeacons). True for a native NON-platform chain
|
||
// (C/X/Q/...) on a SYBIL-PROTECTED (real staked) network — those sync against the STAKED
|
||
// primary-network validator set (m.Validators, populated by the already-bootstrapped
|
||
// P-chain), so an empty set means "not yet loaded / misconfig → wait then fail safe",
|
||
// NOT "single-node → done". Driven by sybil-protection, NOT --skip-bootstrap: a production
|
||
// validator sets skip-bootstrap and that must NOT make it masquerade as single-node and
|
||
// wedge behind at its stale local tip (tasks #66/#74). See chainExpectsStakedBeacons.
|
||
// Discriminate on the validating Net (chainParams.ChainID), NOT the blockchain ID:
|
||
// deployed C/X/Q carry HASH blockchain IDs, and ids.IsNativeChain only matches the
|
||
// symbolic 111...C alias form (no deployed chain has it). Keying off the blockchain ID
|
||
// (the prior ids.IsNativeChain(chainParams.ID)) was therefore ALWAYS false for the real
|
||
// C-Chain, leaving this fix inert — the exact wedge #66/#74 set out to kill fired anyway.
|
||
isPrimaryNetworkChain := chainValidatesOnPrimaryNetwork(chainParams.ChainID)
|
||
expectsStakedBeacons := chainExpectsStakedBeacons(m.SybilProtectionEnabled, isPrimaryNetworkChain, isPlatformChain)
|
||
|
||
// skip-bootstrap forces empty beacons (single-node immediate-start) for every chain EXCEPT
|
||
// one that syncs against the staked set — those MUST always catch a behind validator up from
|
||
// its peers, so emptying their beacons (the prior UNCONDITIONAL override) is precisely the
|
||
// rejoin wedge this fixes. A genuine single-node / dev net runs sybil-protection OFF, so its
|
||
// native chains still fall here and get the empty-beacon immediate-start path.
|
||
if m.SkipBootstrap && !expectsStakedBeacons {
|
||
beacons = &emptyValidatorManager{}
|
||
chainLog.Info("skip-bootstrap enabled - using empty beacons for single-node mode")
|
||
}
|
||
// The beacon set is the ANCHOR for INITIAL SYNC (bootstrap): the blockHandler
|
||
// names the network frontier ONLY from a ⅔-by-stake quorum of these beacons —
|
||
// an arbitrary connected peer can no longer name a forged frontier (the C1
|
||
// forged-chain gate). The validator manager (n.vdrs) is populated by PlatformVM
|
||
// during its initialization via state.initValidatorSets(); for native chains the
|
||
// beacon set IS that primary-network validator set (looked up under networkID =
|
||
// PrimaryNetworkID below). Empty under --skip-bootstrap (single-node), in which
|
||
// case the frontier quorum is inert and the node treats itself as already synced.
|
||
|
||
// Create simple linear chain with basic consensus engine
|
||
chainLog.Info("creating linear chain", log.Stringer("chainID", chainRuntime.ChainID))
|
||
|
||
// Initialize the VM before creating the chain
|
||
// Get chain configuration
|
||
chainConfig, err := m.getChainConfig(chainParams.ID)
|
||
if err != nil {
|
||
chainLog.Warn("failed to get chain config, using empty config",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Err(err))
|
||
chainConfig = ChainConfig{}
|
||
}
|
||
|
||
// Get chain alias for database directory naming
|
||
linearChainAlias := chainParams.ID.String()
|
||
if aliases, _ := m.Aliases(chainParams.ID); len(aliases) > 0 {
|
||
linearChainAlias = aliases[0] // Use first alias (e.g., "P", "C")
|
||
}
|
||
|
||
// Get VM database from chain database manager
|
||
// Get VM database from chain database manager
|
||
vmDB, err := m.chainDBManager.GetVMDatabase(chainParams.ID, linearChainAlias)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to get database for chain %s: %w", chainParams.ID, err)
|
||
}
|
||
|
||
// Create message channel for VM-to-Engine communication
|
||
toEngine := make(chan vm.Message, 1)
|
||
|
||
// Convert []*vm.Fx to []interface{}
|
||
fxsInterface := make([]interface{}, len(chainFxs))
|
||
for i, fx := range chainFxs {
|
||
fxsInterface[i] = fx
|
||
}
|
||
|
||
// Initialize the VM if it supports the Initialize interface
|
||
// Inject automining config for dev mode (applies to the C-Chain EVM only),
|
||
// then stamp the chain-wide SecurityProfile pin into the C-Chain
|
||
// JSON config (F118) so the rpcchainvm plugin can resolve the
|
||
// profile on its side of the boundary.
|
||
vmConfigBytes := m.injectAutominingConfig(chainParams.VMID, chainConfig.Config)
|
||
vmConfigBytes = m.injectSecurityProfileConfig(chainParams.VMID, vmConfigBytes)
|
||
// CONSENSUS-SAFETY (single-proposer-per-height): re-wrap multi-validator
|
||
// linear chains in proposervm so block production follows the proposervm's
|
||
// proposer schedule — exactly ONE validator builds height H, the rest wait
|
||
// and vote. Without it every validator's engine calls BuildBlock
|
||
// UNCONDITIONALLY at every height off a slightly-different mempool, so two
|
||
// nodes can each gather an α-of-K cert for DIFFERENT blocks at the same
|
||
// height → reportCertEquivocation → Logger.Crit → crash (also the DEX-tx
|
||
// non-determinism wedge). proposervm's WaitForEvent only returns PendingTxs
|
||
// inside THIS node's proposer window, collapsing the race at its source.
|
||
// This restores the wrapper this manager is meant to use; Lux had
|
||
// commented it out and hand-rolled a bridge that dropped the invariant.
|
||
//
|
||
// Gate (all three required):
|
||
// - K>1 : single-proposer only matters for a multi-validator
|
||
// quorum; K==1 (single-node --dev) is trivially one
|
||
// proposer and needs no schedule.
|
||
// - not the P-Chain: the P-Chain publishes its OWN height-indexed
|
||
// validators.State DURING its createChain — AFTER the
|
||
// chainRuntime.ValidatorState snapshot used here was
|
||
// taken — so proposervm's windower would see an empty
|
||
// set and degrade to anyone-can-propose with a
|
||
// P-chain-height-0 stamp. The P-Chain keeps the existing
|
||
// path (its newPChainHeightVM gets the live state, which
|
||
// IS published by the K>1 block below). P-Chain blocks
|
||
// are rare (staking events) and ⅔-by-stake finality
|
||
// makes an equivocation crash require Byzantine
|
||
// double-voting, so the residual exposure is low.
|
||
// - not DAG-native : a VM that implements Linearize (X-Chain) drives the
|
||
// engine through a push-notification linearize bridge,
|
||
// and a push bridge does not compose with proposervm's
|
||
// pull/window model. It keeps the existing path.
|
||
// The networkID switch is the DEFAULT; explicitly-set --consensus-* flags
|
||
// are the OVERRIDE. Before this, selection consulted no operator input at
|
||
// all and a five-validator L1 silently ran DefaultParams (K=20, alpha=14,
|
||
// BetaVirtuous=14) — unreachable, so nothing ever finalized.
|
||
consensusParams := m.ConsensusOverrides.ApplyTo(
|
||
selectConsensusParams(m.SybilProtectionEnabled, m.NetworkID))
|
||
if m.SybilProtectionEnabled {
|
||
// The live check keeps what makes a committee safe — the overlap bound
|
||
// and single-fault tolerance — and does not count heads. K is a number of
|
||
// stake-weighted draws; the validator set decides how they fall, not
|
||
// whether the chain may start. The tier floor is a decentralisation
|
||
// target and is reported below, never enforced: refusing to boot does
|
||
// not add validators, it pins every node on whatever it last ran.
|
||
// Native chains register their validators under the primary network id,
|
||
// the same resolution the proposervm windower does a few lines down.
|
||
liveN := m.Validators.Count(constants.PrimaryNetworkID)
|
||
if err := consensusParams.ValidateForLiveValueNetwork(m.NetworkID, liveN); err != nil {
|
||
return nil, fmt.Errorf("refusing to start multi-node chain %s with non-BFT consensus params (liveValidators=%d): %w", chainParams.ID, liveN, err)
|
||
}
|
||
// SAFE BUT SMALL. The decentralisation target is REPORTED, never
|
||
// enforced — a set that satisfies the BFT overlap bound must run, but an
|
||
// operator should still see that it is one fault away from the tier the
|
||
// network is meant to have. Silent acceptance is how a five-validator
|
||
// value network stops being a temporary state.
|
||
if err := consensusParams.MeetsDecentralizationTarget(m.NetworkID); err != nil {
|
||
chainLog.Warn("consensus params are Byzantine-SAFE but below the network decentralisation target — grow the validator set",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Int("K", consensusParams.K),
|
||
log.Int("liveValidators", liveN),
|
||
log.Err(err),
|
||
)
|
||
}
|
||
}
|
||
// v1.36 "Nova": the round-scoped VIEW-CHANGE (prevote/POL/lock) was DELETED from the
|
||
// consensus engine (174af3c31). Nova metastable sampling is the sole decider and the ⅔
|
||
// Quasar attestation trails it — there is no view-change to opt into, so the former
|
||
// LUX_CONSENSUS_VIEW_CHANGE env gate is gone. Keep the braid dead.
|
||
_, innerIsDAGNative := vmTyped.(interface {
|
||
Linearize(context.Context, ids.ID, chan<- vm.Message) error
|
||
})
|
||
wrapInProposerVM := shouldWrapInProposerVM(consensusParams.K, chainParams.ID, innerIsDAGNative)
|
||
|
||
// Resolve the validator-set ID for this chain ONCE, here — BEFORE the
|
||
// proposervm wrap and the consensus-engine wiring both consume it. This is
|
||
// the SINGLE source of truth (DRY) so the proposervm windower and the cert
|
||
// side cannot disagree (a divergent windower/cert set breaks
|
||
// determinism on non-native chains, and a hardcoded primary set on a
|
||
// sovereign L1 silently defeats single-proposer):
|
||
// - native/primary chains (P/C/X/Q/A/B/T/Z/G/I/K) register validators under
|
||
// constants.PrimaryNetworkID (ids.Empty), NOT their individual chain ID.
|
||
// - a sovereign L1 uses its OWN validator-set ID (chainParams.ChainID ==
|
||
// its EVM chainID), falling back to primary ONLY if that set is empty.
|
||
networkID := chainParams.ChainID
|
||
isNative := ids.IsNativeChain(chainParams.ID)
|
||
if isNative {
|
||
networkID = constants.PrimaryNetworkID
|
||
}
|
||
if m.Validators != nil && networkID != constants.PrimaryNetworkID {
|
||
if m.Validators.Count(networkID) == 0 {
|
||
chainLog.Warn("no validators found for network ID; falling back to primary network validators",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Stringer("networkID", networkID),
|
||
log.Stringer("fallbackNetworkID", constants.PrimaryNetworkID),
|
||
)
|
||
networkID = constants.PrimaryNetworkID
|
||
}
|
||
}
|
||
|
||
// engineVM is the VM the consensus engine, the WaitForEvent bridge, the
|
||
// block handler, and HTTP registration all use. For a wrapped chain it is
|
||
// the proposervm; build / parse / verify / accept and the proposer-window
|
||
// WaitForEvent all flow THROUGH it (proposervm forwards to the inner VM and
|
||
// carries the proposer signature + P-chain epoch height in the block bytes).
|
||
// For an unwrapped chain it IS the inner VM, so every call below is the
|
||
// original behavior unchanged.
|
||
var engineVM chain.ChainVM = vmTyped
|
||
if wrapInProposerVM {
|
||
proposervmReg, regErr := metrics.MakeAndRegister(m.proposervmGatherer, linearChainAlias)
|
||
if regErr != nil {
|
||
return nil, fmt.Errorf("failed to register proposervm metrics for chain %s: %w", chainParams.ID, regErr)
|
||
}
|
||
minBlkDelay := proposervm.DefaultMinBlockDelay
|
||
if m.ProposerMinBlockDelay > 0 {
|
||
minBlkDelay = m.ProposerMinBlockDelay
|
||
}
|
||
engineVM = proposervm.New(vmTyped, proposervm.Config{
|
||
Upgrades: m.Upgrades,
|
||
NetworkID: networkID, // the windower uses the SAME set ID as the cert side
|
||
MinBlkDelay: minBlkDelay,
|
||
NumHistoricalBlocks: proposervm.DefaultNumHistoricalBlocks,
|
||
StakingLeafSigner: m.StakingTLSSigner,
|
||
StakingCertLeaf: m.StakingTLSCert,
|
||
StakingMLDSASigner: m.StakingMLDSASigner,
|
||
StakingMLDSAPub: m.StakingMLDSAPub,
|
||
ProposerWindowDuration: m.ProposerWindowDuration,
|
||
Registerer: proposervmReg,
|
||
})
|
||
chainLog.Info("wrapping chain VM in proposervm for single-proposer-per-height block production",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Int("K", consensusParams.K),
|
||
log.Stringer("windowerNetworkID", networkID),
|
||
log.Duration("minBlockDelay", minBlkDelay))
|
||
}
|
||
|
||
chainLog.Info("initializing VM", log.Stringer("chainID", chainParams.ID))
|
||
initCtx, initCancel := context.WithTimeout(context.Background(), vmStartupTimeout)
|
||
defer initCancel()
|
||
// Initialize THROUGH engineVM. proposervm.Initialize builds its windower
|
||
// from chainRuntime.ValidatorState, then initializes the inner VM with the
|
||
// SAME vm.Init (the inner gets the real DB / genesis / ToEngine). For an
|
||
// unwrapped chain engineVM IS the inner VM, so this is the original call.
|
||
err = engineVM.Initialize(
|
||
initCtx,
|
||
vm.Init{
|
||
Runtime: chainRuntime,
|
||
DB: vmDB,
|
||
Log: chainLog,
|
||
Genesis: chainParams.GenesisData,
|
||
Upgrade: chainConfig.Upgrade,
|
||
Config: vmConfigBytes,
|
||
ToEngine: toEngine,
|
||
Fx: fxsInterface,
|
||
Sender: &chainSender{net: m.Net, msgCreator: m.MsgCreator, chainID: chainParams.ID, log: chainLog},
|
||
},
|
||
)
|
||
if err != nil {
|
||
chainLog.Error("VM initialization failed",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Err(err))
|
||
return nil, fmt.Errorf("failed to initialize VM: %w", err)
|
||
}
|
||
chainLog.Info("VM initialized successfully", log.Stringer("chainID", chainParams.ID))
|
||
|
||
// Publish the P-Chain's HEIGHT-INDEXED validators.State so
|
||
// every K>1 chain built AFTER it (C-Chain, L1s, …) resolves the weighted
|
||
// validator set / per-voter pubkeys / stake at the block's P-chain epoch
|
||
// height. The platformvm VM instance IS a validators.State (it embeds the
|
||
// height-indexed pvalidators.Manager built from its own state DB, with the
|
||
// real GetValidatorSet(ctx, height, netID)). It only EXISTS after the
|
||
// P-Chain VM is initialized — which is here — and the P-Chain is created
|
||
// before any other primary-network chain (single serial chain-creator
|
||
// goroutine), so this assignment happens-before every later chain reads
|
||
// m.validatorState in the K>1 wiring block below. Without this the field
|
||
// stays nil → getValidatorState returns the no-op → empty set at every
|
||
// height → ⅔-stake tally is 0 → finality stalls forever on every K>1 chain
|
||
// (the bug the fail-closed guard now also catches). Plain assignment is
|
||
// race-free: chain creation is serialized (dispatchChainCreator).
|
||
if chainParams.ID == constants.PlatformChainID {
|
||
if vdrState, ok := vmImpl.(validators.State); ok {
|
||
m.validatorState = vdrState
|
||
chainLog.Info("published P-Chain height-indexed validator state to chain manager",
|
||
log.Stringer("chainID", chainParams.ID))
|
||
} else {
|
||
// The P-Chain MUST be a validators.State; if it is not, every K>1
|
||
// chain (including the P-Chain itself) would stall. Fail loud.
|
||
return nil, fmt.Errorf(
|
||
"P-Chain VM %T does not implement validators.State — cannot publish the "+
|
||
"height-indexed validator set; every K>1 quorum chain would stall finality",
|
||
vmImpl)
|
||
}
|
||
}
|
||
|
||
// X-Chain (and any DAG-native VM) must be linearized into linear block mode
|
||
// BEFORE it goes Ready, so its embedded block builder is wired to the real
|
||
// toEngine channel the cert runtime reads from. Interface-gated: only VMs that
|
||
// implement Linearize (X-Chain) take this path; C/D/P/Q are untouched. This is
|
||
// what lets X-Chain finalize through the same 2/3-stake cert path as every other
|
||
// chain instead of the (now-removed) undriven DAG engine.
|
||
if linearVM, ok := vmTyped.(interface {
|
||
Linearize(context.Context, ids.ID, chan<- vm.Message) error
|
||
}); ok {
|
||
chainLog.Info("linearizing DAG-native VM into linear block mode",
|
||
log.Stringer("chainID", chainParams.ID))
|
||
linCtx, linCancel := context.WithTimeout(context.Background(), vmStartupTimeout)
|
||
if err := linearVM.Linearize(linCtx, ids.Empty, toEngine); err != nil {
|
||
linCancel()
|
||
chainLog.Error("failed to linearize VM",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Err(err))
|
||
return nil, fmt.Errorf("failed to linearize VM into linear block mode: %w", err)
|
||
}
|
||
linCancel()
|
||
}
|
||
|
||
// Place the VM in BOOTSTRAPPING state (NOT normal operation) after init. The
|
||
// node-layer initial sync (runBootstrapThenPoll → runInitialSync) drives it to the
|
||
// network frontier; ONLY when that completes does the VM transition to Ready (see
|
||
// blockHandler.vmReady, wired below). Going straight to Ready here was the
|
||
// restart-freeze defect: SetState(Ready) fires the EVM's onNormalOperationsStarted
|
||
// (block building, mempool gossip, validator dispatch) at the LOCAL last-accepted
|
||
// height — so a restarted STALE validator served / built at its stale height while
|
||
// the real sync ran detached and non-gating. Bootstrapping fires onBootstrapStarted
|
||
// (snapshots only, no building) — the correct state while the node fetch+executes the
|
||
// gap. The two "go live" transitions (VM serves + engine cert-gates) now happen
|
||
// TOGETHER at the named frontier, never at a stale height.
|
||
// Route SetState through engineVM. For a wrapped chain this is the
|
||
// proposervm, which forwards the state to the inner VM AND records its own
|
||
// consensusState — the gate proposervm.WaitForEvent reads to decide whether
|
||
// to enforce the proposer window. If we set state on the inner VM directly,
|
||
// proposervm.consensusState would stay Unknown and the window would never
|
||
// engage (no single-proposer). For an unwrapped chain engineVM is the inner.
|
||
if stateVM, ok := engineVM.(interface {
|
||
SetState(context.Context, uint32) error
|
||
}); ok {
|
||
chainLog.Info("transitioning VM to bootstrapping (initial sync gates normal operation)",
|
||
log.Stringer("chainID", chainParams.ID))
|
||
stateCtx, stateCancel := context.WithTimeout(context.Background(), vmStartupTimeout)
|
||
if err := stateVM.SetState(stateCtx, uint32(vm.Bootstrapping)); err != nil {
|
||
stateCancel()
|
||
chainLog.Error("failed to transition VM to bootstrapping",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Err(err))
|
||
return nil, fmt.Errorf("failed to transition VM to bootstrapping: %w", err)
|
||
}
|
||
stateCancel()
|
||
}
|
||
|
||
// Create integrated consensus engine - the ONE right way to set up chain consensus
|
||
// This consolidates: engine creation, emitter wiring, VM registration
|
||
// blockBuilder is what the engine builds/parses through. For a wrapped chain
|
||
// it is the proposervm (BuildBlock signs + stamps the P-chain height, the
|
||
// block bytes the Quasar cert covers; ParseBlock verifies the proposer sig
|
||
// of a gossiped block), so build and parse route through the SAME wrapper
|
||
// and certs are over consistent bytes. proposervm.VM structurally satisfies
|
||
// consensuschain.BlockBuilder because vm/chain.Block is a type alias for the
|
||
// engine's block.Block.
|
||
var blockBuilder consensuschain.BlockBuilder
|
||
if bb, ok := engineVM.(consensuschain.BlockBuilder); ok {
|
||
blockBuilder = bb
|
||
chainLog.Info("registered VM with consensus engine for block building",
|
||
log.Stringer("chainID", chainParams.ID))
|
||
} else {
|
||
chainLog.Warn("VM does not implement BlockBuilder interface, block building disabled",
|
||
log.Stringer("chainID", chainParams.ID))
|
||
}
|
||
|
||
// networkID + isNative were resolved ONCE above (before the proposervm wrap)
|
||
// so the windower and the cert side share the IDENTICAL validator-set ID.
|
||
// Do not re-resolve here.
|
||
chainLog.Info("[CONSENSUS DEBUG] Creating consensus engine for chain",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Stringer("chainParams.ChainID", chainParams.ChainID),
|
||
log.Bool("isNativeChain", isNative),
|
||
log.Stringer("networkIDForValidators", networkID),
|
||
log.Stringer("PrimaryNetworkID", constants.PrimaryNetworkID),
|
||
)
|
||
|
||
// consensusParams was selected and value-validated above (before VM
|
||
// Initialize) so the proposervm-wrap decision could read K. It chooses:
|
||
// - Single-node (--dev, sybil protection disabled): K=1, self-voting.
|
||
// - Multi-node (sybil-protected): a BYZANTINE-fault-tolerant set selected
|
||
// by network (Mainnet K=21 / Testnet K=11 / Default K=20 / local-BFT K=4).
|
||
// Never LocalParams() (K=3/α=2, f=0, which a single Byzantine
|
||
// validator forks); the value-net validation fails closed otherwise.
|
||
|
||
// Wire the α-of-K vote/cert topology for multi-validator (K>1)
|
||
// chains so finality is LIVE (votes broadcast to all, certs gossiped,
|
||
// followers finalize on the cert). Without a verifier a K>1 engine refuses
|
||
// to Start; without the gossiper Mode() reports degraded and value-DEX is
|
||
// refused. For K==1 these stay nil (no quorum, no signatures).
|
||
gossiper := &networkGossiper{net: m.Net, msgCreator: m.MsgCreator, networkID: networkID, log: chainLog}
|
||
// catchup is the behind-follower self-heal wire. The engine signals through
|
||
// it (chain.Catchup) when a gossiped child references a parent we lack; it
|
||
// routes to the blockHandler's GetAncestors transport. The handler wraps the
|
||
// engine built just below, so its handler ref is late-bound at chainInfo
|
||
// construction (a no-op until then — see networkCatchup).
|
||
catchup := &networkCatchup{log: chainLog}
|
||
netCfg := consensuschain.NetworkConfig{
|
||
ChainID: chainParams.ID,
|
||
NetworkID: networkID,
|
||
NodeID: m.NodeID,
|
||
Validators: m.Validators, // CRITICAL: Pass validator sampler for k-peer polling
|
||
Logger: chainLog,
|
||
Gossiper: gossiper,
|
||
Catchup: catchup, // fetch missing ancestors so a behind follower converges
|
||
VM: blockBuilder,
|
||
Params: &consensusParams,
|
||
}
|
||
// Keep the finality certs this node assembles on disk, next to the chain they
|
||
// belong to. A peer that fell behind rejoins ONLY by being served the cert for
|
||
// each block in its gap — the network will not re-vote a decided height — so a
|
||
// memory-only cert lasts exactly as long as this process, and how far back we
|
||
// can help a straggler ends up depending on when we last started rather than on
|
||
// the window maxServedCerts names. Opened for EVERY chain, not just a signing
|
||
// one: any node that finalizes can answer a catch-up request, and the more nodes
|
||
// that can, the shallower the hole a restart leaves.
|
||
//
|
||
// Fails OPEN, unlike the vote guard below: an unreadable cert costs a peer one
|
||
// fetch elsewhere, while unreadable equivocation memory risks a fork.
|
||
if certs, certErr := consensuschain.OpenCerts(filepath.Join(chainDataDir, "certs")); certErr != nil {
|
||
chainLog.Warn("could not open the durable finality-cert store — this node can only catch a peer up through heights it finalized since this process started",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Err(certErr))
|
||
} else {
|
||
netCfg.Certs = certs
|
||
}
|
||
if consensusParams.K > 1 {
|
||
// FAIL-CLOSED GUARD: a K>1 quorum chain finalizes ONLY
|
||
// on a ⅔-by-stake supermajority read from the HEIGHT-INDEXED validator
|
||
// state. If that state was never published (m.validatorState == nil →
|
||
// getValidatorState returns the no-op, whose GetValidatorSet is empty at
|
||
// every height), the stake tally is 0 → VerifyWeighted fails closed →
|
||
// NO block EVER finalizes. That is a SILENT permanent finality stall.
|
||
// Refuse to build the chain LOUDLY instead. The P-Chain publishes its
|
||
// own height-indexed State into m.validatorState the moment it is
|
||
// created (it is built before any other K>1 chain), so the P-Chain and
|
||
// every chain after it sees a live state here; only a genuine wiring
|
||
// regression trips this.
|
||
if !quorumValidatorStateLive(m.validatorState) {
|
||
return nil, fmt.Errorf(
|
||
"refusing to start K>1 quorum chain %s: height-indexed validator state is not wired "+
|
||
"(getValidatorState would supply the no-op State → zero stake at every height → "+
|
||
"VerifyWeighted fails closed → finality would stall permanently). The P-Chain must be "+
|
||
"created first and publish its validators.State; this is a wiring bug, not a runtime condition",
|
||
chainParams.ID)
|
||
}
|
||
// Height-indexed validator state is the SINGLE source of epoch truth for
|
||
// ALL FOUR epoch-pinned reads:
|
||
// membership, per-voter PUBKEY (the verifier), the ⅔-by-stake tally, and
|
||
// the set-root commitment. They are all read at the block's P-CHAIN epoch
|
||
// height so every node computes the IDENTICAL set/pubkeys/weights/root for
|
||
// a given block — independent of async current-map skew during a P-chain /
|
||
// L1-staking change. (Reading the CURRENT map made the signer and the
|
||
// assembler disagree on the set-root, and dropped the votes of validators
|
||
// that had left the current map but were members at the epoch — stalling
|
||
// finality at every staking change.)
|
||
vdrState := getValidatorState(m.validatorState)
|
||
// Vote verifier resolves the voter's pubkey from set@epoch,
|
||
// the SAME height-pinned source as the stake + set-root — NOT m.Validators
|
||
// (the current map).
|
||
voteVerifier := newBLSVoteVerifier(vdrState, networkID)
|
||
voteVerifier.log = chainLog
|
||
netCfg.VoteVerifier = voteVerifier
|
||
netCfg.VoteSigner = newBLSVoteSigner(m.StakingBLSKey)
|
||
// Stake-weighted finality: require a ⅔-of-stake supermajority,
|
||
// not just the α-of-K count, so a low-stake coalition cannot finalize.
|
||
netCfg.StakeSource = newValidatorStakeSource(vdrState, networkID)
|
||
// Epoch binding: pin every vote/cert to the weighted validator
|
||
// set IN FORCE AT the block's P-chain epoch height so the ⅔-by-stake
|
||
// predicate is enforced at that epoch — a cert gathered under one set
|
||
// cannot be re-verified against another (its signatures were over this
|
||
// height-pinned root).
|
||
netCfg.ValidatorSetRoot = newValidatorSetRootSource(vdrState, networkID)
|
||
// Durable non-equivocation guard: open the per-chain vote-once store so
|
||
// this signing validator's (height,epoch)→canonical bindings survive a
|
||
// crash/restart. Without it, a crash between casting a vote and finalizing the
|
||
// height would forget the binding and let the restarted node sign a conflicting
|
||
// sibling at that height — a cross-node fork with ZERO Byzantine intent under a
|
||
// rolling upgrade / eviction / OOM. Fail CLOSED on a corrupt store: a signer
|
||
// must not start with equivocation memory it cannot trust.
|
||
voteGuard, guardErr := consensuschain.OpenVoteGuard(filepath.Join(chainDataDir, "vote-guard"))
|
||
if guardErr != nil {
|
||
return nil, fmt.Errorf("refusing to start signing chain %s: durable vote-once guard: %w",
|
||
chainParams.ID, guardErr)
|
||
}
|
||
netCfg.VoteGuard = voteGuard
|
||
// b2: deliver the REAL P-chain epoch height to the engine. The four reads
|
||
// above are height-pinned but the engine reads that height off the VM
|
||
// block (pChainHeightOf) — and a bare plugin block exposes none, so the
|
||
// engine would resolve the set at P-chain height 0 (the GENESIS set),
|
||
// freezing the epoch and dropping every post-genesis validator's vote.
|
||
// Wrap the BlockBuilder so the block the engine builds/parses carries the
|
||
// proposer's live P-chain height (max(GetCurrentHeight, parentH)), stamped
|
||
// into the gossiped bytes so every follower adopts the IDENTICAL height —
|
||
// the set-root/stake/pubkey reads then track the LIVE set at that height.
|
||
// Installed ONLY here (K>1) AND ONLY when this chain is NOT wrapped in
|
||
// proposervm: when wrapInProposerVM is true the blockBuilder already IS
|
||
// the proposervm, whose SignedBlock carries the real P-chain height
|
||
// (selectChildPChainHeight = max(GetCurrentHeight, parentH)) and exposes
|
||
// PChainHeight() — the SAME value the engine's pChainHeightOf reads. That
|
||
// is precisely the proposervm mechanism newPChainHeightVM was a stand-in
|
||
// for, so stacking both would double-stamp the height. We keep
|
||
// newPChainHeightVM only for the unwrapped K>1 chains (P-Chain, X-Chain).
|
||
if blockBuilder != nil && !wrapInProposerVM {
|
||
blockBuilder = newPChainHeightVM(blockBuilder, vdrState, networkID)
|
||
netCfg.VM = blockBuilder
|
||
chainLog.Info("wired P-chain epoch height into consensus block builder (b2)",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Stringer("networkID", networkID))
|
||
}
|
||
}
|
||
// EXPORT-FRONTIER BRIDGE (two-tier consensus, v1.36). VM.Accept now advances the
|
||
// local NOVA (bare-majority) accept tip, which is reorgable and MUST NOT be exported.
|
||
// Push each EXPORT (Quasar, ⅔-by-stake) frontier advance into the VM so the EVM
|
||
// `finalized`/`safe` block tags and the warp cross-chain export gate resolve to the
|
||
// Quasar tip, NEVER the Nova tip (the "semantic collapse" the split exists to prevent).
|
||
//
|
||
// Capability-gated, not just interface-gated: the C-Chain EVM runs as a SEPARATE
|
||
// rpcchainvm plugin process, so vmTyped here is the rpcchainvm *Client, which carries
|
||
// SetLastQuasarFinalized/LastQuasarHeight for EVERY plugin (they cross the ZAP
|
||
// boundary). The client learns from the plugin's Initialize handshake whether the
|
||
// concrete VM actually implements the export capability and reports it via
|
||
// SupportsQuasarExport — false → the observer stays unwired and this chain is Nova-only
|
||
// with no per-finalization cross-process no-op. A VM WITHOUT the probe (an in-process
|
||
// VM whose concrete export methods we hold directly) is treated as capable, preserving
|
||
// the direct-wire path. Push into the RAW inner VM (vmTyped) — the eth/warp backends
|
||
// live there, not on the proposervm wrapper.
|
||
//
|
||
// Ordering: the observer MUST be set on netCfg BEFORE NewRuntime captures it; the boot
|
||
// re-seed needs the constructed engine and so runs after. exportVM (nil unless capable)
|
||
// carries the wired/not-wired decision across that split — a value, not a re-derived
|
||
// predicate.
|
||
var exportVM quasarExportVM
|
||
if qvm, ok := vmTyped.(quasarExportVM); ok {
|
||
capable := true
|
||
if probe, hasProbe := vmTyped.(interface{ SupportsQuasarExport() bool }); hasProbe {
|
||
capable = probe.SupportsQuasarExport()
|
||
}
|
||
if capable {
|
||
exportVM = qvm
|
||
netCfg.QuasarObserver = func(_ ids.ID, height uint64) {
|
||
qvm.SetLastQuasarFinalized(height)
|
||
}
|
||
chainLog.Info("wired EXPORT-frontier (quasar) bridge into the VM (finalized/safe + warp gate track ⅔-stake finality)",
|
||
log.Stringer("chainID", chainParams.ID))
|
||
}
|
||
}
|
||
consensusEngine := consensuschain.NewRuntime(netCfg)
|
||
|
||
// Re-seed the consensus EXPORT frontier from the VM's DURABLE Quasar height so
|
||
// GetQuasarTip / QuasarHeight do not regress on restart (the in-memory frontier resets
|
||
// to (Empty,0) until a fresh ⅔-stake cert re-forms; the VM persisted the export height).
|
||
// Advance-only; the observer above refines it as new certs land this session.
|
||
if exportVM != nil {
|
||
if h := exportVM.LastQuasarHeight(); h > 0 {
|
||
consensusEngine.SyncQuasarFrontier(ids.Empty, h)
|
||
chainLog.Info("re-seeded consensus export (quasar) frontier from the VM's durable height on boot",
|
||
log.Stringer("chainID", chainParams.ID), log.Uint64("quasarHeight", h))
|
||
}
|
||
}
|
||
|
||
// Start the consensus engine with a LIFETIME context (not a timeout):
|
||
// engine.Start parents all four long-running loops (poll, vote, pipeline,
|
||
// re-poll) to this ctx, so a WithTimeout here kills them ~30s after the
|
||
// chain starts and the quorum cert never assembles — the finality wedge.
|
||
// Do not reintroduce a timeout here.
|
||
if err := consensusEngine.Start(context.Background(), true); err != nil {
|
||
chainLog.Error("failed to start consensus engine",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Err(err))
|
||
return nil, fmt.Errorf("failed to start consensus engine: %w", err)
|
||
}
|
||
chainLog.Info("consensus engine started with Lux consensus (Photon → Wave → Focus)",
|
||
log.Stringer("chainID", chainParams.ID))
|
||
if blockBuilder != nil {
|
||
syncCtx, syncCancel := context.WithTimeout(context.Background(), vmStartupTimeout)
|
||
defer syncCancel()
|
||
lastAcceptedID, height, err := consensuschain.SyncStateFromVM(syncCtx, blockBuilder, consensusEngine.Transitive)
|
||
if err != nil {
|
||
chainLog.Warn("failed to sync consensus state from VM",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Stringer("lastAccepted", lastAcceptedID),
|
||
log.Uint64("height", height),
|
||
log.Err(err))
|
||
} else {
|
||
chainLog.Info("synced consensus state from VM",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Stringer("lastAccepted", lastAcceptedID),
|
||
log.Uint64("height", height))
|
||
}
|
||
}
|
||
|
||
// Bridge engineVM's WaitForEvent to the toEngine channel.
|
||
// ForwardVMNotifications reads from toEngine; this goroutine is the sole
|
||
// writer. For a WRAPPED chain engineVM is the proposervm, whose WaitForEvent
|
||
// is the single-proposer gate: when the chain is Ready it returns PendingTxs
|
||
// ONLY inside THIS node's proposer window (timeToBuild → Windower.
|
||
// MinDelayForProposer → one scheduled leader per (height, slot)); every other
|
||
// validator BLOCKS until its later slot, by which time the leader has built,
|
||
// gossiped, and finalized that height, so they advance and never build it.
|
||
// That is the invariant the bare-inner WaitForEvent dropped (everyone built
|
||
// every height → conflicting certs → equivocation crash). For an unwrapped
|
||
// chain engineVM is the inner VM — original behavior.
|
||
go func() {
|
||
ctx := context.Background()
|
||
for {
|
||
// Call WaitForEvent on the VM - this blocks until there are pending txs
|
||
// or staker changes that should trigger block building
|
||
msg, err := engineVM.WaitForEvent(ctx)
|
||
if err != nil {
|
||
if ctx.Err() != nil {
|
||
// Context cancelled, exit gracefully
|
||
return
|
||
}
|
||
chainLog.Warn("WaitForEvent error, retrying",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Err(err))
|
||
time.Sleep(time.Second)
|
||
continue
|
||
}
|
||
|
||
// WaitForEvent now returns vm.Message directly
|
||
chainLog.Debug("[VM NOTIFICATION] WaitForEvent returned",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Uint32("messageType", uint32(msg.Type)))
|
||
toEngine <- msg
|
||
chainLog.Debug("[VM NOTIFICATION] Sent to toEngine channel",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Uint32("msgType", uint32(msg.Type)))
|
||
}
|
||
}()
|
||
|
||
// Forward VM notifications to consensus (single goroutine)
|
||
go consensusEngine.ForwardVMNotifications(toEngine)
|
||
|
||
chainName := chainParams.Name
|
||
if chainName == "" {
|
||
chainName = chainRuntime.ChainID.String()
|
||
}
|
||
// Handler parses inbound blocks through the SAME builder the engine emits
|
||
// through (blockBuilder == the P-chain-height wrapper on K>1, the inner VM
|
||
// on K==1), so the container bytes it parses match the bytes the engine
|
||
// framed — one codec, no raw-vs-wrapped split.
|
||
// engineVM is the connectable VM (the proposervm on a wrapped chain, the
|
||
// inner VM otherwise); it carries Connected/Disconnected, which the router
|
||
// dispatches so the P-chain uptime tracker observes validator connectivity.
|
||
//
|
||
// consensusParams.K > 1 is the SAME predicate that wired netCfg.VoteVerifier
|
||
// above, and the verifier is exactly what makes the engine authenticate a
|
||
// vote before tallying it. Passing it here tells the handler that an inbound
|
||
// unsigned Chits can never become a counted vote on this chain, so it must
|
||
// not be converted into one (nova_poll.go).
|
||
bh := newBlockHandler(blockBuilder, engineVM, chainLog, consensusEngine, m.Net, m.MsgCreator, chainParams.ID, networkID, beacons, m.NodeID, expectsStakedBeacons, consensusParams.K > 1)
|
||
// Gate this native chain's bootstrap frontier-TRUST on the P-chain having finished its
|
||
// initial sync, so the staked beacon set (and thus the stake-majority floor denominator)
|
||
// is the TRUE full validator set, not a partial mid-replay set. Wired ONLY for native
|
||
// non-platform chains (expectsStakedBeacons) — the P-chain has no such dependency (it
|
||
// anchors to its own configured CustomBeacons) and must not gate on itself. The signal is
|
||
// m.pChainBootstrapped, published by monitorBootstrap when the P-chain completes initial sync.
|
||
if expectsStakedBeacons {
|
||
bh.primaryNetworkReady = m.pChainBootstrapped.Load
|
||
}
|
||
// Wire the gated normal-operation transition. The bootstrap driver calls this ONCE
|
||
// initial sync has reached the named frontier (runInitialSync) — never before, so the
|
||
// VM cannot serve / build at a stale height. nil when the VM exposes no SetState
|
||
// (degenerate / non-EVM paths): runInitialSync then just marks ready (nothing to
|
||
// transition). The closure captures engineVM: for a wrapped chain SetState(Ready)
|
||
// both (a) flips proposervm.consensusState to Ready — the gate proposervm.
|
||
// WaitForEvent reads to ENGAGE the proposer window (single-proposer turns on
|
||
// exactly at go-live, not at a stale height) — and (b) forwards Ready to the
|
||
// inner VM so the EVM starts building/gossiping. Routing this to the inner VM
|
||
// instead would start the EVM but leave the window disengaged.
|
||
if stateVM, ok := engineVM.(interface {
|
||
SetState(context.Context, uint32) error
|
||
}); ok {
|
||
chainID := chainParams.ID
|
||
bh.vmReady = func(ctx context.Context) error {
|
||
chainLog.Info("initial sync reached the frontier — transitioning VM to normal operation",
|
||
log.Stringer("chainID", chainID))
|
||
return stateVM.SetState(ctx, uint32(vm.Ready))
|
||
}
|
||
}
|
||
// Late-bind the handler as the engine's catch-up wire: it owns requestContext
|
||
// (send GetAncestors) and handleContext -> Put -> engine (deliver the fetched
|
||
// ancestors), so a follower that falls behind now self-heals instead of
|
||
// looping on an unfinalizable orphan.
|
||
catchup.handler = bh
|
||
// Start the proactive frontier poller: a node that received NO gossip (the
|
||
// stranded-validator case) has no other trigger to discover it is behind, so
|
||
// it periodically asks peers for their accepted tip and pulls the gap (with
|
||
// certs) through the catch-up transport. Node-lifetime goroutine (same ctx
|
||
// model as the WaitForEvent bridge above).
|
||
pollerCtx, pollerCancel := context.WithCancel(context.Background())
|
||
bh.pollerCancel = pollerCancel
|
||
// Drive INITIAL SYNC first (fetch+execute from the local tip to the network
|
||
// frontier — the fix for a node that previously declared itself bootstrapped at
|
||
// its local height and synced nothing), then hand off to the live frontier
|
||
// poller. bootstrapDone is the REAL ready signal monitorBootstrap gates on. See
|
||
// bootstrap_sync.go.
|
||
go bh.runBootstrapThenPoll(pollerCtx)
|
||
// A chain that stops moving must be visible without reading a log. Shares the
|
||
// poller's lifetime, so it ends when the handler does. See observe.go.
|
||
go bh.observe(pollerCtx, chainMetricsReg)
|
||
createdChain = &chainInfo{
|
||
Name: chainName,
|
||
VMID: chainParams.VMID,
|
||
Runtime: chainRuntime,
|
||
// engineVM = the proposervm for a wrapped chain (else the inner VM).
|
||
// HTTP registration calls engineVM.CreateHandlers, and proposervm.
|
||
// CreateHandlers forwards the inner VM's handlers (the C-Chain /rpc) and
|
||
// adds /proposervm — so endpoints are preserved. Shutdown/health route
|
||
// through proposervm to the inner.
|
||
VM: engineVM,
|
||
Engine: consensusEngine, // Use real consensus engine directly
|
||
Handler: bh,
|
||
}
|
||
default:
|
||
return nil, fmt.Errorf("unsupported VM type: %T", vmImpl)
|
||
}
|
||
|
||
vmGatherer, err := m.getOrMakeVMGatherer(chainParams.VMID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
_ = vmGatherer
|
||
|
||
return createdChain, nil
|
||
}
|
||
|
||
func (m *manager) AddRegistrant(r Registrant) {
|
||
m.registrants = append(m.registrants, r)
|
||
}
|
||
|
||
// dagVMAdapter adapts a DAG VM to interfaces.VM for HTTP handler registration
|
||
type dagVMAdapter struct {
|
||
underlying interface{}
|
||
}
|
||
|
||
func (v *dagVMAdapter) CreateHandlers(ctx context.Context) (map[string]http.Handler, error) {
|
||
if h, ok := v.underlying.(interface {
|
||
CreateHandlers(context.Context) (map[string]http.Handler, error)
|
||
}); ok {
|
||
return h.CreateHandlers(ctx)
|
||
}
|
||
return map[string]http.Handler{}, nil
|
||
}
|
||
|
||
func (v *dagVMAdapter) CreateStaticHandlers(ctx context.Context) (map[string]http.Handler, error) {
|
||
if h, ok := v.underlying.(interface {
|
||
CreateStaticHandlers(context.Context) (map[string]http.Handler, error)
|
||
}); ok {
|
||
return h.CreateStaticHandlers(ctx)
|
||
}
|
||
return map[string]http.Handler{}, nil
|
||
}
|
||
|
||
func (v *dagVMAdapter) HealthCheck(ctx context.Context) (interface{}, error) {
|
||
return map[string]interface{}{"healthy": true}, nil
|
||
}
|
||
|
||
func (v *dagVMAdapter) NewHTTPHandler(ctx context.Context) (http.Handler, error) {
|
||
return nil, nil
|
||
}
|
||
|
||
func (v *dagVMAdapter) SetState(ctx context.Context, state vm.State) error {
|
||
if s, ok := v.underlying.(interface {
|
||
SetState(context.Context, uint32) error
|
||
}); ok {
|
||
return s.SetState(ctx, uint32(state))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (v *dagVMAdapter) Shutdown(ctx context.Context) error {
|
||
if s, ok := v.underlying.(interface {
|
||
Shutdown(context.Context) error
|
||
}); ok {
|
||
return s.Shutdown(ctx)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (v *dagVMAdapter) Version(ctx context.Context) (string, error) {
|
||
return "1.0.0", nil
|
||
}
|
||
|
||
func (v *dagVMAdapter) Initialize(
|
||
ctx context.Context,
|
||
chainRuntime *runtime.Runtime,
|
||
dbMgr dbmanager.Manager,
|
||
genesisBytes []byte,
|
||
upgradeBytes []byte,
|
||
configBytes []byte,
|
||
toEngine chan<- vm.Message,
|
||
fxs []*vm.Fx,
|
||
appSender interface{},
|
||
) error {
|
||
return nil // DAG VMs are pre-initialized
|
||
}
|
||
|
||
// createDAG creates a DAG chain (X-Chain, Q-Chain) using the VM's DAG engine
|
||
func (m *manager) createDAG(
|
||
rt *runtime.Runtime,
|
||
chainParams ChainParameters,
|
||
vmImpl interface{},
|
||
fxs []*vm.Fx,
|
||
) (*chainInfo, error) {
|
||
// Type assert to get GetEngine() method from exchangevm/qvm
|
||
dagVM, ok := vmImpl.(interface{ GetEngine() consensusdag.Engine })
|
||
if !ok {
|
||
return nil, fmt.Errorf("VM does not implement GetEngine() for DAG consensus")
|
||
}
|
||
|
||
m.Log.Info("creating DAG chain",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.String("vmID", chainParams.VMID.String()),
|
||
)
|
||
|
||
// Register chain aliases early so the VM's address parser can resolve them.
|
||
// The "X" prefix in addresses like "X-dev1..." must resolve to the actual blockchain ID.
|
||
if err := m.Alias(chainParams.ID, chainParams.ID.String()); err != nil {
|
||
m.Log.Warn("failed to alias chain with itself", log.Err(err))
|
||
}
|
||
if strings.EqualFold(chainParams.Name, "X-Chain") {
|
||
_ = m.Alias(chainParams.ID, "X")
|
||
} else if strings.EqualFold(chainParams.Name, "Q-Chain") {
|
||
_ = m.Alias(chainParams.ID, "Q")
|
||
}
|
||
|
||
// Get chain configuration
|
||
chainConfig, err := m.getChainConfig(chainParams.ID)
|
||
if err != nil {
|
||
m.Log.Warn("failed to get chain config, using empty config",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Err(err))
|
||
chainConfig = ChainConfig{}
|
||
}
|
||
|
||
// Inject automining config for dev mode (applies to the C-Chain EVM only)
|
||
chainConfig.Config = m.injectAutominingConfig(chainParams.VMID, chainConfig.Config)
|
||
|
||
// Get chain alias for database directory naming
|
||
chainAlias := chainParams.ID.String()
|
||
if aliases, _ := m.Aliases(chainParams.ID); len(aliases) > 0 {
|
||
chainAlias = aliases[0] // Use first alias (e.g., "X", "Q")
|
||
}
|
||
|
||
// Get VM database from chain database manager
|
||
// In isolated mode, each chain gets its own ZapDB
|
||
// In legacy mode, uses prefixdb on shared database
|
||
vmDB, err := m.chainDBManager.GetVMDatabase(chainParams.ID, chainAlias)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to get database for chain %s: %w", chainParams.ID, err)
|
||
}
|
||
|
||
// Create a context for VM initialization with timeout
|
||
initCtx, cancelInit := context.WithTimeout(context.Background(), vmStartupTimeout)
|
||
defer cancelInit() // Ensure cleanup on function exit
|
||
|
||
// Initialize VM if it supports Initialize
|
||
// Try multiple Initialize signatures since VMs may have different interfaces
|
||
vmInitialized := false
|
||
|
||
// Try QVM Initialize signature (uses consensus/core types)
|
||
if initVM, ok := vmImpl.(interface {
|
||
Initialize(
|
||
ctx context.Context,
|
||
chainRuntime interface{},
|
||
db database.Database,
|
||
genesisBytes []byte,
|
||
upgradeBytes []byte,
|
||
configBytes []byte,
|
||
toEngine chan<- vm.Message,
|
||
fxs []*vm.Fx,
|
||
appSender warp.Sender,
|
||
) error
|
||
}); ok {
|
||
toEngine := make(chan vm.Message, 1)
|
||
err := initVM.Initialize(
|
||
initCtx,
|
||
rt,
|
||
vmDB,
|
||
chainParams.GenesisData,
|
||
chainConfig.Upgrade,
|
||
chainConfig.Config,
|
||
toEngine,
|
||
fxs,
|
||
&chainSender{net: m.Net, msgCreator: m.MsgCreator, chainID: chainParams.ID, log: m.Log},
|
||
)
|
||
if err != nil {
|
||
m.Log.Warn("QVM-style initialization failed", log.Stringer("chainID", chainParams.ID), log.Err(err))
|
||
} else {
|
||
m.Log.Info("QVM initialized successfully", log.Stringer("chainID", chainParams.ID))
|
||
vmInitialized = true
|
||
}
|
||
}
|
||
|
||
// Try ExchangeVM Initialize signature (uses interface{} types for flexibility)
|
||
if !vmInitialized {
|
||
if initVM, ok := vmImpl.(interface {
|
||
Initialize(
|
||
ctx context.Context,
|
||
chainRuntime interface{},
|
||
dbManager interface{},
|
||
genesisBytes []byte,
|
||
upgradeBytes []byte,
|
||
configBytes []byte,
|
||
toEngine chan<- interface{},
|
||
fxs []interface{},
|
||
appSender interface{},
|
||
) error
|
||
}); ok {
|
||
toEngine := make(chan interface{}, 1)
|
||
// Convert fxs to []interface{}
|
||
fxsInterface := make([]interface{}, len(fxs))
|
||
for i, fx := range fxs {
|
||
fxsInterface[i] = fx
|
||
}
|
||
err := initVM.Initialize(
|
||
initCtx,
|
||
rt,
|
||
vmDB,
|
||
chainParams.GenesisData,
|
||
chainConfig.Upgrade,
|
||
chainConfig.Config,
|
||
toEngine,
|
||
fxsInterface,
|
||
&chainSender{net: m.Net, msgCreator: m.MsgCreator, chainID: chainParams.ID, log: m.Log},
|
||
)
|
||
if err != nil {
|
||
m.Log.Warn("ExchangeVM-style initialization failed", log.Stringer("chainID", chainParams.ID), log.Err(err))
|
||
} else {
|
||
m.Log.Info("ExchangeVM initialized successfully", log.Stringer("chainID", chainParams.ID))
|
||
vmInitialized = true
|
||
}
|
||
}
|
||
}
|
||
|
||
// Try vmcore.Init struct-based Initialize (used by exchangevm)
|
||
if !vmInitialized {
|
||
if initVM, ok := vmImpl.(interface {
|
||
Initialize(context.Context, vm.Init) error
|
||
}); ok {
|
||
toEngine := make(chan vm.Message, 1)
|
||
// Convert []*vm.Fx to []any for vm.Init.Fx field
|
||
fxAny := make([]any, len(fxs))
|
||
for i, fx := range fxs {
|
||
fxAny[i] = fx
|
||
}
|
||
err := initVM.Initialize(initCtx, vm.Init{
|
||
Runtime: rt,
|
||
DB: vmDB,
|
||
Genesis: chainParams.GenesisData,
|
||
Upgrade: chainConfig.Upgrade,
|
||
Config: chainConfig.Config,
|
||
ToEngine: toEngine,
|
||
Fx: fxAny,
|
||
})
|
||
if err != nil {
|
||
m.Log.Warn("vmcore.Init-style initialization failed",
|
||
log.Stringer("chainID", chainParams.ID), log.Err(err))
|
||
} else {
|
||
m.Log.Info("ExchangeVM initialized via vmcore.Init",
|
||
log.Stringer("chainID", chainParams.ID))
|
||
vmInitialized = true
|
||
}
|
||
}
|
||
}
|
||
|
||
// Linearize the DAG chain (required for X-Chain).
|
||
// This transitions the chain from DAG mode to linear block mode.
|
||
if vmInitialized {
|
||
if linearVM, ok := vmImpl.(interface {
|
||
Linearize(context.Context, ids.ID, chan<- vm.Message) error
|
||
}); ok {
|
||
toEngine := make(chan vm.Message, 1)
|
||
if err := linearVM.Linearize(initCtx, ids.Empty, toEngine); err != nil {
|
||
m.Log.Warn("failed to linearize DAG chain", log.Stringer("chainID", chainParams.ID), log.Err(err))
|
||
} else {
|
||
m.Log.Info("DAG chain linearized successfully", log.Stringer("chainID", chainParams.ID))
|
||
}
|
||
}
|
||
}
|
||
|
||
// Transition the DAG VM to normal operation if initialization succeeded.
|
||
//
|
||
// DELIBERATE SCOPE: this go-live is UNCONDITIONAL — unlike the LINEAR-chain path
|
||
// (buildChain → runInitialSync), it does NOT gate on a node-layer initial sync reaching the
|
||
// network frontier. The gated lifecycle is implemented only for native LINEAR chains
|
||
// (C/D/B/T/Z), which use consensuschain.Runtime + the blockHandler bootstrap wiring
|
||
// (bootstrap_sync.go). DAG chains (X/Q) run a different engine (consensusdag) with no such
|
||
// node-layer fetch+execute loop, so the stale-go-live gate cannot simply be reused here; adding
|
||
// it requires building the DAG equivalent of the linear bootstrap loop. That is tracked as a
|
||
// SEPARATE task — RECOMMENDED to stay separate, because the chains that can go live at a stale
|
||
// height with producer state at risk are the linear ones (P plus linear C/D/B/T/Z); X/Q carry
|
||
// no such state. This call is UNCHANGED by this fix — the DAG path neither regresses nor
|
||
// improves here; it retains its prior behavior pending that task.
|
||
if vmInitialized {
|
||
if stateVM, ok := vmImpl.(interface {
|
||
SetState(context.Context, uint32) error
|
||
}); ok {
|
||
if err := stateVM.SetState(initCtx, uint32(vm.Ready)); err != nil {
|
||
m.Log.Warn("failed to transition VM to normal op", log.Stringer("chainID", chainParams.ID), log.Err(err))
|
||
}
|
||
}
|
||
}
|
||
|
||
// Get and start the DAG engine
|
||
dagEngine := dagVM.GetEngine()
|
||
if starter, ok := dagEngine.(interface {
|
||
Start(context.Context, uint32) error
|
||
}); ok {
|
||
if err := starter.Start(context.Background(), 0); err != nil {
|
||
return nil, fmt.Errorf("failed to start DAG engine: %w", err)
|
||
}
|
||
}
|
||
|
||
m.Log.Info("DAG chain created successfully",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.String("status", "using native DAG consensus"),
|
||
)
|
||
|
||
// Register HTTP handlers for DAG VMs (exchangevm, qvm, etc.)
|
||
adapter := &dagVMAdapter{underlying: vmImpl}
|
||
dagHandlerCtx, dagHandlerCancel := context.WithTimeout(context.Background(), vmStartupTimeout)
|
||
defer dagHandlerCancel()
|
||
handlers, err := adapter.CreateHandlers(dagHandlerCtx)
|
||
if err != nil {
|
||
m.Log.Warn("failed to create HTTP handlers for DAG chain",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Err(err),
|
||
)
|
||
} else if len(handlers) > 0 {
|
||
chainIDStr := chainParams.ID.String()
|
||
for endpoint, handler := range handlers {
|
||
// A DAG chain hangs from the same segment as every other chain, and
|
||
// says so when a registration does not take. Its routes were once
|
||
// written out here under a segment of their own, which is how a chain
|
||
// came to be running, serving, and absent from the address the rest of
|
||
// the node publishes.
|
||
route := func(base string) {
|
||
if err := m.Server.AddRoute(handler, constants.ChainAliasPrefix+"/"+base, endpoint); err != nil {
|
||
m.Log.Error("failed to add DAG chain route",
|
||
log.String("base", base),
|
||
log.String("endpoint", endpoint),
|
||
log.Err(err),
|
||
)
|
||
}
|
||
}
|
||
|
||
route(chainIDStr)
|
||
// Register name alias (e.g., "x-chain")
|
||
if chainParams.Name != "" {
|
||
route(strings.ToLower(chainParams.Name))
|
||
}
|
||
// Register standard single-letter alias
|
||
for alias, name := range map[string]string{
|
||
"X": "X-Chain", "Q": "Q-Chain",
|
||
} {
|
||
if strings.EqualFold(chainParams.Name, name) {
|
||
route(alias)
|
||
m.Log.Info("Registered DAG chain HTTP handler",
|
||
log.String("alias", alias),
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.String("endpoint", endpoint),
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
dagName := chainParams.Name
|
||
if dagName == "" {
|
||
dagName = chainParams.ID.String()
|
||
}
|
||
return &chainInfo{
|
||
Name: dagName,
|
||
VMID: chainParams.VMID,
|
||
Runtime: rt,
|
||
VM: adapter,
|
||
Handler: &noopHandler{},
|
||
}, nil
|
||
}
|
||
|
||
// errBootstrapTimeout is returned when a chain fails to bootstrap within the timeout period
|
||
var errBootstrapTimeout = errors.New("chain failed to bootstrap within timeout")
|
||
|
||
// monitorBootstrap monitors when a chain finishes bootstrapping and notifies the chain.
|
||
// This is critical for health checks because the health check queries m.Nets.Bootstrapping()
|
||
// which returns chains that have chains still in bootstrapping state. Without this notification,
|
||
// the health check would permanently report "chains not bootstrapped".
|
||
//
|
||
// IMPORTANT: If bootstrap times out, the chain is NOT marked as bootstrapped. This ensures
|
||
// real bootstrap failures are surfaced rather than masked by forcing a "ready" state.
|
||
func (m *manager) monitorBootstrap(engine Engine, h handler.Handler, sb network.Net, chainID ids.ID) {
|
||
// The READY signal is the handler's real initial-sync completion: the bootstrap
|
||
// loop has fetched+executed up to the discovered network frontier. This REPLACES
|
||
// the old engine.IsBootstrapped() poll, which returned true the instant the engine
|
||
// started — at whatever LOCAL last-accepted height the node had (genesis for an
|
||
// empty DB) — so a behind/empty node was declared bootstrapped before syncing a
|
||
// single block. A handler that does not drive initial sync (no BootstrapComplete)
|
||
// falls back to the engine's started flag (degenerate / DAG-less paths).
|
||
type bootstrapCompleter interface{ BootstrapComplete() bool }
|
||
type bootstrapChecker interface{ IsBootstrapped() bool }
|
||
// bootstrapFailer surfaces a fail-SAFE RETURN from the sync loop (F5). A handler that drives
|
||
// initial sync implements it; the watchdog then stops the chain the moment Run returns
|
||
// (eclipse / partition / deep gap) instead of waiting out the 5-min no-progress window.
|
||
type bootstrapFailer interface {
|
||
BootstrapFailed() bool
|
||
BootstrapFailure() error
|
||
}
|
||
|
||
var ready func() bool
|
||
if completer, ok := h.(bootstrapCompleter); ok {
|
||
ready = completer.BootstrapComplete
|
||
} else if checker, ok := engine.(bootstrapChecker); ok {
|
||
ready = checker.IsBootstrapped
|
||
} else {
|
||
// Cannot check readiness — assume ready (safe: if we cannot tell, do not pin
|
||
// the chain unbootstrapped forever).
|
||
m.Log.Info("no bootstrap-completion signal available, marking chain as bootstrapped",
|
||
log.Stringer("chainID", chainID))
|
||
sb.Bootstrapped(chainID)
|
||
return
|
||
}
|
||
|
||
// PROGRESS signal (H2). The hard 5-minute timer that was set ONCE and never reset
|
||
// killed a healthy-but-slow initial sync (a deep gap legitimately takes longer than
|
||
// 5 minutes). Instead we watch the locally-accepted height: as long as it keeps
|
||
// ADVANCING, the sync is making progress and the stall clock is reset. Only a
|
||
// genuine NO-PROGRESS stall — no height gain for the whole window — is a real failure.
|
||
type bootstrapHeighter interface{ BootstrapHeight() uint64 }
|
||
var heightOf func() uint64
|
||
if hp, ok := h.(bootstrapHeighter); ok {
|
||
heightOf = hp.BootstrapHeight
|
||
}
|
||
|
||
// F5 fail-safe RETURN predicate + reason getter (nil for degenerate handlers).
|
||
var failed func() bool
|
||
var failure func() error
|
||
if f, ok := h.(bootstrapFailer); ok {
|
||
failed = f.BootstrapFailed
|
||
failure = f.BootstrapFailure
|
||
}
|
||
|
||
// WAIT reason. A handler that is in its bounded connectivity-RETRY wait (a transient
|
||
// quorum-unreachable fail-safe it is re-attempting) reports why; anything else reports
|
||
// empty. The no-progress watchdog treats a non-empty reason as a deliberate quorum WAIT,
|
||
// not a stall: it must NOT force-STOP a node that is correctly failing safe DOWN and waiting
|
||
// for its quorum to return. The network cannot progress without the quorum, and nothing
|
||
// outside the process can tell a deliberate wait from a stall, so a stop here is permanent.
|
||
// The node stays in Bootstrapping (serving nothing as head) and converges when the quorum
|
||
// returns. nil for degenerate handlers.
|
||
type bootstrapWaiter interface{ BootstrapWait() string }
|
||
var waiting func() string
|
||
if w, ok := h.(bootstrapWaiter); ok {
|
||
waiting = w.BootstrapWait
|
||
}
|
||
|
||
// Run the progress-based watchdog (decomplected into watchBootstrapProgress so the
|
||
// loop is unit-testable), then handle the manager-side outcome.
|
||
bootstrapStallTimeout := m.stallWindow
|
||
if bootstrapStallTimeout <= 0 {
|
||
bootstrapStallTimeout = 5 * time.Minute
|
||
}
|
||
// ONE per-chain check carries WHY this chain has not finished initial sync. The reason is
|
||
// swapped as the condition changes, so a quorum wait that later becomes a no-progress stall
|
||
// (or the reverse) reports what is true NOW rather than whichever arrived first.
|
||
var reason gatomic.Pointer[string]
|
||
reported := false
|
||
report := func(why string, atHeight uint64) {
|
||
reason.Store(&why)
|
||
if reported {
|
||
return
|
||
}
|
||
reported = true
|
||
m.reportBootstrap(chainID, ready, atHeight, &reason)
|
||
}
|
||
for {
|
||
outcome, stalledAtHeight, why := watchBootstrapProgress(ready, failed, waiting, heightOf, 100*time.Millisecond, bootstrapStallTimeout, m.chainCreatorShutdownCh)
|
||
switch outcome {
|
||
case bootstrapReady:
|
||
m.Log.Info("chain finished bootstrapping, notifying chain",
|
||
log.Stringer("chainID", chainID))
|
||
// The P-chain completing initial sync means the staked primary-network validator set is
|
||
// now FULLY loaded (every staker tx replayed). Publish that so native non-platform chains
|
||
// can safely begin trusting their beacon set (blockHandler.primaryNetworkReady → the
|
||
// partial-set gate). Set BEFORE sb.Bootstrapped so the signal is never lagging the readiness.
|
||
if chainID == constants.PlatformChainID {
|
||
m.pChainBootstrapped.Store(true)
|
||
}
|
||
sb.Bootstrapped(chainID)
|
||
// The M-Chain's committed state is now readable, so entitlement can be
|
||
// decided. Drain any restricted chains parked waiting for it. This must
|
||
// happen HERE rather than at M-Chain creation: M is an engine chain marked
|
||
// bootstrapped asynchronously, so draining at creation would re-run the
|
||
// check while the answer was still "not ready", re-park every chain, and
|
||
// leave them parked with nothing left to drain them.
|
||
if chainID == m.MChainID {
|
||
m.retryPendingRestrictedChains()
|
||
}
|
||
return
|
||
case bootstrapShutdown:
|
||
// Manager is shutting down.
|
||
return
|
||
case bootstrapStalled:
|
||
// A no-progress window says the sync did not advance during THAT WINDOW. It does
|
||
// not say the chain cannot finish, and stopping the engine here turns a slow sync
|
||
// into a chain that can never sync: StopWithError cancels the chain's context, so
|
||
// every later VM.Accept returns "context canceled", the finality guard refuses to
|
||
// advance past the VM's applied state, and the only exit is a pod restart that can
|
||
// stall again the same way.
|
||
//
|
||
// A node declared stalled and stopped can go on to reach the network's height in
|
||
// its VM and still refuse every incoming cert: it has the blocks, and the verdict
|
||
// outlived the condition that produced it.
|
||
//
|
||
// So report it and KEEP WATCHING. The chain stays out of Bootstrapped (it is not
|
||
// synced, and masking that is the other failure), the health check shows exactly
|
||
// why, and if the sync converges the next pass marks it bootstrapped by itself.
|
||
// Only bootstrapFailed below — where the sync loop ITSELF returned "I cannot
|
||
// proceed" — is terminal, because there the engine is reporting its own verdict
|
||
// rather than a watchdog inferring one.
|
||
report("bootstrap stalled (no progress)", stalledAtHeight)
|
||
continue
|
||
case bootstrapWaiting:
|
||
// The driver is in its bounded connectivity RETRY, waiting for the beacon quorum to
|
||
// become reachable. It is neither stopped nor failed and converges the instant the
|
||
// quorum returns, so the handling is unchanged — keep watching. What changes is that
|
||
// it now SAYS SO, in the driver's own numbers. This is the one state a chain can hold
|
||
// forever, and it published nothing: the node answered 503 naming a chain id and no
|
||
// reason, which reads exactly like a chain nobody has looked at.
|
||
report(why, stalledAtHeight)
|
||
continue
|
||
case bootstrapFailed:
|
||
// F5: the sync loop RETURNED a fail-safe error (eclipse / partition / deep gap). Surface
|
||
// it PROMPTLY with the real reason — not after the 5-min no-progress watchdog. Same
|
||
// manager-side handling as a stall (stop engine + failing health check), but the instant
|
||
// Run returns and with the precise diagnostic so the operator knows to fix peering or
|
||
// state-sync rather than wait.
|
||
reason := errBootstrapTimeout
|
||
if failure != nil {
|
||
if e := failure(); e != nil {
|
||
reason = e
|
||
}
|
||
}
|
||
m.failBootstrapChain(engine, chainID, reason, stalledAtHeight, "bootstrap failed safe (eclipsed/partitioned/too-far-behind)")
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// reportBootstrap publishes WHY a chain has not finished initial sync on the chain's own
|
||
// health check, and leaves the engine RUNNING so the sync can still finish. The check
|
||
// re-reads ready() on every call rather than freezing the verdict, so it clears itself the
|
||
// moment the chain converges — a reason that has since been falsified stops being reported
|
||
// as a failure. It also re-reads `reason`, so the check states the CURRENT condition
|
||
// (quorum wait / no progress) rather than the one that happened to be first.
|
||
//
|
||
// This is the only place a not-yet-bootstrapped chain speaks, and it covers BOTH the
|
||
// no-progress stall and the deliberate quorum wait. Without the wait, a chain that is
|
||
// working as designed — retrying until its beacons return — was reported by the node-level
|
||
// check as a bare chain id with no reason, and the reason existed nowhere outside the
|
||
// process. Compare failBootstrapChain, which stops the engine and is reserved for a sync
|
||
// loop that reported its own failure.
|
||
func (m *manager) reportBootstrap(chainID ids.ID, ready func() bool, atHeight uint64, reason *gatomic.Pointer[string]) {
|
||
why := "not bootstrapped"
|
||
if r := reason.Load(); r != nil {
|
||
why = *r
|
||
}
|
||
m.Log.Warn("chain has not finished initial sync - NOT marked bootstrapped, still trying",
|
||
log.Stringer("chainID", chainID),
|
||
log.String("reason", why),
|
||
log.Uint64("height", atHeight))
|
||
|
||
chainAlias := m.PrimaryAliasOrDefault(chainID)
|
||
if err := m.Health.RegisterHealthCheck(
|
||
chainAlias+"-bootstrap",
|
||
health.CheckerFunc(func(context.Context) (interface{}, error) {
|
||
if ready != nil && ready() {
|
||
return map[string]interface{}{"chainID": chainID.String()}, nil
|
||
}
|
||
current := "not bootstrapped"
|
||
if r := reason.Load(); r != nil {
|
||
current = *r
|
||
}
|
||
return map[string]interface{}{
|
||
"chainID": chainID.String(),
|
||
"error": current,
|
||
"height": atHeight,
|
||
}, errBootstrapTimeout
|
||
}),
|
||
health.ApplicationTag,
|
||
); err != nil {
|
||
m.Log.Error("failed to register bootstrap health check",
|
||
log.Stringer("chainID", chainID),
|
||
log.Err(err))
|
||
}
|
||
}
|
||
|
||
// failBootstrapChain stops a chain whose initial sync did NOT reach the frontier — either a
|
||
// no-progress STALL (bootstrapStalled) or an explicit fail-SAFE return from the sync loop
|
||
// (bootstrapFailed, F5). It stops the engine with `reason` and registers a failing health check
|
||
// carrying it, so the operator sees the precise cause (no-progress vs eclipse/partition/deep-gap)
|
||
// and the chain is marked failed rather than silently half-started. Shared by both failure
|
||
// outcomes so the stop+health logic lives in exactly one place.
|
||
func (m *manager) failBootstrapChain(engine Engine, chainID ids.ID, reason error, atHeight uint64, summary string) {
|
||
m.Log.Error("chain bootstrap did not complete - chain NOT marked as bootstrapped",
|
||
log.Stringer("chainID", chainID),
|
||
log.Uint64("height", atHeight),
|
||
log.String("reason", summary),
|
||
log.Err(reason))
|
||
|
||
// Stop the engine with the real reason so the chain is marked failed.
|
||
if err := engine.StopWithError(context.Background(), reason); err != nil {
|
||
m.Log.Error("failed to stop engine after bootstrap failure",
|
||
log.Stringer("chainID", chainID),
|
||
log.Err(err))
|
||
}
|
||
|
||
// Register a health check that reports the bootstrap failure (carrying the real reason).
|
||
chainAlias := m.PrimaryAliasOrDefault(chainID)
|
||
healthErr := m.Health.RegisterHealthCheck(
|
||
chainAlias+"-bootstrap",
|
||
health.CheckerFunc(func(context.Context) (interface{}, error) {
|
||
return map[string]interface{}{
|
||
"chainID": chainID.String(),
|
||
"error": summary,
|
||
"height": atHeight,
|
||
}, reason
|
||
}),
|
||
health.ApplicationTag,
|
||
)
|
||
if healthErr != nil {
|
||
m.Log.Error("failed to register bootstrap failure health check",
|
||
log.Stringer("chainID", chainID),
|
||
log.Err(healthErr))
|
||
}
|
||
}
|
||
|
||
// bootstrapOutcome is the result of the progress-based bootstrap watchdog.
|
||
type bootstrapOutcome int
|
||
|
||
const (
|
||
bootstrapReady bootstrapOutcome = iota // ready() reported initial sync complete
|
||
bootstrapStalled // no height progress for the stall window
|
||
bootstrapShutdown // shutdown was signaled
|
||
bootstrapFailed // the sync loop RETURNED a fail-safe error — surface PROMPTLY (F5), not via the no-progress watchdog
|
||
bootstrapWaiting // the driver is waiting for its beacon quorum — a deliberate WAIT, reported so it is not silent
|
||
)
|
||
|
||
// watchBootstrapProgress is the PROGRESS-BASED bootstrap watchdog (H2), decomplected from
|
||
// the manager-side success/failure handling so it can be unit-tested. It polls ready()
|
||
// every tick and returns:
|
||
// - bootstrapReady the instant ready() is true;
|
||
// - bootstrapStalled when heightOf() has NOT advanced for stallTimeout — so a
|
||
// slow-but-ADVANCING sync (each tick the height climbs) resets the clock and is NEVER
|
||
// timed out, while a genuine no-progress stall fails after the window;
|
||
// - bootstrapShutdown when shutdown fires.
|
||
//
|
||
// It also returns the last observed height (the stall diagnostic) and, for bootstrapWaiting, the
|
||
// wait reason it decided on — the value it already read, rather than leaving the caller to ask a
|
||
// second time and get a different answer. heightOf may be nil (then the window is a fixed
|
||
// wall-clock budget from the start — the legacy behavior).
|
||
//
|
||
// failed (F5) is the PROMPT fail-safe predicate: when the sync loop RETURNS a fail-safe error
|
||
// (deep gap / disjoint peer / exhausted attempt bound) the driver flips it, and the watchdog returns
|
||
// bootstrapFailed the next tick — instead of waiting out stallTimeout for a chain that has already
|
||
// stopped trying. nil disables it (degenerate handlers with no fail-safe signal — legacy behavior).
|
||
//
|
||
// waiting is the deliberate-WAIT reason, empty when the driver is not waiting: when it is in its
|
||
// bounded connectivity-RETRY (a transient quorum-unreachable fail-safe it is re-attempting), the
|
||
// no-progress window is NOT a stall — the node is correctly failing safe DOWN and waiting for its
|
||
// quorum to return, so the clock is reset rather than force-STOPPING it, which nothing outside the
|
||
// process would undo. A genuine stall (not waiting, no height progress) still fails. nil disables
|
||
// it.
|
||
func watchBootstrapProgress(
|
||
ready func() bool,
|
||
failed func() bool,
|
||
waiting func() string,
|
||
heightOf func() uint64,
|
||
tick, stallTimeout time.Duration,
|
||
shutdown <-chan struct{},
|
||
) (bootstrapOutcome, uint64, string) {
|
||
ticker := time.NewTicker(tick)
|
||
defer ticker.Stop()
|
||
|
||
var lastHeight uint64
|
||
var heightKnown bool
|
||
lastProgress := time.Now()
|
||
|
||
for {
|
||
select {
|
||
case <-ticker.C:
|
||
if ready() {
|
||
return bootstrapReady, lastHeight, ""
|
||
}
|
||
// F5: a fail-safe RETURN from the sync loop is surfaced immediately — no point
|
||
// waiting out the no-progress window for a chain that has already given up.
|
||
if failed != nil && failed() {
|
||
return bootstrapFailed, lastHeight, ""
|
||
}
|
||
if heightOf != nil {
|
||
if cur := heightOf(); !heightKnown || cur > lastHeight {
|
||
lastHeight, heightKnown = cur, true
|
||
lastProgress = time.Now()
|
||
}
|
||
}
|
||
if time.Since(lastProgress) >= stallTimeout {
|
||
// A node in its connectivity-RETRY wait is NOT stalled — it is deliberately failing
|
||
// safe DOWN and waiting for its quorum to return (self-heal), and force-stopping it
|
||
// is the brick the K8s probes would never undo. So the WAIT is not a failure; it is
|
||
// REPORTED. The caller re-enters, which resets the clock exactly as before — the
|
||
// only change is that the state now leaves the process, because a wait with no end
|
||
// and no signal is indistinguishable from a broken chain to anyone outside it.
|
||
if waiting != nil {
|
||
if why := waiting(); why != "" {
|
||
return bootstrapWaiting, lastHeight, why
|
||
}
|
||
}
|
||
return bootstrapStalled, lastHeight, ""
|
||
}
|
||
case <-shutdown:
|
||
return bootstrapShutdown, lastHeight, ""
|
||
}
|
||
}
|
||
}
|
||
|
||
// IsBootstrapped reports whether [id] has ACTUALLY finished initial sync on this
|
||
// node: the chain exists AND its validation net has marked it bootstrapped —
|
||
// monitorBootstrap called sb.Bootstrapped(id) only after runInitialSync reached the
|
||
// named network frontier and transitioned the VM to normal operation (head advanced
|
||
// to the frontier, eth-RPC serving live). The old body returned true the instant the
|
||
// chain merely EXISTED in m.chains — set right after createChain launched the (async,
|
||
// possibly-stalling) bootstrap goroutine — so a C-Chain stalled at genesis (head 0x0,
|
||
// never converged) still reported info.isBootstrapped(C)=true, masking the stall from
|
||
// any readiness gate keyed on it. Keying on the SAME sb.Bootstrapped signal the health
|
||
// check (m.Nets.Bootstrapping) already uses makes info.isBootstrapped track real state,
|
||
// so a hands-off rolling upgrade's wait-for-healthy gate can trust it.
|
||
func (m *manager) IsBootstrapped(id ids.ID) bool {
|
||
m.chainsLock.Lock()
|
||
_, exists := m.chains[id]
|
||
m.chainsLock.Unlock()
|
||
if !exists {
|
||
return false
|
||
}
|
||
return m.Nets.IsChainBootstrapped(id)
|
||
}
|
||
|
||
func (m *manager) GetChains() []ChainInfo {
|
||
m.chainsLock.Lock()
|
||
defer m.chainsLock.Unlock()
|
||
|
||
result := make([]ChainInfo, 0, len(m.chains))
|
||
for id, info := range m.chains {
|
||
result = append(result, ChainInfo{
|
||
ID: id,
|
||
Name: info.Name,
|
||
VMID: info.VMID,
|
||
// Real per-chain convergence, not mere existence (same fix as IsBootstrapped):
|
||
// a tracked-but-still-syncing chain reports Bootstrapped=false.
|
||
Bootstrapped: m.Nets.IsChainBootstrapped(id),
|
||
})
|
||
}
|
||
return result
|
||
}
|
||
|
||
func (m *manager) registerBootstrappedHealthChecks() error {
|
||
bootstrappedCheck := health.CheckerFunc(func(context.Context) (interface{}, error) {
|
||
if chainIDs := m.Nets.Bootstrapping(); len(chainIDs) != 0 {
|
||
return chainIDs, errNotBootstrapped
|
||
}
|
||
return []ids.ID{}, nil
|
||
})
|
||
if err := m.Health.RegisterReadinessCheck("bootstrapped", bootstrappedCheck, health.ApplicationTag); err != nil {
|
||
return fmt.Errorf("couldn't register bootstrapped readiness check: %w", err)
|
||
}
|
||
if err := m.Health.RegisterHealthCheck("bootstrapped", bootstrappedCheck, health.ApplicationTag); err != nil {
|
||
return fmt.Errorf("couldn't register bootstrapped health check: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// Starts chain creation loop to process queued chains
|
||
func (m *manager) StartChainCreator(platformParams ChainParameters) error {
|
||
// Add the P-Chain to the Primary Network
|
||
sb, _ := m.Nets.GetOrCreate(constants.PrimaryNetworkID)
|
||
sb.AddChain(platformParams.ID)
|
||
|
||
// The P-chain is created synchronously to ensure that `VM.Initialize` has
|
||
// finished before returning from this function. This is required because
|
||
// the P-chain initializes state that the rest of the node initialization
|
||
// depends on.
|
||
m.createChain(platformParams)
|
||
|
||
m.Log.Info("starting chain creator")
|
||
m.chainCreatorExited.Add(1)
|
||
go func() { close(m.unblockChainCreatorCh) }()
|
||
go m.dispatchChainCreator()
|
||
return nil
|
||
}
|
||
|
||
func (m *manager) dispatchChainCreator() {
|
||
defer m.chainCreatorExited.Done()
|
||
|
||
select {
|
||
// This channel will be closed when Shutdown is called on the manager.
|
||
case <-m.chainCreatorShutdownCh:
|
||
return
|
||
case <-m.unblockChainCreatorCh:
|
||
}
|
||
|
||
// Handle chain creations
|
||
for {
|
||
// Get the next chain we should create.
|
||
// Dequeue waits until an element is pushed, so this is not
|
||
// busy-looping.
|
||
chainParams, ok := m.chainsQueue.PopLeft()
|
||
if !ok { // queue is closed, return directly
|
||
return
|
||
}
|
||
m.createChain(chainParams)
|
||
}
|
||
}
|
||
|
||
// PrimaryAliasOrDefault returns the primary alias for a chain, or the chain ID if no alias exists
|
||
func (m *manager) PrimaryAliasOrDefault(chainID ids.ID) string {
|
||
alias, err := m.PrimaryAlias(chainID)
|
||
if err != nil {
|
||
// Return chain ID as string if no alias found
|
||
return chainID.String()
|
||
}
|
||
return alias
|
||
}
|
||
|
||
// Shutdown stops all the chains
|
||
func (m *manager) Shutdown() {
|
||
m.Log.Info("shutting down chain manager")
|
||
m.chainsQueue.Close()
|
||
close(m.chainCreatorShutdownCh)
|
||
m.chainCreatorExited.Wait()
|
||
// Router doesn't have Shutdown method in consensus package
|
||
}
|
||
|
||
// RetryPendingChains re-queues chains that were waiting for the specified VM.
|
||
// This is called when a VM is hot-loaded via admin.loadVMs.
|
||
// Returns the number of chains that were re-queued.
|
||
func (m *manager) RetryPendingChains(vmID ids.ID) int {
|
||
m.pendingVMChainsLock.Lock()
|
||
pendingChains, ok := m.pendingVMChains[vmID]
|
||
if ok {
|
||
delete(m.pendingVMChains, vmID)
|
||
}
|
||
m.pendingVMChainsLock.Unlock()
|
||
|
||
if !ok || len(pendingChains) == 0 {
|
||
return 0
|
||
}
|
||
|
||
// Re-queue all pending chains for this VM
|
||
for _, chainParams := range pendingChains {
|
||
m.Log.Info("Re-queuing chain after VM hot-load",
|
||
log.Stringer("vmID", vmID),
|
||
log.Stringer("chainID", chainParams.ID),
|
||
)
|
||
m.chainsQueue.PushRight(chainParams)
|
||
}
|
||
|
||
return len(pendingChains)
|
||
}
|
||
|
||
// Notify registrants [those who want to know about the creation of chains]
|
||
// that the specified chain has been created
|
||
func (m *manager) notifyRegistrants(name string, rt *runtime.Runtime, vmImpl interface{}) {
|
||
for _, registrant := range m.registrants {
|
||
if coreVM, ok := vmImpl.(vm.VM); ok {
|
||
registrant.RegisterChain(name, rt, coreVM)
|
||
}
|
||
}
|
||
}
|
||
|
||
// stateChainIdentity publishes which chain this blockchain actually is, so every
|
||
// handshake states it and a peer running a different one is excluded from it.
|
||
//
|
||
// Everything here is creation data, and this is the one place it is all in hand
|
||
// at once: GenesisData is the record the chain was created from — for a chain
|
||
// created by a CreateChainTx, the bytes recorded in that transaction, delivered
|
||
// to the VM verbatim — and it is digested exactly as delivered, never parsed. Two
|
||
// honest nodes on one chain therefore derive identical values with no
|
||
// coordination, and two nodes handed different records cannot help but differ.
|
||
//
|
||
// The upgrade bytes are reported separately and never enforced: a difference
|
||
// there means the two nodes agree today and are scheduled to diverge, which is
|
||
// worth seeing early and is not grounds for either to exclude the other.
|
||
func (m *manager) stateChainIdentity(chainParams ChainParameters) {
|
||
if m.Net == nil {
|
||
return
|
||
}
|
||
cfg, err := m.getChainConfig(chainParams.ID)
|
||
if err != nil {
|
||
// A chain runs without operator config; only the rule generation is
|
||
// unknown, and that is reported rather than enforced.
|
||
m.Log.Debug("no chain config while stating chain identity",
|
||
log.Stringer("chainID", chainParams.ID),
|
||
log.Err(err),
|
||
)
|
||
}
|
||
m.Net.RegisterChainIdentity(peer.ChainIdentity{
|
||
NetworkID: m.NetworkID,
|
||
ChainID: chainParams.ID,
|
||
VMID: chainParams.VMID,
|
||
Genesis: peer.GenesisDigest(chainParams.GenesisData),
|
||
Rules: peer.RulesDigest(cfg.Upgrade),
|
||
})
|
||
}
|
||
|
||
// getChainConfig returns value of a entry by looking at ID key and alias key
|
||
// it first searches ID key, then falls back to it's corresponding primary alias
|
||
func (m *manager) getChainConfig(id ids.ID) (ChainConfig, error) {
|
||
if val, ok := m.ManagerConfig.ChainConfigs[id.String()]; ok {
|
||
return val, nil
|
||
}
|
||
aliases, err := m.Aliases(id)
|
||
if err != nil {
|
||
return ChainConfig{}, err
|
||
}
|
||
for _, alias := range aliases {
|
||
if val, ok := m.ManagerConfig.ChainConfigs[alias]; ok {
|
||
return val, nil
|
||
}
|
||
}
|
||
|
||
return ChainConfig{}, nil
|
||
}
|
||
|
||
// injectSecurityProfileConfig stamps the chain-wide ChainSecurityProfile
|
||
// pin into the C-Chain JSON config bytes so the EVM plugin can
|
||
// resolve the profile on its side of the rpcchainvm boundary. Closes
|
||
// red-team finding F118.
|
||
//
|
||
// The pin form is intentionally minimal — ProfileID byte + ProfileHash
|
||
// hex — so this manager package does not need to round-trip the full
|
||
// ChainSecurityProfile struct. The plugin VM calls
|
||
// consensusconfig.ProfileByID + ComputeHash and refuses the chain if the
|
||
// pinned hash does not match the live canonical content. Forked binaries
|
||
// that swap canonical profile content fail the hash compare; legitimate
|
||
// upgrades land via a new ProfileID and ProfileHash pair.
|
||
//
|
||
// Only applies to C-Chain (EVMID); other VMs are passed through.
|
||
func (m *manager) injectSecurityProfileConfig(vmID ids.ID, configBytes []byte) []byte {
|
||
if m.SecurityProfile == nil {
|
||
return configBytes
|
||
}
|
||
if vmID != constants.EVMID {
|
||
return configBytes
|
||
}
|
||
|
||
// Parse existing config or create empty object.
|
||
var cfg map[string]interface{}
|
||
if len(configBytes) > 0 {
|
||
if err := json.Unmarshal(configBytes, &cfg); err != nil {
|
||
m.Log.Warn("failed to parse C-Chain config for security profile injection, creating new config",
|
||
log.Err(err))
|
||
cfg = make(map[string]interface{})
|
||
}
|
||
} else {
|
||
cfg = make(map[string]interface{})
|
||
}
|
||
|
||
cfg["lux-security-profile"] = map[string]interface{}{
|
||
"profileID": m.SecurityProfile.ProfileID & 0xff,
|
||
"profileHashHex": fmt.Sprintf("%x", m.SecurityProfile.ProfileHash[:]),
|
||
}
|
||
|
||
out, err := json.Marshal(cfg)
|
||
if err != nil {
|
||
m.Log.Warn("failed to marshal C-Chain config after security profile injection", log.Err(err))
|
||
return configBytes
|
||
}
|
||
m.Log.Info("injected chain security profile pin into C-Chain plugin config",
|
||
log.Uint32("profileID", m.SecurityProfile.ProfileID),
|
||
log.String("profileName", m.SecurityProfile.ProfileName))
|
||
return out
|
||
}
|
||
|
||
// injectAutominingConfig modifies the config bytes to include enable-automining flag
|
||
// when dev mode automining is enabled. This is used for the C-Chain EVM to enable
|
||
// anvil-like block production behavior.
|
||
// Only applies to VMs that use JSON config format (EVMID). VMs using binary codec
|
||
// config (ThresholdVM, ZKVM, etc.) are not modified.
|
||
func (m *manager) injectAutominingConfig(vmID ids.ID, configBytes []byte) []byte {
|
||
if !m.EnableAutomining {
|
||
return configBytes
|
||
}
|
||
|
||
// Only inject automining config for EVM-based chains (C-Chain)
|
||
// Other VMs like ThresholdVM, ZKVM use binary codec config format
|
||
if vmID != constants.EVMID {
|
||
return configBytes
|
||
}
|
||
|
||
// Parse existing config or create empty object
|
||
var config map[string]interface{}
|
||
if len(configBytes) > 0 {
|
||
if err := json.Unmarshal(configBytes, &config); err != nil {
|
||
// If we can't parse existing config, create new one with just automining
|
||
m.Log.Warn("failed to parse chain config for automining injection, creating new config",
|
||
log.Err(err))
|
||
config = make(map[string]interface{})
|
||
}
|
||
} else {
|
||
config = make(map[string]interface{})
|
||
}
|
||
|
||
// Inject enable-automining flag
|
||
config["enable-automining"] = true
|
||
// Inject skip-block-fee flag to allow block generation without requiring transaction fees
|
||
// This is necessary for dev mode APIs (eth_setBalance, eth_setStorageAt, evm_mine, etc.)
|
||
config["skip-block-fee"] = true
|
||
|
||
// Serialize back to JSON
|
||
modifiedBytes, err := json.Marshal(config)
|
||
if err != nil {
|
||
m.Log.Warn("failed to marshal modified chain config", log.Err(err))
|
||
return configBytes
|
||
}
|
||
|
||
m.Log.Info("injected enable-automining and skip-block-fee into chain config")
|
||
return modifiedBytes
|
||
}
|
||
|
||
func (m *manager) getOrMakeVMGatherer(vmID ids.ID) (metrics.MultiGatherer, error) {
|
||
vmGatherer, ok := m.vmGatherer[vmID]
|
||
if ok {
|
||
return vmGatherer, nil
|
||
}
|
||
|
||
vmName := constants.VMName(vmID)
|
||
// The VM's own name is its metric namespace: one gatherer per VM, and the
|
||
// chain label inside it separates the chains that share a VM.
|
||
vmNamespace := vmName
|
||
vmGatherer = metrics.NewLabelGatherer(ChainLabel)
|
||
err := m.Metrics.Register(
|
||
vmNamespace,
|
||
vmGatherer,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
m.vmGatherer[vmID] = vmGatherer
|
||
return vmGatherer, nil
|
||
}
|
||
|
||
// chainExpectsStakedBeacons reports whether a chain must sync its bootstrap frontier against the
|
||
// STAKED primary-network validator set — the ⅔-by-stake quorum that names the network frontier
|
||
// (blockHandler.expectsStakedBeacons, the C1 forged-chain gate). When true, --skip-bootstrap does
|
||
// NOT empty the chain's beacon set (buildChain below), so a behind validator always catches up
|
||
// from its peers.
|
||
//
|
||
// It is driven by SYBIL PROTECTION — the true "this is a real staked network" signal — and NOT by
|
||
// --skip-bootstrap. A validator sets --skip-bootstrap to skip the initial bootstrap WAIT, but that
|
||
// flag must NEVER disable peer-sync on a staked network. Key this decision off --skip-bootstrap and
|
||
// a behind native chain (C/X/Q...) gets expectsStakedBeacons=false plus an empty beacon set, so
|
||
// FrontierTip reports FrontierNoBeacons ("nothing to sync to"), the node names its STALE local
|
||
// last-accepted the network frontier, goes live there, and can never catch up across restarts —
|
||
// only a chaindata wipe clears it. A genuine single-node / dev network runs
|
||
// sybil-protection OFF, so it still takes the empty-beacon immediate-start path. The platform chain
|
||
// anchors to its own CustomBeacons (not the staked set), so it is excluded here as before; a
|
||
// single-VALIDATOR staked net (self-only set) is handled downstream by FrontierTip's
|
||
// hasExternalBeacons rule, which reads the LOADED set.
|
||
func chainExpectsStakedBeacons(sybilProtectionEnabled, isPrimaryNetworkChain, isPlatformChain bool) bool {
|
||
return sybilProtectionEnabled && isPrimaryNetworkChain && !isPlatformChain
|
||
}
|
||
|
||
// chainValidatesOnPrimaryNetwork reports whether a chain's validating Net IS the primary
|
||
// network — the correct discriminator for the "native" C/X/Q/... set that syncs its bootstrap
|
||
// frontier against the primary-network STAKED validator set. It keys off the validating Net
|
||
// network ID, NOT the blockchain ID: deployed C/X/Q carry HASH blockchain IDs, whereas
|
||
// ids.IsNativeChain only ever matches the symbolic 111...C alias form that NO deployed chain
|
||
// uses (only the P-chain, at PlatformChainID=111...P, has a symbolic blockchain ID — and it is
|
||
// excluded as the platform chain anyway). Keying the durable-rejoin discriminator off the
|
||
// blockchain ID (the prior ids.IsNativeChain(chainParams.ID)) therefore silently excluded every
|
||
// real C/X/Q and left the fix inert across restarts (#66/#74). The validating Net is
|
||
// PrimaryNetworkID for C/X/Q and each sovereign L1's own net ID for an L2, so this correctly
|
||
// keeps the empty-beacon single-node path for L2s while re-enabling peer-sync for C/X/Q.
|
||
func chainValidatesOnPrimaryNetwork(validatingNetID ids.ID) bool {
|
||
return validatingNetID == constants.PrimaryNetworkID
|
||
}
|
||
|
||
// emptyValidatorManager implements validators.Manager with no validators
|
||
type emptyValidatorManager struct{}
|
||
|
||
func (e *emptyValidatorManager) GetValidator(chainID ids.ID, nodeID ids.NodeID) (*validators.GetValidatorOutput, bool) {
|
||
return nil, false
|
||
}
|
||
|
||
func (e *emptyValidatorManager) GetValidators(chainID ids.ID) (validators.Set, error) {
|
||
// Return nil for empty validator set since NewEmpty doesn't exist
|
||
return nil, nil
|
||
}
|
||
|
||
func (e *emptyValidatorManager) GetWeight(chainID ids.ID, nodeID ids.NodeID) uint64 {
|
||
return 0
|
||
}
|
||
|
||
func (e *emptyValidatorManager) GetCurrentHeight(context.Context) (uint64, error) {
|
||
return 0, nil
|
||
}
|
||
|
||
func (e *emptyValidatorManager) GetValidatorSet(ctx context.Context, height uint64, chainID ids.ID) (map[ids.NodeID]*validators.GetValidatorOutput, error) {
|
||
return map[ids.NodeID]*validators.GetValidatorOutput{}, nil
|
||
}
|
||
|
||
func (e *emptyValidatorManager) OnAcceptedBlockID(blkID ids.ID) {}
|
||
|
||
func (e *emptyValidatorManager) String() string {
|
||
return "empty validator manager"
|
||
}
|
||
|
||
func (e *emptyValidatorManager) TotalWeight(chainID ids.ID) (uint64, error) {
|
||
return 0, nil
|
||
}
|
||
|
||
func (e *emptyValidatorManager) GetLight(chainID ids.ID, nodeID ids.NodeID) uint64 {
|
||
return 0
|
||
}
|
||
|
||
func (e *emptyValidatorManager) TotalLight(chainID ids.ID) (uint64, error) {
|
||
return 0, nil
|
||
}
|
||
|
||
func (e *emptyValidatorManager) AddStaker(chainID ids.ID, nodeID ids.NodeID, publicKey []byte, txID ids.ID, light uint64) error {
|
||
return nil
|
||
}
|
||
|
||
func (e *emptyValidatorManager) AddWeight(chainID ids.ID, nodeID ids.NodeID, weight uint64) error {
|
||
return nil
|
||
}
|
||
|
||
func (e *emptyValidatorManager) RemoveWeight(chainID ids.ID, nodeID ids.NodeID, weight uint64) error {
|
||
return nil
|
||
}
|
||
|
||
func (e *emptyValidatorManager) GetMap(chainID ids.ID) map[ids.NodeID]*validators.GetValidatorOutput {
|
||
return nil
|
||
}
|
||
|
||
func (e *emptyValidatorManager) GetValidatorIDs(chainID ids.ID) []ids.NodeID {
|
||
return nil
|
||
}
|
||
|
||
func (e *emptyValidatorManager) NumValidators(chainID ids.ID) int {
|
||
return 0
|
||
}
|
||
|
||
func (e *emptyValidatorManager) NumNets() int {
|
||
return 0
|
||
}
|
||
|
||
func (e *emptyValidatorManager) SubsetWeight(chainID ids.ID, nodeIDs set.Set[ids.NodeID]) (uint64, error) {
|
||
return 0, nil
|
||
}
|
||
|
||
func (e *emptyValidatorManager) Sample(chainID ids.ID, size int) ([]ids.NodeID, error) {
|
||
return nil, nil
|
||
}
|
||
|
||
func (e *emptyValidatorManager) Count(chainID ids.ID) int {
|
||
return 0
|
||
}
|
||
|
||
func (e *emptyValidatorManager) RegisterCallbackListener(listener validators.ManagerCallbackListener) {
|
||
}
|
||
|
||
func (e *emptyValidatorManager) RegisterSetCallbackListener(chainID ids.ID, listener validators.SetCallbackListener) {
|
||
}
|
||
|
||
func (e *emptyValidatorManager) GetCurrentValidators(ctx context.Context, height uint64, chainID ids.ID) (map[ids.NodeID]*validators.GetValidatorOutput, error) {
|
||
return nil, nil
|
||
}
|
||
|
||
// blockHandler implements handler.Handler interface and processes incoming blocks
|
||
// This enables block propagation between validators
|
||
type blockHandler struct {
|
||
// vm is the SAME BlockBuilder the consensus engine builds/parses through — the
|
||
// P-chain-height-stamping wrapper on K>1 chains (so ParseBlock unwraps the
|
||
// transport envelope the engine emits and recovers the proposer's epoch
|
||
// height), the inner VM directly on K==1. The handler only needs the
|
||
// BlockBuilder subset (GetBlock / ParseBlock / LastAccepted); typing it as the
|
||
// builder — not chain.ChainVM — keeps the engine and the handler on ONE block
|
||
// codec, so inbound P2P container bytes are parsed by the same code that framed
|
||
// them (no raw-vs-wrapped mismatch).
|
||
// namingProgress carries each anchor's ancestry descent ACROSS bootstrap attempts,
|
||
// keyed by reported tip. It lives HERE, not on the BootstrapPolicy, because a fresh
|
||
// policy is built every attempt: the per-attempt budget resets and the cursor must
|
||
// not, or a gap wider than one attempt can never be crossed however often we retry.
|
||
// Dropped once a frontier is named.
|
||
namingProgress map[ids.ID]*NamingProgress
|
||
|
||
vm consensuschain.BlockBuilder
|
||
logger log.Logger
|
||
engine *consensuschain.Runtime // Consensus engine for proper block handling
|
||
net mesh.Network // Network for sending Qbit responses
|
||
msgCreator message.OutboundMsgBuilder // Message creator for Qbit responses
|
||
chainID ids.ID // Chain ID for message routing
|
||
networkID ids.ID // Network ID for validator routing
|
||
|
||
// connector receives peer connect/disconnect notifications routed from the
|
||
// node's chainRouter and forwards them to this chain's VM (e.g. the P-chain
|
||
// uptime tracker; other VMs use them for their own peer sets). connectedNodes
|
||
// dedups delivery so a peer the router dispatches once per tracked network is
|
||
// forwarded to the VM exactly once. A nil connector makes forwarding a no-op.
|
||
connector chain.ChainVM
|
||
connMu sync.Mutex
|
||
connectedNodes set.Set[ids.NodeID]
|
||
|
||
// Context sync support - when a block fails verification due to missing context,
|
||
// we request the prerequisite blocks from the peer to catch up
|
||
pendingContext map[ids.ID]contextRequest // Map from blockID to pending context request
|
||
|
||
// descentAnchor is the one block this handler walks toward while it is behind.
|
||
// A single outstanding-request registry is what dedupes every fetch and keeps
|
||
// exactly one driver walking the gap. Five callers here can request context and
|
||
// four anchor at the network tip, which asks the wrong question when we are
|
||
// behind: a responder serves the window ENDING at the id it is asked for, so a
|
||
// tip anchor returns blocks above us every time. Keep the descent's next anchor
|
||
// in a local and the walk is discarded the moment another caller re-anchors, so
|
||
// it restarts forever.
|
||
//
|
||
// Liveness only. This decides which block we ASK a peer for and grants nothing:
|
||
// finality still requires a verified quorum cert and still advances in contiguous
|
||
// order.
|
||
// descentHeight is where that walk currently stands, so a reply from above it
|
||
// cannot drag the walk back up. See descend.
|
||
descentAnchor ids.ID
|
||
descentHeight uint64
|
||
requestIDCounter uint32 // Counter for generating unique request IDs
|
||
maxContextBlocks int // Max context blocks to request/serve (default: 256)
|
||
contextRequestMu sync.Mutex // Protects pendingContext and requestIDCounter
|
||
|
||
// serving bounds how much of this node's memory a catch-up request can claim.
|
||
// Answering one walks up to maxContextBlocks blocks out of the VM and assembles
|
||
// them into a single multi-hundred-kilobyte message, so the number of answers
|
||
// being built at once is a memory multiplier — and nothing about the request
|
||
// rate is ours to choose. A node that has fallen behind asks every peer, as
|
||
// fast as its own timer allows, and keeps asking for as long as it stays
|
||
// behind. Unbounded, that turns one lagging peer into this node's memory
|
||
// ceiling.
|
||
//
|
||
// GetAncestors is best-effort and multi-round by design, so declining a request
|
||
// costs the asker one retry. servingPeers keeps one answer in flight per peer
|
||
// (a second question from a peer we are already answering is that same peer
|
||
// asking twice); servingSlots caps the total across all peers.
|
||
servingMu sync.Mutex
|
||
servingPeers set.Set[ids.NodeID]
|
||
servingSlots chan struct{}
|
||
pollerCancel context.CancelFunc // Cancels runFrontierPoller when the handler stops, so re-creating a chain leaks no goroutine
|
||
|
||
diag catchupDiag // Per-outcome counters that gate the catch-up diagnostics (see catchupSample)
|
||
|
||
// signedVotesRequired is true exactly when this chain's engine AUTHENTICATES
|
||
// every vote before tallying it — i.e. a VoteVerifier is wired, which the node
|
||
// does for exactly the chains with K>1. It is the Nova/Quasar boundary: on
|
||
// such a chain an unsigned Chits-derived value must never be handed to the
|
||
// engine as a Vote (see nova_poll.go). Deliberately NOT derived from the
|
||
// engine's Mode(), which also demands a cert gossiper and a stake source and
|
||
// therefore under-reports a degraded engine that drops those votes identically.
|
||
signedVotesRequired bool
|
||
// pollUnsignedCount is the volume gate for the unsigned-poll-response
|
||
// diagnostic (see catchupSample); it sits on a per-message path.
|
||
pollUnsignedCount gatomic.Uint64
|
||
|
||
// Poll-response buffering — when a peer's preference arrives for a block we do
|
||
// not hold yet, park it and drain when the block arrives. Only a K==1 chain
|
||
// ever parks anything: on a signed-vote chain the response stops at the
|
||
// Nova/Quasar boundary before it can be buffered.
|
||
pendingPollResponses map[ids.ID][]NovaPollResponse // blockID -> parked poll responses
|
||
pendingPollMu sync.Mutex // Protects pendingPollResponses
|
||
|
||
// INITIAL SYNC (bootstrap). The handler is BOTH the fetch transport (BlockSource)
|
||
// and the execute sink (Chain) the engine/chain/bootstrap loop drives — see
|
||
// bootstrap_sync.go. While bsActive, inbound frontier/ancestor replies route to the
|
||
// correlation channels below (so the synchronous loop can await them) instead of
|
||
// the live cert/vote path; bootstrapDone is the REAL ready signal monitorBootstrap
|
||
// gates on. bsActive false ⇒ every method behaves exactly as before this change.
|
||
bootstrapDone gatomic.Bool // true once initial sync reached the frontier
|
||
// bootstrapFailed is non-nil once initial sync RETURNED A FAIL-SAFE error (eclipse /
|
||
// partition / deep gap) instead of reaching the frontier (F5). monitorBootstrap polls it so
|
||
// the chain is stopped PROMPTLY with the real reason — not left in the dead window between
|
||
// Run returning and the 5-min no-progress watchdog (where the chain was neither syncing nor
|
||
// stopped). bootstrapDone XOR bootstrapFailed XOR still-syncing.
|
||
bootstrapFailed gatomic.Pointer[bsFailure]
|
||
// bootstrapWait is WHY runInitialSync is in its bounded connectivity-RETRY wait — a transient
|
||
// beacon-quorum-unreachable fail-safe it is re-attempting (the SELF-HEAL path) — and nil when it
|
||
// is not waiting. It tells monitorBootstrap's no-progress watchdog this is a deliberate WAIT for
|
||
// the quorum to return (the network cannot make progress without it), NOT a stall — so the
|
||
// watchdog does not force-STOP a node that is correctly failing safe and waiting, which (given
|
||
// the K8s probes only poll the always-green /v1/health/ops/liveness) would otherwise be a permanent
|
||
// brick. Carrying the reason rather than a bare flag is what lets the health check name the
|
||
// condition instead of restating that a chain is not bootstrapped.
|
||
bootstrapWait gatomic.Pointer[string]
|
||
// shortfall is the last round's measured distance from the beacon response floor, recorded by
|
||
// FrontierTip because that is the only place it exists: the status FrontierTip returns carries
|
||
// a state and no numbers, and bs.Run fails safe on a bare sentinel. Dropped at the start of
|
||
// every round, so it is never older than the round that measured it, and nil when the last
|
||
// round did not reach the floor check at all.
|
||
shortfall gatomic.Pointer[string]
|
||
bsActive gatomic.Bool // true while the bootstrap loop is driving
|
||
bsMu sync.Mutex // guards bsFrontierCh + bsAccepted + bsAncestorCh + bsAheadBeacons
|
||
bsFrontierCh chan bsFrontierReply // weighted frontier replies for the current FrontierTip
|
||
bsAccepted map[uint32]bsAcceptedRound // requestID -> the open second-question round
|
||
bsAncestorCh map[uint32]chan []bootstrapFetchedBlock // requestID -> cert-carrying reply for the current Ancestors
|
||
bsCertificates map[ids.ID][]byte // blockID -> cert retained across Ancestors -> AcceptBootstrapBlock
|
||
summaryOfferCh map[uint32]chan summary.Offer // requestID -> discovery replies
|
||
summaryBallotCh map[uint32]chan summary.Ballot // requestID -> ratification replies
|
||
summaryPeers map[uint32]set.Set[ids.NodeID] // requestID -> who was asked
|
||
bsAncestorPeers map[uint32]set.Set[ids.NodeID] // requestID -> the beacons that request was sent to
|
||
bsRotor gatomic.Uint32 // round-robins the Ancestors peer sample (M1: no monopoly)
|
||
// bsAheadBeacons is the set of beacons whose accepted tip, reported in the most recent
|
||
// FrontierTip round, is a block this node does NOT hold — i.e. beacons genuinely AHEAD that
|
||
// therefore HAVE the ancestry the descent needs. sampleAncestorBeacons prefers them so a
|
||
// re-bootstrapping node asks GetAncestors of peers that can serve, never wasting the sample on
|
||
// peers still at genesis (the ava PeerTracker "ask a prover" bias, without a full tracker).
|
||
// Recorded in collectFrontierReplies; empty ⇒ sampleAncestorBeacons falls back to the full
|
||
// rotated set (identical to prior behavior). Guarded by bsMu.
|
||
bsAheadBeacons set.Set[ids.NodeID]
|
||
|
||
// vmReady transitions the VM to NORMAL OPERATION (vm.Ready → the EVM's
|
||
// onNormalOperationsStarted: block building, mempool gossip, validator dispatch).
|
||
// runInitialSync calls it EXACTLY ONCE, and ONLY after initial sync has fetch+executed
|
||
// up to the named network frontier — never at the local (possibly stale) height. That
|
||
// ordering is the whole fix: a restarted stale validator stays in Bootstrapping (serving
|
||
// nothing as head) until it has converged, so it can never go live at a stale height.
|
||
// nil for handlers over a VM with no SetState (degenerate / test) → runInitialSync just
|
||
// marks ready. Set in buildChain right after construction.
|
||
vmReady func(context.Context) error
|
||
|
||
// beacons is the BEACON / staked-validator set the accepted-frontier quorum is
|
||
// anchored to (m.Validators for native chains, CustomBeacons for the P-chain). The
|
||
// C1 forged-chain gate: ONLY a node in this set, weighted by stake, can contribute to
|
||
// naming the frontier an empty/behind node syncs to — an arbitrary connected peer
|
||
// cannot. nil ⇒ no beacon quorum (single-node / skip-bootstrap), bootstrap is inert.
|
||
beacons validators.Manager
|
||
|
||
// selfNodeID is THIS node's own NodeID (m.NodeID). The node is itself a beacon (a
|
||
// validator in `beacons`), and it KNOWS its own accepted frontier with certainty — it
|
||
// cannot, and need not, ask itself over the network. FrontierTip counts this SELF-VOTE in
|
||
// the caught-up determination ONLY under FULL beacon connectivity (the fresh-net fix), so a
|
||
// node whose own stake makes the PEER-ONLY responder weight fall below the stake-majority
|
||
// floor still concludes caught-up at genesis instead of hanging in FrontierConnecting. Empty
|
||
// for degenerate / single-node handlers (no self in the beacon set ⇒ the self-vote is inert).
|
||
selfNodeID ids.NodeID
|
||
|
||
// expectsStakedBeacons is true when this chain syncs against the STAKED primary-network
|
||
// validator set (m.Validators) — i.e. a native non-platform chain (C/X/Q/...) on a
|
||
// sybil-protected network. That set is populated by the already-bootstrapped P-chain, so
|
||
// an EMPTY beacon set here means the staked set is not yet loaded or is misconfigured,
|
||
// NOT a single-node network. In that case FrontierTip must WAIT (→ ConnectDeadline → fail
|
||
// safe), never false-complete at the stale height and never fall back to unweighted /
|
||
// endpoint trust (which would reopen C1). False for the P-chain (its CustomBeacons may be
|
||
// legitimately empty under endpoint-only --bootstrap-nodes) and for single-node mode,
|
||
// where an empty beacon set legitimately means "nothing to sync to".
|
||
expectsStakedBeacons bool
|
||
|
||
// primaryNetworkReady reports whether the STAKED primary-network validator set this native
|
||
// chain anchors its beacon quorum to is FULLY LOADED — i.e. the P-chain has finished its own
|
||
// initial sync (replaying genesis stakers + every AddValidatorTx). nil ⇒ no gate (the P-chain
|
||
// itself, single-node / skip-bootstrap, and unit tests that wire a static beacon set).
|
||
//
|
||
// THE PARTIAL-SET GATE. A native non-platform chain's runBootstrapThenPoll starts right
|
||
// after the chain's VM.Initialize (dispatchChainCreator), NOT after the P-chain has finished
|
||
// BOOTSTRAPPING — so FrontierTip can run while m.Validators.GetMap(PrimaryNetworkID) is still a
|
||
// PARTIAL set. The MinResponseWeight stake-majority floor (total/2+1) is fail-secure ONLY when
|
||
// `total` is the TRUE full-set stake; under a partial denominator a degenerate at-or-below
|
||
// responder subset (the genuinely-ahead producers not yet loaded / not yet connected) clears the
|
||
// floor and the frontier decision false-completes the node at its stale local height, leaving a
|
||
// validator serving and voting behind the network with nothing left converging it. Gating
|
||
// frontier-trust on a fully-loaded set makes the floor's denominator the true full validator
|
||
// set, so the existing fail-secure logic holds. Deadlock-free:
|
||
// the P-chain bootstraps INDEPENDENTLY from its own configured (full-from-config) CustomBeacons,
|
||
// so a native chain only waits a bounded extra moment for the P-chain to converge.
|
||
primaryNetworkReady func() bool
|
||
|
||
// wsCheckpointID + wsCheckpointHeight are an OPTIONAL operator-supplied weak-
|
||
// subjectivity anchor (a recent finalized block id at height) the α-agreed frontier
|
||
// must descend from — defense-in-depth for an empty-genesis node. Zero ⇒ disabled.
|
||
wsCheckpointID ids.ID
|
||
wsCheckpointHeight uint64
|
||
|
||
// bootstrapMinResponses / bootstrapAgreement / bootstrapCheckpoint configure the
|
||
// BootstrapTrust policy (bootstrap_trust.go) — the SEPARATE object that decides whether a
|
||
// fetched frontier is safe to BEGIN SYNC FROM, distinct from consensus finality. They are the
|
||
// fix for the mass-recovery deadlock: the acceptance gate is a response FLOOR + a
|
||
// ⅔-of-RESPONDERS agreement, NOT a ⅔-of-CURRENT-total-stake quorum (unsatisfiable when the
|
||
// recovery targets are themselves down validators). All default when zero (bootstrapPolicy):
|
||
// - bootstrapMinResponses: minimum distinct authenticated configured beacons that must
|
||
// respond before any frontier is named (INVARIANT 2's partition-capture floor). Zero ⇒ a
|
||
// MAJORITY of the configured beacon set. For a 5-validator set this is 3, so 3 reachable
|
||
// producers recover the network while a 2-beacon partition is rejected.
|
||
// - bootstrapAgreement: the fraction of the RESPONDER weight a named frontier must exceed.
|
||
// Zero ⇒ ⅔. An operator may tighten to ¾ (Ratio{3,4}).
|
||
// - bootstrapCheckpoint: an OPTIONAL operator-pinned (id,height) to anchor from when fewer
|
||
// than the floor respond (the explicit override for the "1 of N reachable" case). nil ⇒
|
||
// reject below the floor rather than trust a captured minority.
|
||
bootstrapMinResponses int
|
||
bootstrapAgreement Ratio
|
||
bootstrapCheckpoint *Checkpoint
|
||
|
||
// bootstrapConnectDeadline / bootstrapRetryInterval tune the initial-sync loop
|
||
// (chainbootstrap.Config): how long ONE bs.Run attempt WAITS for beacon connectivity before
|
||
// failing safe (ConnectDeadline — the in-attempt retry that self-heals when beacons return),
|
||
// and the re-sample pause between rounds (RetryInterval, also the inter-attempt backoff). Zero ⇒
|
||
// library defaults (3m / 1s). Operator-overridable; the bootstrap tests set them small to bound
|
||
// the fail-safe path.
|
||
bootstrapConnectDeadline time.Duration
|
||
bootstrapRetryInterval time.Duration
|
||
// bootstrapMaxAttempts bounds how many times runInitialSync RE-ATTEMPTS bootstrap after a
|
||
// TRANSIENT connectivity fail-safe (the beacon quorum unreachable — eclipse / partition /
|
||
// majority co-restart still in flight). Each attempt is itself bounded (ConnectDeadline) with a
|
||
// backoff between, so this is the OUTER self-heal: the node stays in Bootstrapping (failing safe
|
||
// DOWN — never live at a stale height) and CONVERGES the instant the quorum returns. ≤0 ⇒
|
||
// UNLIMITED (retry until the quorum returns or shutdown) — the production default, because a
|
||
// node without a quorum must keep trying to rejoin and the K8s liveness probe does NOT restart
|
||
// it (all luxd probes poll the always-green /v1/health/ops/liveness). A STRUCTURAL failure (deep
|
||
// gap → state-sync) is never retried regardless. Tests pin it to 1 to assert the single-attempt
|
||
// terminal fail-safe in isolation.
|
||
bootstrapMaxAttempts int
|
||
}
|
||
|
||
// bsFrontierReply is one beacon's accepted-frontier answer, tagged with the responder so
|
||
// FrontierTip can weight it by that beacon's stake. The C1 fix turns the frontier from
|
||
// "first peer to answer" into "the tip a ⅔-by-stake supermajority of beacons name."
|
||
type bsFrontierReply struct {
|
||
nodeID ids.NodeID
|
||
tip ids.ID
|
||
}
|
||
|
||
// bsAcceptedReply is one beacon's answer to the second naming question: which of the candidate
|
||
// tips it has ACCEPTED. Tagged with the responder for the same reason bsFrontierReply is — the
|
||
// answer is worth exactly that beacon's configured stake, and nothing without a name attached.
|
||
type bsAcceptedReply struct {
|
||
nodeID ids.NodeID
|
||
accepted []ids.ID
|
||
}
|
||
|
||
// bsAcceptedRound is one open second-question round: who it was asked of, and where the answers
|
||
// go. The asked set lives WITH the channel so an answer can be refused at the door — a peer that
|
||
// was not asked cannot take a slot in the round's buffer, let alone be tallied. Correlating on
|
||
// request id alone leaves that slot open to whoever answers first.
|
||
type bsAcceptedRound struct {
|
||
asked set.Set[ids.NodeID]
|
||
ch chan bsAcceptedReply
|
||
}
|
||
|
||
// contextRequest tracks a pending context request (wire: GetAncestors)
|
||
type contextRequest struct {
|
||
nodeID ids.NodeID
|
||
requestID uint32
|
||
blockID ids.ID
|
||
timestamp time.Time
|
||
}
|
||
|
||
// newBlockHandler builds the per-chain inbound handler. signedVotesRequired is a
|
||
// CONSTRUCTOR parameter, not a field set afterwards, because its zero value is
|
||
// the permissive K==1 legacy path: a site that forgot to state it would silently
|
||
// resurrect the unsigned-Chits-to-Vote braid on a quorum chain.
|
||
func newBlockHandler(vm consensuschain.BlockBuilder, connector chain.ChainVM, logger log.Logger, engine *consensuschain.Runtime, net mesh.Network, msgCreator message.OutboundMsgBuilder, chainID ids.ID, networkID ids.ID, beacons validators.Manager, selfNodeID ids.NodeID, expectsStakedBeacons bool, signedVotesRequired bool) *blockHandler {
|
||
return &blockHandler{
|
||
vm: vm,
|
||
connector: connector,
|
||
logger: logger,
|
||
engine: engine,
|
||
net: net,
|
||
msgCreator: msgCreator,
|
||
chainID: chainID,
|
||
networkID: networkID,
|
||
beacons: beacons,
|
||
selfNodeID: selfNodeID,
|
||
expectsStakedBeacons: expectsStakedBeacons,
|
||
signedVotesRequired: signedVotesRequired,
|
||
pendingContext: make(map[ids.ID]contextRequest),
|
||
maxContextBlocks: 256, // Default max context blocks to request/serve
|
||
pendingPollResponses: make(map[ids.ID][]NovaPollResponse),
|
||
connectedNodes: set.NewSet[ids.NodeID](16),
|
||
bsAncestorCh: make(map[uint32]chan []bootstrapFetchedBlock),
|
||
bsCertificates: make(map[ids.ID][]byte),
|
||
bsAncestorPeers: make(map[uint32]set.Set[ids.NodeID]),
|
||
servingPeers: set.NewSet[ids.NodeID](16),
|
||
servingSlots: make(chan struct{}, maxConcurrentServes),
|
||
}
|
||
}
|
||
|
||
// maxConcurrentServes is how many catch-up answers this node will assemble at once,
|
||
// across every peer. Each one holds up to maxContextBlocks blocks plus the message
|
||
// built from them, so this number times the message cap is the memory this path can
|
||
// claim — a few tens of megabytes, rather than however much peers ask for.
|
||
const maxConcurrentServes = 8
|
||
|
||
// beginServe claims the right to answer nodeID, or reports false when this node is
|
||
// already answering that peer or is at its overall limit. A false is not an error:
|
||
// the asker retries, and it is better to answer a few peers well than to run every
|
||
// peer out of memory.
|
||
func (b *blockHandler) beginServe(nodeID ids.NodeID) bool {
|
||
b.servingMu.Lock()
|
||
// Self-arming. A handler assembled as a struct literal rather than through
|
||
// newBlockHandler still gets the bound: the limit protects memory, so it must not
|
||
// be something a construction path can be missing.
|
||
if b.servingSlots == nil {
|
||
b.servingSlots = make(chan struct{}, maxConcurrentServes)
|
||
}
|
||
if b.servingPeers == nil {
|
||
b.servingPeers = set.NewSet[ids.NodeID](16)
|
||
}
|
||
slots := b.servingSlots
|
||
if b.servingPeers.Contains(nodeID) {
|
||
b.servingMu.Unlock()
|
||
return false
|
||
}
|
||
b.servingPeers.Add(nodeID)
|
||
b.servingMu.Unlock()
|
||
|
||
select {
|
||
case slots <- struct{}{}:
|
||
return true
|
||
default:
|
||
b.servingMu.Lock()
|
||
b.servingPeers.Remove(nodeID)
|
||
b.servingMu.Unlock()
|
||
return false
|
||
}
|
||
}
|
||
|
||
func (b *blockHandler) endServe(nodeID ids.NodeID) {
|
||
b.servingMu.Lock()
|
||
slots := b.servingSlots
|
||
b.servingPeers.Remove(nodeID)
|
||
b.servingMu.Unlock()
|
||
if slots != nil {
|
||
<-slots
|
||
}
|
||
}
|
||
|
||
// hasBlock returns true if the block is available (either in consensus pendingBlocks or VM storage).
|
||
// This is critical for poll-response handling: when a peer names a block we've built or received,
|
||
// the block may only be in pendingBlocks (not yet verified/stored in VM).
|
||
func (b *blockHandler) hasBlock(ctx context.Context, blockID ids.ID) bool {
|
||
// First check if the block is in consensus pending (built or received but not yet finalized).
|
||
// This allows votes to be processed for blocks we're currently considering in consensus.
|
||
if b.engine != nil && b.engine.HasPendingBlock(blockID) {
|
||
return true
|
||
}
|
||
|
||
// Fall back to checking VM storage for verified blocks
|
||
if b.vm == nil {
|
||
return false
|
||
}
|
||
_, err := b.vm.GetBlock(ctx, blockID)
|
||
return err == nil
|
||
}
|
||
|
||
// isMissingContextError returns true if the error indicates missing prerequisite blocks
|
||
func isMissingContextError(err error) bool {
|
||
if err == nil {
|
||
return false
|
||
}
|
||
errStr := err.Error()
|
||
return strings.Contains(errStr, "unknown ancestor") ||
|
||
strings.Contains(errStr, "missing parent") ||
|
||
strings.Contains(errStr, "parent not found") ||
|
||
strings.Contains(errStr, "unknown parent") ||
|
||
strings.Contains(errStr, "missing context") ||
|
||
strings.Contains(errStr, "not found") // parent block not in local state
|
||
}
|
||
|
||
// maxPendingContext hard-bounds the in-flight catch-up request map. A Byzantine
|
||
// peer can stream AcceptedFrontier replies naming distinct random tips, each
|
||
// landing in requestContext; without a bound pendingContext grows without limit
|
||
// and OOMs the node. pendingContextTTL reaps a request once its
|
||
// deadline has well passed, so a peer that takes the request then withholds
|
||
// Context no longer pins the slot — the block becomes re-requestable from an
|
||
// honest peer. The GetAncestors deadline below is 10s; a
|
||
// 30s TTL is comfortably past it.
|
||
const (
|
||
maxPendingContext = 4096
|
||
pendingContextTTL = 30 * time.Second
|
||
)
|
||
|
||
// catchupDiag counts each OUTCOME on the cert-gap fetch path — request, serve,
|
||
// deliver, apply — one counter per outcome, the shape quorum.go's vote verifier
|
||
// already uses. Nothing reads these but the log gate below; they exist so that each
|
||
// outcome is sampled against its OWN rate and a common one cannot bury a rare one.
|
||
type catchupDiag struct {
|
||
// request side (requestContext)
|
||
noWire, dedup, reaped, evicted, noPeer, unsent gatomic.Uint64
|
||
// serve side (GetContext — the inbound GetAncestors)
|
||
serveNoWire, serveAsked, serveEmpty, serveBusy gatomic.Uint64
|
||
// deliver side (handleContext — the Ancestors reply)
|
||
replyIn, replyNoVM, replyEmpty, replyBootstrap gatomic.Uint64
|
||
// frontier decision (AcceptedFrontier) — the branch that decides whether we fetch
|
||
frontierBootstrap, frontierCurrent, frontierCertGap gatomic.Uint64
|
||
frontierPending, frontierAbsent, frontierNoEngine gatomic.Uint64
|
||
// blocks that arrived on the wire and did NOT parse. Dropping one is recoverable and
|
||
// stays recoverable — nothing is finalized, the sender is not penalised — but the node
|
||
// cannot place that block, and on the catch-up path it is what keeps the window from
|
||
// descending. Counted so the drop is visible to a scrape instead of only to whoever
|
||
// thinks to raise the log level on the right one of eight chains.
|
||
blockUnparsed gatomic.Uint64
|
||
}
|
||
|
||
// catchupSample is the volume gate for the catch-up diagnostics: the first few of an
|
||
// outcome, then every 500th. Same shape as quorum.go's vote-verifier sampler, for the
|
||
// same reason — these sites sit on per-message paths under a cert storm (hundreds of
|
||
// signature verifications a second, one node re-gossiping the same cert hundreds of
|
||
// times), so an unconditional Info line here buries its own signal.
|
||
// Sampling still names a systematic failure on its FIRST occurrence and keeps naming
|
||
// it while it persists, which is what a single roll needs.
|
||
func catchupSample(c *gatomic.Uint64) (uint64, bool) {
|
||
n := c.Add(1)
|
||
return n, n <= 3 || n%500 == 0
|
||
}
|
||
|
||
// speaksFor tags a logger with the chain whose behaviour it reports. It is called
|
||
// exactly once per chain, at the top of buildChain; everything a chain is built from
|
||
// takes that logger, so no emitter carries the identity itself and a field applied
|
||
// twice cannot appear.
|
||
//
|
||
// Every chain on a node writes to ONE writer, and the lines that matter most here —
|
||
// the catch-up descent, the served window, the cert the finality guard refused —
|
||
// carry heights and block ids and nothing else. Chains created together produce
|
||
// blocks at the same cadence, so their heights sit in the same range: a reader
|
||
// cannot tell from "finalized=23587 oldestHeight=33232" which of eight chains is
|
||
// speaking. The identity is known at construction and costs one field.
|
||
func speaksFor(base log.Logger, chainID ids.ID) log.Logger {
|
||
if base == nil || base.IsZero() {
|
||
return base
|
||
}
|
||
return base.New("chain", chainID.String())
|
||
}
|
||
|
||
// requestContext sends a context request (wire: GetAncestors) to fetch missing blocks from a peer
|
||
func (b *blockHandler) requestContext(ctx context.Context, nodeID ids.NodeID, blockID ids.ID) {
|
||
// FOLLOW THE WALK. While a descent is under way, every caller asks for the block
|
||
// the descent is walking toward rather than whatever it happened to see. Upstream
|
||
// keeps one outstanding-request registry and one driver for exactly this reason;
|
||
// here five callers can request context and four of them see the network tip. A
|
||
// responder serves the window ENDING at the id it is asked for, so a tip-anchored
|
||
// request returns blocks above a behind node every time — and replaces the anchor
|
||
// the descent had just earned. The walks then interleave, each one's oldest height
|
||
// bouncing to whatever window the last reply served, and none of them reaches the
|
||
// node's own next height.
|
||
//
|
||
// The anchor clears when the descent connects, so a caught-up node asks for
|
||
// exactly what its caller named. Liveness only: this chooses the question, never
|
||
// the answer, and nothing finalizes without a verified cert.
|
||
b.contextRequestMu.Lock()
|
||
if anchor := b.descentAnchor; anchor != ids.Empty && anchor != blockID {
|
||
blockID = anchor
|
||
}
|
||
b.contextRequestMu.Unlock()
|
||
|
||
if b.net == nil || b.msgCreator == nil {
|
||
// The catch-up DECISION was made and there is no wire to carry it. Silence here
|
||
// reads exactly like a node that was never asked to fetch anything.
|
||
if n, loud := catchupSample(&b.diag.noWire); loud {
|
||
b.logger.Warn("catch-up fetch skipped — no network transport on this handler",
|
||
log.Stringer("blockID", blockID),
|
||
log.Uint64("count", n))
|
||
}
|
||
return
|
||
}
|
||
|
||
now := time.Now()
|
||
|
||
b.contextRequestMu.Lock()
|
||
// Check if we already have a pending request for this block -- but only an
|
||
// UNEXPIRED one suppresses. "A request exists" and "that request is still
|
||
// live" are two different facts, and keying suppression on the first alone
|
||
// wedges the node.
|
||
//
|
||
// The sweep below expires stale slots, but it is reachable ONLY on a cache
|
||
// MISS -- this early return runs first. A node that is behind asks for
|
||
// exactly one block, the one it is missing, so it produces nothing BUT
|
||
// cache hits: the sweep never runs, the slot never expires, and the one
|
||
// block it needs becomes the one block it can never ask for again. That
|
||
// slot then holds for hours past a TTL measured in seconds, suppressing
|
||
// every later ask, while the node stays behind a live chain.
|
||
if prior, exists := b.pendingContext[blockID]; exists {
|
||
if age := now.Sub(prior.timestamp); age <= pendingContextTTL {
|
||
b.contextRequestMu.Unlock()
|
||
// Genuinely in flight -- the storm re-asking for a fetch already sent.
|
||
if n, loud := catchupSample(&b.diag.dedup); loud {
|
||
b.logger.Info("catch-up fetch suppressed — a request for this block is already in flight",
|
||
log.Stringer("blockID", blockID),
|
||
log.Stringer("askedOf", prior.nodeID),
|
||
log.Duration("inFlight", age),
|
||
log.Uint64("count", n))
|
||
}
|
||
return
|
||
}
|
||
// Past TTL: the peer took the request and never answered. Drop the slot and
|
||
// fall through to re-request, exactly as the sweep would have. Same policy,
|
||
// now applied to the block actually being asked for.
|
||
delete(b.pendingContext, blockID)
|
||
if n, loud := catchupSample(&b.diag.reaped); loud {
|
||
b.logger.Warn("catch-up request expired in place — re-requesting the block it was holding",
|
||
log.Stringer("blockID", blockID),
|
||
log.Stringer("askedOf", prior.nodeID),
|
||
log.Duration("age", now.Sub(prior.timestamp)),
|
||
log.Uint64("repliesSeen", b.diag.replyIn.Load()),
|
||
log.Uint64("count", n))
|
||
}
|
||
}
|
||
|
||
// BOUND the map. Reap requests past their TTL first: a
|
||
// withheld Context no longer pins the slot, so the block is re-requestable.
|
||
// Carried out of the critical section — the log lines are emitted after the unlock,
|
||
// never under it.
|
||
reaped := 0
|
||
var reapedID ids.ID
|
||
var reapedOf ids.NodeID
|
||
var reapedAge time.Duration
|
||
for id, req := range b.pendingContext {
|
||
if age := now.Sub(req.timestamp); age > pendingContextTTL {
|
||
delete(b.pendingContext, id)
|
||
reaped++
|
||
if age > reapedAge {
|
||
reapedID, reapedOf, reapedAge = id, req.nodeID, age
|
||
}
|
||
}
|
||
}
|
||
// Then hard-cap: if still at the bound (a genuine flood inside one TTL window),
|
||
// evict the oldest so the map can NEVER exceed maxPendingContext.
|
||
evicted := false
|
||
var evictedID ids.ID
|
||
if len(b.pendingContext) >= maxPendingContext {
|
||
var oldestID ids.ID
|
||
var oldestT time.Time
|
||
first := true
|
||
for id, req := range b.pendingContext {
|
||
if first || req.timestamp.Before(oldestT) {
|
||
oldestID, oldestT, first = id, req.timestamp, false
|
||
}
|
||
}
|
||
delete(b.pendingContext, oldestID)
|
||
evicted, evictedID = true, oldestID
|
||
}
|
||
|
||
// Generate a new request ID
|
||
b.requestIDCounter++
|
||
requestID := b.requestIDCounter
|
||
|
||
// Record the pending request
|
||
b.pendingContext[blockID] = contextRequest{
|
||
nodeID: nodeID,
|
||
requestID: requestID,
|
||
blockID: blockID,
|
||
timestamp: now,
|
||
}
|
||
b.contextRequestMu.Unlock()
|
||
|
||
// A slot cleared past its TTL is a request that was never resolved by anything but
|
||
// the reaper — and pendingContext is ONLY cleared here, at the cap, and on the
|
||
// no-peer release, never on a reply. So this line is not by itself proof that no
|
||
// reply came; repliesSeen is. Zero replies across the whole run, with slots expiring,
|
||
// is the Ancestors leg failing, and nothing else on this path says so.
|
||
if reaped > 0 {
|
||
if n, loud := catchupSample(&b.diag.reaped); loud {
|
||
b.logger.Warn("catch-up requests cleared past their TTL — those blocks are requestable again",
|
||
log.Int("reaped", reaped),
|
||
log.Stringer("oldestBlockID", reapedID),
|
||
log.Stringer("oldestAskedOf", reapedOf),
|
||
log.Duration("oldestAge", reapedAge),
|
||
log.Uint64("repliesSeen", b.diag.replyIn.Load()),
|
||
log.Uint64("count", n))
|
||
}
|
||
}
|
||
if evicted {
|
||
if n, loud := catchupSample(&b.diag.evicted); loud {
|
||
b.logger.Warn("catch-up request evicted at the pending cap — distinct missing blocks are arriving faster than one TTL window",
|
||
log.Stringer("blockID", evictedID),
|
||
log.Int("cap", maxPendingContext),
|
||
log.Uint64("count", n))
|
||
}
|
||
}
|
||
|
||
// Create and send context request (wire: GetAncestors message)
|
||
msg, err := b.msgCreator.GetAncestors(
|
||
b.chainID,
|
||
requestID,
|
||
10*time.Second, // Deadline
|
||
blockID,
|
||
p2p.EngineType_ENGINE_TYPE_CHAIN, // Use chain consensus engine type
|
||
)
|
||
if err != nil {
|
||
b.logger.Error("failed to create context request message",
|
||
log.Stringer("blockID", blockID),
|
||
log.Err(err))
|
||
return
|
||
}
|
||
|
||
// PEER SELECTION (defect #3). The consensus layer signals "I hold a VERIFIED cert
|
||
// for a block I don't track — fetch it" by passing ids.EmptyNodeID (topology.go:
|
||
// requestCatchup(cert.Position.BlockID, ids.EmptyNodeID)); picking a real peer is
|
||
// the node layer's job. The prior code blindly Add(EmptyNodeID) + Send, so
|
||
// GetAncestors went to ZERO peers (every send reporting sentTo=0) and the
|
||
// certified-but-untracked block was NEVER fetched — the node saw the cert, could not
|
||
// finalize, and never recovered. When nodeID is Empty, sample real connected peers
|
||
// that track this chain's network (the SAME selection pollFrontierOnce uses); a valid
|
||
// cert already gated this request, so asking any network peer is sound (the served
|
||
// gap is cert-verified on accept).
|
||
nodeSet := set.NewSet[ids.NodeID](frontierPollSample)
|
||
// Hoisted out of the range so the peer count is available to the diagnostics below
|
||
// without a second PeerInfo call; range evaluates the expression exactly once either
|
||
// way, so this is the same walk it always was.
|
||
connected := 0
|
||
if nodeID == ids.EmptyNodeID {
|
||
peers := b.net.PeerInfo(nil)
|
||
connected = len(peers)
|
||
for _, p := range peers {
|
||
if p.TrackedChains.Contains(b.networkID) {
|
||
nodeSet.Add(p.ID)
|
||
if nodeSet.Len() >= frontierPollSample {
|
||
break
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
nodeSet.Add(nodeID)
|
||
}
|
||
if nodeSet.Len() == 0 {
|
||
// No reachable peer to serve the block. Release the pending slot so a later tick
|
||
// (frontier poll → AcceptedFrontier, or a re-gossiped cert) can retry — otherwise
|
||
// the block stays pinned unrequestable until the TTL reap.
|
||
b.contextRequestMu.Lock()
|
||
delete(b.pendingContext, blockID)
|
||
b.contextRequestMu.Unlock()
|
||
// connected > 0 with an empty sample is the TrackedChains filter matching nothing —
|
||
// the same networkID-vs-chainID confusion that once made this filter reject every
|
||
// real peer. connected == 0 is simply an unconnected node.
|
||
if n, loud := catchupSample(&b.diag.noPeer); loud {
|
||
b.logger.Warn("catch-up fetch has NO peer to ask — no GetAncestors is sent",
|
||
log.Stringer("blockID", blockID),
|
||
log.Stringer("advertisedBy", nodeID),
|
||
log.Stringer("networkID", b.networkID),
|
||
log.Int("connectedPeers", connected),
|
||
log.Uint64("count", n))
|
||
}
|
||
return
|
||
}
|
||
|
||
sentTo := b.net.Send(msg, nodeSet, b.networkID, 0)
|
||
if sentTo.Len() == 0 {
|
||
// GetAncestors reached ZERO peers: the node holds a verified cert for a block it
|
||
// cannot fetch, and until this line nothing on the wire said so. This is the known
|
||
// historical failure of this exact call, so it is Warn — sampled only so a storm
|
||
// cannot bury its first occurrence.
|
||
if n, loud := catchupSample(&b.diag.unsent); loud {
|
||
b.logger.Warn("catch-up GetAncestors reached ZERO peers — the gap cannot be fetched",
|
||
log.Stringer("blockID", blockID),
|
||
log.Stringer("advertisedBy", nodeID),
|
||
log.Uint32("requestID", requestID),
|
||
log.Int("asked", nodeSet.Len()),
|
||
log.Uint64("count", n))
|
||
}
|
||
return
|
||
}
|
||
b.logger.Info("requested context for missing prerequisites",
|
||
log.Stringer("from", nodeID),
|
||
log.Stringer("blockID", blockID),
|
||
log.Uint32("requestID", requestID),
|
||
log.Int("asked", nodeSet.Len()),
|
||
log.Int("sentTo", sentTo.Len()))
|
||
}
|
||
|
||
func (b *blockHandler) Runtime() *runtime.Runtime { return nil }
|
||
func (b *blockHandler) Start(ctx context.Context, startReqID uint32) {}
|
||
func (b *blockHandler) Push(ctx context.Context, msg handler.Message) {}
|
||
func (b *blockHandler) Len() int { return 0 }
|
||
func (b *blockHandler) Get(ctx context.Context, nodeID ids.NodeID, requestID uint32, deadline time.Time, msg []byte) error {
|
||
return nil
|
||
}
|
||
|
||
// catchupEntryMagic tags a CERT-CARRYING catch-up container so a requester can
|
||
// tell it apart from a legacy raw-block container. "LCU2" = Lux Catch-Up wire v2.
|
||
// A real block's bytes never start with this: an EVM/RLP block opens with an RLP
|
||
// list header (0xf8/0xf9/0xfa) and a P/X-chain block with a codec version (0x0000),
|
||
// neither of which is 0x4C ('L'). So the discriminator is unambiguous AND a
|
||
// cross-version exchange fails CLEANLY: a legacy decoder reads these 4 magic bytes
|
||
// as a uint32 length (0x4C435532 ≈ 1.28 GB) which exceeds the buffer, so it rejects
|
||
// the frame (0 blocks processed) — no panic, no misparse, no bad accept.
|
||
var catchupEntryMagic = [4]byte{'L', 'C', 'U', '2'}
|
||
|
||
// encodeCatchupEntry frames ONE catch-up container pairing a block with its
|
||
// finality cert:
|
||
//
|
||
// magic:4 | blockLen:4 | block | certLen:4 | cert (all big-endian)
|
||
//
|
||
// certLen == 0 means "no finality cert available for this block" — e.g. a still
|
||
// pending (unfinalized) block served on the live missing-parent path; the requester
|
||
// then votes on it (the legacy path) instead of cert-accepting. The Ancestors
|
||
// flattener wraps this whole entry again as one [entryLen:4][entry] container, so
|
||
// the existing outer framing is reused unchanged.
|
||
func encodeCatchupEntry(blockBytes, certBytes []byte) []byte {
|
||
out := make([]byte, 0, 4+4+len(blockBytes)+4+len(certBytes))
|
||
out = append(out, catchupEntryMagic[:]...)
|
||
var u32 [4]byte
|
||
binary.BigEndian.PutUint32(u32[:], uint32(len(blockBytes)))
|
||
out = append(out, u32[:]...)
|
||
out = append(out, blockBytes...)
|
||
binary.BigEndian.PutUint32(u32[:], uint32(len(certBytes)))
|
||
out = append(out, u32[:]...)
|
||
out = append(out, certBytes...)
|
||
return out
|
||
}
|
||
|
||
// decodeCatchupEntry is the inverse of encodeCatchupEntry. ok is false (NOT an
|
||
// error) when entry is not a v2 cert-carrying frame — no magic, or a structurally
|
||
// invalid one (lengths that do not consume the entry EXACTLY). The caller then
|
||
// treats entry as a legacy raw block. Strict: any overflow or trailing bytes ⇒ not
|
||
// a v2 entry (fail to the legacy interpretation, never a partial parse).
|
||
func decodeCatchupEntry(entry []byte) (blockBytes, certBytes []byte, ok bool) {
|
||
if len(entry) < 4+4 || [4]byte{entry[0], entry[1], entry[2], entry[3]} != catchupEntryMagic {
|
||
return nil, nil, false
|
||
}
|
||
rest := entry[4:]
|
||
blockLen := binary.BigEndian.Uint32(rest[:4])
|
||
rest = rest[4:]
|
||
if uint64(blockLen) > uint64(len(rest)) {
|
||
return nil, nil, false
|
||
}
|
||
blockBytes = rest[:blockLen]
|
||
rest = rest[blockLen:]
|
||
if len(rest) < 4 {
|
||
return nil, nil, false
|
||
}
|
||
certLen := binary.BigEndian.Uint32(rest[:4])
|
||
rest = rest[4:]
|
||
if uint64(certLen) != uint64(len(rest)) { // must consume the entry EXACTLY
|
||
return nil, nil, false
|
||
}
|
||
return blockBytes, rest, true
|
||
}
|
||
|
||
// GetContext responds to a request for verification context (parent chain blocks)
|
||
// starting from containerID. We respond with up to maxAncestors blocks in
|
||
// chronological order (oldest first) so the requester can attach the missing
|
||
// context. Each block is paired with its finality cert (encodeCatchupEntry) when we
|
||
// have one (CertForBlock) so a BEHIND requester can finalize the gap via the cert
|
||
// path; a still-pending block is served with an empty cert (the requester votes on
|
||
// it, the legacy live missing-parent behaviour).
|
||
func (b *blockHandler) GetContext(ctx context.Context, nodeID ids.NodeID, requestID uint32, deadline time.Time, containerID ids.ID) error {
|
||
if b.vm == nil || b.net == nil || b.msgCreator == nil {
|
||
// A peer asked us to serve a gap and this handler has nothing to serve it with.
|
||
// Unnamed, that is indistinguishable from the request never arriving.
|
||
if n, loud := catchupSample(&b.diag.serveNoWire); loud {
|
||
b.logger.Warn("catch-up request NOT served — no VM or network transport on this handler",
|
||
log.Stringer("from", nodeID),
|
||
log.Stringer("containerID", containerID),
|
||
log.Uint64("count", n))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Claim a serving slot BEFORE touching the VM. Everything below this line — the
|
||
// block walk, the assembled containers, the wire message — is memory a peer asked
|
||
// us to spend, and a peer that has fallen behind asks continuously, of everyone.
|
||
// Declining costs the asker one retry on a protocol that already retries.
|
||
if !b.beginServe(nodeID) {
|
||
if n, loud := catchupSample(&b.diag.serveBusy); loud {
|
||
b.logger.Info("catch-up request declined — already answering this peer, or at the serving limit",
|
||
log.Stringer("from", nodeID),
|
||
log.Stringer("containerID", containerID),
|
||
log.Uint64("count", n))
|
||
}
|
||
return nil
|
||
}
|
||
defer b.endServe(nodeID)
|
||
|
||
if n, loud := catchupSample(&b.diag.serveAsked); loud {
|
||
b.logger.Info("serving a catch-up request",
|
||
log.Stringer("from", nodeID),
|
||
log.Stringer("containerID", containerID),
|
||
log.Uint32("requestID", requestID),
|
||
log.Uint64("count", n))
|
||
}
|
||
|
||
// Collect context blocks (walk parent chain).
|
||
//
|
||
// SIZE-CHUNKING (the heavy-DEX-block self-heal fix). The response is the Ancestors
|
||
// wire message, whose UNCOMPRESSED size the peer compressor refuses above the message
|
||
// cap (constants.DefaultMaxMessageSize; the zstd compressor bounds its input to prevent a
|
||
// decompression bomb). The old loop bounded ONLY by COUNT (maxContextBlocks=256), so under
|
||
// heavy DEX load 256 blocks summed to 3.4-5.7 MB > the 2 MB cap, msgCreator.Ancestors FAILED
|
||
// to build, and the behind validator got NOTHING — it could never resync and fell
|
||
// permanently behind (the benchmark-proven stall). We now ALSO bound by serialized size:
|
||
// stop before the accumulated payload would exceed the budget, but ALWAYS include at least
|
||
// one block so a behind node makes progress every round; the requester re-requests for the
|
||
// remaining gap (GetAncestors/context is already a multi-round, oldest-first fill). A single
|
||
// block that alone exceeds the budget is still served (best-effort) so the walk never
|
||
// deadlocks — the trust-tiered validator cap (peer layer) gives such a block the headroom to
|
||
// actually send; for a stranger it will be refused by the tight cap, which is correct.
|
||
var containers [][]byte
|
||
currentID := containerID
|
||
|
||
// Leave margin under the cap for the p2p envelope (chainID, requestID, per-container length
|
||
// prefixes, compression framing) so the assembled message stays comfortably below the limit.
|
||
const contextResponseMargin = 128 * 1024 // 128 KiB
|
||
byteBudget := constants.DefaultMaxMessageSize - contextResponseMargin
|
||
accumulated := 0
|
||
truncatedForSize := false
|
||
|
||
for i := 0; i < b.maxContextBlocks; i++ {
|
||
// First check pending blocks (for recently proposed but not yet accepted blocks)
|
||
// This is critical: when we propose a block and send PullQuery, other validators
|
||
// request context for the proposed block which may not be accepted yet.
|
||
var blk chain.Block
|
||
var found bool
|
||
|
||
if b.engine != nil {
|
||
blk, found = b.engine.GetPendingBlock(currentID)
|
||
}
|
||
|
||
if !found {
|
||
// Fall back to VM storage for accepted blocks
|
||
var err error
|
||
blk, err = b.vm.GetBlock(ctx, currentID)
|
||
if err != nil {
|
||
// Block not found in either location, stop walking
|
||
break
|
||
}
|
||
}
|
||
|
||
// Pair the block with its finality cert (empty if we have none — a still
|
||
// pending block, or one whose cert aged out of the served window). The
|
||
// requester cert-accepts when a cert is present and votes otherwise. Prepend
|
||
// to keep oldest-first.
|
||
var certBytes []byte
|
||
if b.engine != nil {
|
||
certBytes, _ = b.engine.CertForBlock(blk)
|
||
}
|
||
entry := encodeCatchupEntry(blk.Bytes(), certBytes)
|
||
|
||
// SIZE GATE: stop before exceeding the budget — but never drop the FIRST block, so a
|
||
// behind node always receives at least one block per request and cannot deadlock.
|
||
if len(containers) > 0 && accumulated+len(entry) > byteBudget {
|
||
truncatedForSize = true
|
||
break
|
||
}
|
||
accumulated += len(entry)
|
||
containers = append([][]byte{entry}, containers...)
|
||
|
||
// Get parent ID for next iteration
|
||
parentID := blk.Parent()
|
||
if parentID == ids.Empty {
|
||
// Reached genesis, stop
|
||
break
|
||
}
|
||
currentID = parentID
|
||
}
|
||
|
||
if len(containers) == 0 {
|
||
// We hold neither the requested block nor any ancestor of it, so NOTHING goes back
|
||
// on the wire — the requester gets silence, not an empty batch. This is the serving
|
||
// half of the distinction the requester cannot make on its own.
|
||
if n, loud := catchupSample(&b.diag.serveEmpty); loud {
|
||
b.logger.Warn("catch-up request served NOTHING — we do not hold the requested block, and NO reply is sent",
|
||
log.Stringer("from", nodeID),
|
||
log.Stringer("containerID", containerID),
|
||
log.Uint32("requestID", requestID),
|
||
log.Uint64("count", n))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Create and send Context response (wire protocol uses Ancestors message type)
|
||
msg, err := b.msgCreator.Ancestors(b.chainID, requestID, containers)
|
||
if err != nil {
|
||
b.logger.Error("failed to create context response",
|
||
log.Stringer("containerID", containerID),
|
||
log.Err(err))
|
||
return nil
|
||
}
|
||
|
||
nodeSet := set.NewSet[ids.NodeID](1)
|
||
nodeSet.Add(nodeID)
|
||
|
||
sentTo := b.net.Send(msg, nodeSet, b.networkID, 0)
|
||
b.logger.Info("sent context response",
|
||
log.Stringer("to", nodeID),
|
||
log.Stringer("containerID", containerID),
|
||
log.Uint32("requestID", requestID),
|
||
log.Int("numBlocks", len(containers)),
|
||
log.Int("payloadBytes", accumulated),
|
||
log.Bool("truncatedForSize", truncatedForSize),
|
||
log.Int("sentTo", sentTo.Len()))
|
||
|
||
return nil
|
||
}
|
||
|
||
// handleContext processes an incoming context response (wire: Ancestors message).
|
||
// This is called when we previously requested context for a block we couldn't verify.
|
||
// Each block in the context is processed via Put to add it to our state.
|
||
// After processing, we drain any buffered Qbits that were waiting for context.
|
||
// shouldDescend is the whole descent rule, as one question about values.
|
||
//
|
||
// A batch that accepted nothing is answering the wrong question: the responder
|
||
// serves a window ending at the id it was asked for, so a node further behind than
|
||
// that window sees every entry above its tip and refuses all of them. Asking the
|
||
// same peer for the same tip returns the same window forever, so we ask instead for
|
||
// the oldest entry's PARENT — the next window down — and walk toward our own tip.
|
||
//
|
||
// The walk ends at genesis. Genesis has height 0 and the empty id for a parent, so a
|
||
// batch that starts there has nothing below it; asking a peer for the empty id asks
|
||
// for a block that cannot exist. The peer answers "not found" or the plugin answers
|
||
// with a zero-length parent, the response carries nothing, and we arrive back here to
|
||
// ask the same impossible question of the next peer. Nothing ends that on its own:
|
||
// with no finalized ledger every window reads as "above" a tip of zero.
|
||
// batch is what one catch-up reply did to this node. It exists so the choice of
|
||
// WHICH column decides a descent is made inside a tested function instead of at
|
||
// the call site, where it was a bare argument and no test could reach it: the
|
||
// call could be handed certAccepted instead of advanced and every test in the
|
||
// package still passed.
|
||
type batch struct {
|
||
// advanced is the delta in the APPLIED head. It is the only column that says
|
||
// this node got closer to its own tip.
|
||
advanced uint64
|
||
// certAccepted counts certs folded for blocks that may sit ABOVE the gap and
|
||
// apply nothing. It is deliberately not consulted by shouldDescend, and it is
|
||
// carried here so that omission is visible rather than implicit.
|
||
certAccepted uint64
|
||
|
||
haveOldest bool
|
||
oldestHeight uint64
|
||
oldestParent ids.ID
|
||
finalized uint64
|
||
finalizedSet bool
|
||
}
|
||
|
||
func shouldDescend(b batch) bool {
|
||
advanced, haveOldest, oldestHeight, oldestParent, finalized, finalizedSet :=
|
||
b.advanced, b.haveOldest, b.oldestHeight, b.oldestParent, b.finalized, b.finalizedSet
|
||
// advanced, not certAccepted. A batch can accept hundreds of certs for blocks
|
||
// ABOVE the gap -- tracked, deferred, applying nothing -- and gating on that
|
||
// reads the walk as progress and leaves the anchor where it is. Observed on
|
||
// mainnet as `advanced=0 certAccepted=208` in the same log line, on four of five
|
||
// validators, one block id re-requested 396 times while the applied head never
|
||
// moved. The delta in the applied head is the only column that says whether this
|
||
// node got closer to its own tip.
|
||
if advanced > 0 || !haveOldest {
|
||
return false
|
||
}
|
||
if oldestHeight == 0 || oldestParent == ids.Empty {
|
||
return false
|
||
}
|
||
return !finalizedSet || oldestHeight > finalized+1
|
||
}
|
||
|
||
// descend moves the walk to next and reports whether it moved.
|
||
//
|
||
// A walk only ever goes DOWN. Every peer answers the same behind node at once, and
|
||
// each reply names the window IT served, so adopting whichever reply landed last lets
|
||
// a peer far above the gap re-anchor a walk that had already descended past it. The
|
||
// highest reply wins that race most rounds, and the walk sits at the same distance
|
||
// from our tip forever: our own finalized height holds while the anchor jumps around
|
||
// above it, and the one reply within a window of the gap is overwritten before its
|
||
// request goes out.
|
||
//
|
||
// Strictly decreasing, the walk reaches our tip in gap/window steps and stops.
|
||
//
|
||
// Liveness only, like the anchor it maintains: this picks which block we ask a peer
|
||
// for. Finality still requires a verified quorum cert and still advances in order.
|
||
func (b *blockHandler) descend(next ids.ID, nextHeight uint64) bool {
|
||
b.contextRequestMu.Lock()
|
||
defer b.contextRequestMu.Unlock()
|
||
if b.descentAnchor != ids.Empty && nextHeight >= b.descentHeight {
|
||
return false
|
||
}
|
||
b.descentAnchor, b.descentHeight = next, nextHeight
|
||
return true
|
||
}
|
||
|
||
// catchupTarget is the height this node actually needs next.
|
||
//
|
||
// FinalizedLedger reports what CONSENSUS certified; AppliedHead reports what the VM
|
||
// committed. The two persist separately, so an unclean stop brings a node back with
|
||
// finality above the block its VM holds — and then nothing moves. Finality cannot
|
||
// advance, because the fail-closed guard refuses to run past applied state; and
|
||
// catch-up, steering by the ledger, asks for the height ABOVE the gap, so the blocks
|
||
// the VM is missing are never fetched. The ledger can lead the VM by thousands of
|
||
// blocks: every cert is then refused for a parent the VM does not hold, and no descent
|
||
// runs at all, because from the ledger's side certs are being accepted.
|
||
//
|
||
// A block can only be applied on top of the one before it, so the LOWER of the two is
|
||
// what to ask a peer for.
|
||
func (b *blockHandler) catchupTarget(ctx context.Context) (uint64, bool) {
|
||
_, fh, set := b.engine.FinalizedLedger()
|
||
if !set {
|
||
return fh, false // nothing certified yet — unchanged, descend freely
|
||
}
|
||
if _, applied, err := b.engine.AppliedHead(ctx); err == nil && applied < fh {
|
||
return applied, true // the VM is the binding constraint
|
||
}
|
||
return fh, true
|
||
}
|
||
|
||
// retire drops the walk once finality has passed the window it was looking for.
|
||
//
|
||
// The walk exists to FIND the gap — the window holding our next contiguous height.
|
||
// While finality is still below that window the walk is still the right question to
|
||
// be asking, and dropping it there costs a full descent to ask it again.
|
||
func (b *blockHandler) retire(finalized uint64) {
|
||
b.contextRequestMu.Lock()
|
||
defer b.contextRequestMu.Unlock()
|
||
if b.descentAnchor != ids.Empty && finalized >= b.descentHeight {
|
||
b.descentAnchor, b.descentHeight = ids.Empty, 0
|
||
}
|
||
}
|
||
|
||
func (b *blockHandler) handleContext(ctx context.Context, nodeID ids.NodeID, requestID uint32, data []byte) error {
|
||
// Count EVERY inbound Ancestors reply, whatever becomes of it: this counter is the
|
||
// answer to "did a reply EVER arrive", which the TTL-expiry line reports and which
|
||
// nothing else on this path carries.
|
||
if n, loud := catchupSample(&b.diag.replyIn); loud {
|
||
b.logger.Info("catch-up response received",
|
||
log.Stringer("from", nodeID),
|
||
log.Uint32("requestID", requestID),
|
||
log.Int("dataLen", len(data)),
|
||
log.Uint64("count", n))
|
||
}
|
||
if b.vm == nil {
|
||
if n, loud := catchupSample(&b.diag.replyNoVM); loud {
|
||
b.logger.Warn("catch-up response DROPPED — no VM on this handler",
|
||
log.Stringer("from", nodeID),
|
||
log.Uint32("requestID", requestID),
|
||
log.Uint64("count", n))
|
||
}
|
||
return nil
|
||
}
|
||
if len(data) == 0 {
|
||
// An EMPTY reply and NO reply are the same silence downstream and mean opposite
|
||
// things: the peer answered and had nothing, or the peer never answered at all.
|
||
// This is the only place the two are distinguishable on the receiving side.
|
||
if n, loud := catchupSample(&b.diag.replyEmpty); loud {
|
||
b.logger.Warn("catch-up response arrived EMPTY — the peer replied with zero bytes (this is NOT a missing reply)",
|
||
log.Stringer("from", nodeID),
|
||
log.Uint32("requestID", requestID),
|
||
log.Uint64("count", n))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// INITIAL SYNC: while the bootstrap loop is driving, this Ancestors reply is the
|
||
// oldest-first batch the loop's Ancestors() requested — hand the raw blocks to that
|
||
// waiting call (correlated by requestID); the loop decides ordering + re-execution.
|
||
// See bootstrap_sync.go.
|
||
if b.deliverBootstrapAncestors(nodeID, requestID, data) {
|
||
// Consumed by the bootstrap lane: the live cert path never sees these blocks, so
|
||
// a reply landing here explains an unchanged live height.
|
||
if n, loud := catchupSample(&b.diag.replyBootstrap); loud {
|
||
b.logger.Info("catch-up response consumed by initial sync — the live cert path did not see it",
|
||
log.Stringer("from", nodeID),
|
||
log.Uint32("requestID", requestID),
|
||
log.Int("dataLen", len(data)),
|
||
log.Uint64("count", n))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// The outer framing (added by the Ancestors flattener) is
|
||
// [entryLen:4][entry][entryLen:4][entry]..., oldest-first. Each entry is either a
|
||
// v2 cert-carrying frame (magic|blockLen|block|certLen|cert) or a legacy raw
|
||
// block. We decode the outer length, then dispatch the entry:
|
||
// - cert present → AcceptCatchupBlock: parse → Verify → finalize via the SAME
|
||
// verified-cert predicate as live finality (the network already decided this
|
||
// height; we cannot re-vote it). A bad cert is REJECTED here, never finalized.
|
||
// - no cert / legacy block → Put: the voting path (a fresh near-tip block on the
|
||
// live missing-parent path, where the network is still voting).
|
||
// Entries are applied in order; a rejected entry (out-of-order, already-have, or
|
||
// bad cert) is skipped and the loop CONTINUES — the next entry may be the right
|
||
// contiguous one, and a behind node re-polls the frontier on its next tick.
|
||
processed := 0
|
||
remaining := data
|
||
// Per-response disposition. processed counts what LANDED; the rest count what did
|
||
// not, and firstReason carries the FIRST refusal verbatim. Held as locals and
|
||
// reported on the one summary line below: the per-entry detail is up to 256 lines a
|
||
// response, which is exactly the volume that buries the summary.
|
||
certAccepted, certRejected, voted, voteFailed, badFrame := 0, 0, 0, 0, 0
|
||
// deferred counts blocks verified and TRACKED, waiting for the fold to reach their
|
||
// height — the engine's one non-failure refusal. It was counted among the failures,
|
||
// so a node doing exactly the right thing read as a node being refused.
|
||
deferred := 0
|
||
// missingAncestor counts entries whose block and cert were both sound but whose
|
||
// parent envelope this node does not hold. It is deliberately NOT certRejected:
|
||
// the two were one number, so a node fetching exactly what it needed looked
|
||
// identical to a node being served forgeries.
|
||
missingAncestor := 0
|
||
// The applied head, read before and after the batch. Every other counter here says
|
||
// what this loop DID; only the head delta says whether the node MOVED. certAccepted
|
||
// once counted ledger-gate skips as successes — 253 per batch on a node applying
|
||
// nothing — and every diagnosis made from that number started from a lie.
|
||
appliedBefore, _ := b.appliedHeight(ctx)
|
||
// Where this batch starts, kept so a batch that lands NOTHING can still walk the
|
||
// request window down toward our tip. See the descent after the loop.
|
||
var oldestHeight uint64
|
||
var oldestParent ids.ID
|
||
haveOldest := false
|
||
firstReason := ""
|
||
note := func(reason string) {
|
||
if firstReason == "" {
|
||
firstReason = reason
|
||
}
|
||
}
|
||
|
||
for len(remaining) >= 4 {
|
||
entryLen := int(binary.BigEndian.Uint32(remaining[:4]))
|
||
remaining = remaining[4:]
|
||
if entryLen <= 0 || entryLen > len(remaining) {
|
||
badFrame++
|
||
note(fmt.Sprintf("bad frame: entryLen %d with %d bytes remaining", entryLen, len(remaining)))
|
||
b.logger.Debug("invalid context entry length",
|
||
log.Stringer("from", nodeID),
|
||
log.Int("processed", processed),
|
||
log.Int("entryLen", entryLen),
|
||
log.Int("remaining", len(remaining)))
|
||
break
|
||
}
|
||
|
||
entry := remaining[:entryLen]
|
||
remaining = remaining[entryLen:]
|
||
|
||
// One decode decides the route. A v2 frame yields (block, cert); a legacy raw
|
||
// container yields !isV2, and the whole entry IS the block.
|
||
blockBytes, certBytes, isV2 := decodeCatchupEntry(entry)
|
||
|
||
// Remember where this batch STARTS. The responder serves a window ending at the
|
||
// id it was asked for, so on a node further behind than that window the oldest
|
||
// entry still sits above our tip and every entry is refused. The parent of that
|
||
// oldest entry is the next window down, and asking for it is the only thing that
|
||
// moves the window toward us.
|
||
raw := blockBytes
|
||
if !isV2 {
|
||
raw = entry
|
||
}
|
||
if !haveOldest {
|
||
if parsed, perr := b.vm.ParseBlock(ctx, raw); perr == nil {
|
||
oldestHeight, oldestParent, haveOldest = parsed.Height(), parsed.Parent(), true
|
||
} else if n, loud := catchupSample(&b.diag.blockUnparsed); loud {
|
||
// The oldest entry's parent is the only thing that steps the window down. Losing
|
||
// it does not stop the node, it stops the DESCENT — which presents as a chain
|
||
// sitting still, the one symptom that looks identical to having nothing to do.
|
||
b.logger.Warn("catch-up block did not parse - window cannot descend",
|
||
log.Stringer("from", nodeID),
|
||
log.Uint64("count", n),
|
||
log.Err(perr))
|
||
}
|
||
}
|
||
|
||
if isV2 && len(certBytes) > 0 {
|
||
// CERT path: finalize the gap block on its verified cert (no re-vote).
|
||
if err := b.engine.AcceptCatchupBlock(ctx, blockBytes, certBytes); err != nil {
|
||
if errors.Is(err, consensuschain.ErrCatchupDeferred) {
|
||
// Verified and tracked, finality deferred until the fold reaches its
|
||
// height — progress, not a refusal. Counting it as rejected is how a
|
||
// converging node logged hundreds of failures per batch.
|
||
deferred++
|
||
processed++
|
||
continue
|
||
}
|
||
if errors.Is(err, consensuschain.ErrCatchupMissingAncestor) {
|
||
// The block and its cert are fine; this node does not hold the
|
||
// OUTER envelope the block names as its parent, because its own
|
||
// wrapper of that height differs from the network's. Trying
|
||
// another peer cannot help — every peer serves the same envelope
|
||
// — so ask THIS one for the parent. GetContext walks outer parents
|
||
// down from any id the responder holds, so the reply is the
|
||
// envelope chain running down to this node's own head.
|
||
if parsed, perr := b.vm.ParseBlock(ctx, raw); perr == nil {
|
||
b.requestContext(ctx, nodeID, parsed.Parent())
|
||
missingAncestor++
|
||
continue
|
||
}
|
||
}
|
||
certRejected++
|
||
note("cert-accept: " + err.Error())
|
||
b.logger.Debug("catch-up cert-accept rejected (skipping entry)",
|
||
log.Stringer("from", nodeID),
|
||
log.Int("processed", processed),
|
||
log.Err(err))
|
||
continue // not finalized; the cert-gate held — try the next entry
|
||
}
|
||
certAccepted++
|
||
processed++
|
||
continue
|
||
}
|
||
|
||
// An entry that arrives WITHOUT its own cert. This is ancestry we asked for,
|
||
// not live gossip: a responder attaches a cert only for blocks inside its
|
||
// served window, so a node behind by more than that window receives a whole
|
||
// response of blocks carrying none.
|
||
//
|
||
// EVERY BLOCK STILL REQUIRES A CERT TO FINALIZE. Nothing is accepted on a
|
||
// peer's say-so. The entry is parsed and locally Verified, then TRACKED —
|
||
// tracking is not finalizing, and AcceptCatchupBlock returns an error for it
|
||
// precisely because it did not finalize. Finality happens in exactly one
|
||
// place, HandleIncomingCert, behind the same α-floor, set-root and
|
||
// VerifyWeighted ⅔-of-stake checks as every other path.
|
||
//
|
||
// What a tracked block buys is that a LATER verified cert can finalize it.
|
||
// That is sound because the chain is hash-linked: a block commits to its
|
||
// parent by hash, so a verified quorum cert on a descendant certifies its
|
||
// entire ancestry — no block can be substituted into that path without
|
||
// breaking the commitment. The fold walks only that certified ancestry, so an
|
||
// uncertified branch is never reachable no matter what a peer sends us.
|
||
//
|
||
// They must NOT go through Put. Put is the live missing-parent path and
|
||
// self-fetches context for each block it cannot place, so routing a 256-entry
|
||
// response through it turns ONE answer into 256 new requests — hundreds of
|
||
// requests a minute against a node that applies nothing of what it is served.
|
||
// Voting is also the wrong instrument: the network does not re-vote a
|
||
// height it has already decided.
|
||
if !isV2 {
|
||
blockBytes = entry
|
||
}
|
||
if err := b.engine.AcceptCatchupBlock(ctx, blockBytes, nil); err != nil {
|
||
if errors.Is(err, consensuschain.ErrCatchupDeferred) {
|
||
deferred++
|
||
processed++
|
||
continue
|
||
}
|
||
voteFailed++
|
||
note("track: " + err.Error())
|
||
b.logger.Debug("catch-up entry verified and tracked, awaiting a cert (or refused)",
|
||
log.Stringer("from", nodeID),
|
||
log.Int("processed", processed),
|
||
log.Err(err))
|
||
continue // never finalized without a cert — keep draining the batch
|
||
}
|
||
voted++
|
||
processed++
|
||
}
|
||
|
||
// One line per response, carrying the whole disposition: what the peer served, how
|
||
// much of it LANDED, how much was refused, and the first reason it was refused. A
|
||
// response of 250 blocks that finalizes none reads identically to a response of one
|
||
// that finalizes it, until this line separates them.
|
||
//
|
||
// advanced is the one honest number: the applied-head delta across the batch. The
|
||
// other counters describe what this loop did with what it was served; only the
|
||
// delta says whether the NODE moved. A batch reporting hundreds accepted with
|
||
// advanced=0 is a node discarding its own recovery, whatever the other columns say.
|
||
appliedAfter, _ := b.appliedHeight(ctx)
|
||
var advanced uint64
|
||
if appliedAfter > appliedBefore {
|
||
advanced = appliedAfter - appliedBefore
|
||
}
|
||
b.logger.Info("processed context entries",
|
||
log.Stringer("from", nodeID),
|
||
log.Uint32("requestID", requestID),
|
||
log.Int("processed", processed),
|
||
log.Uint64("advanced", advanced),
|
||
log.Uint64("appliedHead", appliedAfter),
|
||
log.Int("certAccepted", certAccepted),
|
||
log.Int("certRejected", certRejected),
|
||
log.Int("deferred", deferred),
|
||
log.Int("missingAncestor", missingAncestor),
|
||
log.Int("voted", voted),
|
||
log.Int("voteFailed", voteFailed),
|
||
log.Int("badFrame", badFrame),
|
||
log.String("firstReason", firstReason))
|
||
|
||
// DESCENT. A batch that landed nothing because every entry sits above our next
|
||
// contiguous height is not a failure to be logged — it is the answer to the wrong
|
||
// question. The responder serves a window ending at the id it was asked for, and we
|
||
// asked for the peer's TIP, so on a node further behind than that window the whole
|
||
// batch is above us and AcceptCatchupBlock refuses all of it. Asking again for the
|
||
// same tip gets the same window forever.
|
||
//
|
||
// So ask for the oldest entry's PARENT: that is the next window down. Each response
|
||
// walks the request one window closer until it reaches finalized+1, at which point
|
||
// the batch connects and the normal path drains the rest. One request per response,
|
||
// so this descends rather than amplifies, and requestContext's own TTL gate bounds
|
||
// it.
|
||
//
|
||
// Without this a node between "further behind than one served window" and "far
|
||
// enough behind to bootstrap" has no way home at all: runtime catch-up refuses to
|
||
// descend and the bootstrapper refuses the gap, so a validator thousands of blocks
|
||
// behind a live network sits in that hole holding every block it needs.
|
||
// The test is whether the batch ADVANCED us, which is certAccepted — the only
|
||
// counter that means a block became ours. `processed` counts entries handled, and
|
||
// a batch of 250 blocks that finalizes none still reports processed=250, so gating
|
||
// on processed==0 meant the descent almost never fired. Requiring certRejected>0
|
||
// narrowed it further to batches that carried certs at all: a responder serves an
|
||
// empty cert for any block outside its window, so the common batch — every entry
|
||
// above us, none of them certified — reported certRejected=0 and was read as
|
||
// nothing to descend from: thousands of requests, a handful of descents.
|
||
fh, set := b.catchupTarget(ctx)
|
||
|
||
// A walk ends when our own finality reaches it — not on the first block it wins.
|
||
// Retiring it on ANY successful fold restarts the descent from the network tip
|
||
// after every single block, so a node N blocks behind pays the whole descent N
|
||
// times and advances one height per full walk — a node thousands of blocks behind
|
||
// re-descends every window between it and the tip to gain a single height.
|
||
if certAccepted > 0 && set {
|
||
b.retire(fh)
|
||
}
|
||
|
||
if shouldDescend(batch{
|
||
advanced: advanced,
|
||
certAccepted: uint64(certAccepted),
|
||
haveOldest: haveOldest,
|
||
oldestHeight: oldestHeight,
|
||
oldestParent: oldestParent,
|
||
finalized: fh,
|
||
finalizedSet: set,
|
||
}) {
|
||
if b.descend(oldestParent, oldestHeight-1) {
|
||
b.logger.Info("catch-up batch was entirely above our tip — descending to its parent",
|
||
log.Stringer("from", nodeID),
|
||
log.Uint64("oldestHeight", oldestHeight),
|
||
log.Uint64("finalized", fh),
|
||
log.Stringer("requesting", oldestParent))
|
||
}
|
||
// Re-drive either way. requestContext asks for the anchor, so a reply from
|
||
// above the walk still pushes the walk's own next request out — that is what
|
||
// keeps the descent moving when a reply is lost.
|
||
b.requestContext(ctx, nodeID, oldestParent)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// GetAcceptedFrontier answers a peer asking "what is your accepted tip?" with our
|
||
// last-accepted block id. This MUST NOT be a no-op: it is how a behind node learns
|
||
// the network tip. A no-op here meant every node received an empty frontier and
|
||
// assumed its own last-accepted WAS the frontier — so a node that fell behind could
|
||
// never discover it was behind (and bootstrap stopped at its own height instead of
|
||
// the network's). Same dead-handler class as the GossipOp/catch-up gaps.
|
||
func (b *blockHandler) GetAcceptedFrontier(ctx context.Context, nodeID ids.NodeID, requestID uint32, deadline time.Time) error {
|
||
if b.vm == nil || b.net == nil || b.msgCreator == nil {
|
||
return nil
|
||
}
|
||
acceptedBlkID, err := b.vm.LastAccepted(ctx)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
msg, err := b.msgCreator.AcceptedFrontier(b.chainID, requestID, acceptedBlkID)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
nodeSet := set.NewSet[ids.NodeID](1)
|
||
nodeSet.Add(nodeID)
|
||
b.net.Send(msg, nodeSet, b.networkID, 0)
|
||
return nil
|
||
}
|
||
|
||
// AcceptedFrontier handles a peer's reply to GetAcceptedFrontier: the peer's
|
||
// accepted tip. If we do not have that block, we are BEHIND — pull the chain
|
||
// ending at it through the catch-up transport (requestContext → GetAncestors;
|
||
// the missing-parent path walks down the rest of the gap and the blocks arrive
|
||
// via handleContext → Put → engine). This is the proactive half that lets a node
|
||
// which missed the block gossip recover without a restart.
|
||
func (b *blockHandler) AcceptedFrontier(ctx context.Context, nodeID ids.NodeID, requestID uint32, containerID ids.ID) error {
|
||
if b.vm == nil || containerID == ids.Empty {
|
||
return nil
|
||
}
|
||
// INITIAL SYNC: while the bootstrap loop is driving, this reply is a frontier vote the
|
||
// loop asked for — hand it to FrontierTip TAGGED with the sender (so it can be weighted
|
||
// by the beacon's stake in the ⅔ quorum) and do NOT auto-fetch (the loop owns the
|
||
// descent). See bootstrap_sync.go.
|
||
if b.deliverBootstrapFrontier(nodeID, containerID) {
|
||
b.logFrontierDecision(&b.diag.frontierBootstrap, "consumed by initial sync — the loop owns the descent", nodeID, containerID)
|
||
return nil
|
||
}
|
||
if blk, err := b.vm.GetBlock(ctx, containerID); err == nil {
|
||
// HAVE-BLOCK, LACK-FINALIZATION (defect #5). Holding the peer's tip block does NOT
|
||
// mean we are caught up. A verified-but-unfinalized block (we voted for it, but the
|
||
// α-of-K cert never reached us) leaves us behind on the CERT, not the block bytes.
|
||
// The prior check returned "not behind" on GetBlock success, so such a node NEVER
|
||
// fetched the missing cert and sat stuck at its unfinalized height forever (the exact
|
||
// "verified 288 but no cert" condition). Only "have the block AND it is finalized
|
||
// here" is truly not-behind; otherwise fall through to fetch the cert-carrying gap so
|
||
// AcceptCatchupBlock can finalize it on its verified cert (no re-vote).
|
||
if b.engine == nil {
|
||
// No engine: finalized and merely-held cannot be told apart, so nothing is
|
||
// fetched. That is a degraded handler, not a trace — Warn, as with the mode gate.
|
||
if n, loud := catchupSample(&b.diag.frontierNoEngine); loud {
|
||
b.logger.Warn("frontier reply cannot be judged — no consensus engine on this handler; NOT fetching",
|
||
log.Stringer("from", nodeID),
|
||
log.Stringer("containerID", containerID),
|
||
log.Uint64("count", n))
|
||
}
|
||
return nil
|
||
}
|
||
if fin, ok := b.engine.FinalizedBlockAtHeight(blk.Height()); ok && fin == containerID {
|
||
b.logFrontierDecision(&b.diag.frontierCurrent, "have the block and finalized it — not behind", nodeID, containerID)
|
||
return nil // have the block AND finalized it — truly not behind
|
||
}
|
||
// have the block but not finalized here → behind on the cert → fetch below
|
||
b.logFrontierDecision(&b.diag.frontierCertGap, "have the block but NOT finalized — behind on the CERT, fetching the gap", nodeID, containerID)
|
||
} else if b.engine != nil {
|
||
if _, found := b.engine.GetPendingBlock(containerID); found {
|
||
b.logFrontierDecision(&b.diag.frontierPending, "already tracked as pending by the live path — NOT fetching", nodeID, containerID)
|
||
return nil // already tracked (pending) — the live path is handling it
|
||
}
|
||
b.logFrontierDecision(&b.diag.frontierAbsent, "block absent — fetching the gap", nodeID, containerID)
|
||
} else {
|
||
b.logFrontierDecision(&b.diag.frontierAbsent, "block absent and no engine — fetching the gap", nodeID, containerID)
|
||
}
|
||
b.requestContext(ctx, nodeID, containerID) // behind (missing block OR its cert) → fetch the gap
|
||
return nil
|
||
}
|
||
|
||
// logFrontierDecision names WHICH branch of the frontier decision ran, because that
|
||
// branch IS whether we fetch. A silent branch makes "concluded not behind" and
|
||
// "received no reply at all" produce byte-identical output, which is an
|
||
// unanswerable question at exactly the moment someone is asking it.
|
||
//
|
||
// Sampled per branch: each outcome carries its own counter, so a common decision
|
||
// cannot bury a rare one and the first occurrence of every branch is visible.
|
||
// ourAcceptedHeight is read from the IN-PROCESS finalized ledger only — never a VM
|
||
// round trip, which over ZAP would put an RPC on an inbound message path.
|
||
func (b *blockHandler) logFrontierDecision(c *gatomic.Uint64, decision string, nodeID ids.NodeID, containerID ids.ID) {
|
||
n, loud := catchupSample(c)
|
||
if !loud {
|
||
return
|
||
}
|
||
var ourHeight uint64
|
||
if b.engine != nil {
|
||
if _, h, set := b.engine.FinalizedLedger(); set {
|
||
ourHeight = h
|
||
}
|
||
}
|
||
b.logger.Info("frontier reply decision",
|
||
log.String("decision", decision),
|
||
log.Stringer("from", nodeID),
|
||
log.Stringer("containerID", containerID),
|
||
log.Uint64("ourAcceptedHeight", ourHeight),
|
||
log.Uint64("count", n))
|
||
}
|
||
|
||
// frontierPollInterval is the heartbeat at which a node proactively asks a small
|
||
// sample of peers "what is your accepted tip?" so it discovers it has fallen behind
|
||
// the finalized frontier WITHOUT a restart (the producers do not re-gossip old
|
||
// blocks to a behind node, so nothing else tells it). It is a SLOW heartbeat, not a
|
||
// hot loop: a reply naming a tip we lack drives AcceptedFrontier → requestContext,
|
||
// which is itself throttled downstream (pendingContext per-block dedup + the
|
||
// engine's claimCatchupLocked cooldown), so discovery cannot become a fetch storm.
|
||
const frontierPollInterval = 15 * time.Second
|
||
|
||
// frontierPollSample is how many peers each tick is asked. A SMALL sample (not a
|
||
// broadcast) keeps the heartbeat cheap; because PeerInfo's order varies per call,
|
||
// successive ticks reach different peers, so a behind node that drew a peer which
|
||
// cannot serve certs (un-upgraded, or aged-out window) reaches a serving peer on a
|
||
// later tick — no single-peer dependency and no hot retry (GAP-4 backoff).
|
||
const frontierPollSample = 4
|
||
|
||
// runFrontierPoller is the proactive half of frontier-sync: it periodically sends
|
||
// GetAcceptedFrontier to a few peers tracking this chain. Started once when the
|
||
// chain handler is created; exits when ctx is done (node shutdown).
|
||
func (b *blockHandler) runFrontierPoller(ctx context.Context) {
|
||
if b.net == nil || b.msgCreator == nil || b.engine == nil {
|
||
return
|
||
}
|
||
ticker := time.NewTicker(frontierPollInterval)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
b.pollFrontierOnce(ctx)
|
||
}
|
||
}
|
||
}
|
||
|
||
// pollFrontierOnce asks up to frontierPollSample connected peers (that track this
|
||
// chain) for their accepted tip. The reply lands in AcceptedFrontier, which fetches
|
||
// the gap if we are behind. Safe no-op when there are no peers (e.g. a single-node
|
||
// dev chain): nothing is sent, nothing is fetched.
|
||
func (b *blockHandler) pollFrontierOnce(ctx context.Context) {
|
||
peers := b.net.PeerInfo(nil)
|
||
if len(peers) == 0 {
|
||
// Nothing to ask. Per-event: this is the 15s heartbeat, four lines a minute at
|
||
// worst, and its absence is exactly as informative as its presence.
|
||
b.logger.Info("frontier poll skipped — no connected peers")
|
||
return
|
||
}
|
||
// Filter on the NETWORK id, not the chain id. A peer advertises in its handshake the NETS it
|
||
// tracks (network/peer/peer.go: constants.PrimaryNetworkID plus every tracked L1 net id) — it
|
||
// NEVER advertises an individual native chain id like the C-Chain's. Filtering on b.chainID
|
||
// therefore matched ZERO real peers, so the sample was always empty, no GetAcceptedFrontier was
|
||
// ever sent, and a behind-but-Ready validator NEVER discovered it was behind (it sits frozen at a
|
||
// stale height with no self-heal). This is the IDENTICAL fix already applied to
|
||
// connectedBeacons (bootstrap_sync.go); the NormalOp poller had the same
|
||
// latent chainID/networkID confusion. Matching on b.networkID reaches exactly the peers
|
||
// participating in this chain's validation network. Catch-up safety is unchanged: the reply
|
||
// drives requestContext, which pulls the gap WITH CERTS (cert-verified accept), so asking any
|
||
// network peer is sound.
|
||
sample := set.NewSet[ids.NodeID](frontierPollSample)
|
||
for _, p := range peers {
|
||
if p.TrackedChains.Contains(b.networkID) {
|
||
sample.Add(p.ID)
|
||
if sample.Len() >= frontierPollSample {
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if sample.Len() == 0 {
|
||
// Peers ARE connected and none of them matched the filter above. That is the exact
|
||
// shape of the failure this filter has already caused once: matching on the wrong
|
||
// id emptied every sample, no GetAcceptedFrontier was ever sent, and a behind node
|
||
// never learned it was behind — with no line saying so.
|
||
b.logger.Warn("frontier poll skipped — no connected peer tracks this network; the tip is never asked for",
|
||
log.Stringer("networkID", b.networkID),
|
||
log.Int("connectedPeers", len(peers)))
|
||
return
|
||
}
|
||
|
||
b.contextRequestMu.Lock()
|
||
b.requestIDCounter++
|
||
requestID := b.requestIDCounter
|
||
b.contextRequestMu.Unlock()
|
||
|
||
msg, err := b.msgCreator.GetAcceptedFrontier(b.chainID, requestID, 10*time.Second)
|
||
if err != nil {
|
||
b.logger.Warn("frontier poll: failed to build GetAcceptedFrontier", log.Err(err))
|
||
return
|
||
}
|
||
sentTo := b.net.Send(msg, sample, b.networkID, 0)
|
||
b.logger.Info("frontier poll sent",
|
||
log.Uint32("requestID", requestID),
|
||
log.Int("asked", sample.Len()),
|
||
log.Int("sentTo", sentTo.Len()))
|
||
}
|
||
|
||
// GetAccepted answers which of the asked ids this node has ACCEPTED. It is the second half of
|
||
// naming a frontier, and it exists because the first half cannot be answered in agreement on a
|
||
// live chain: GetAcceptedFrontier returns ONE tip per peer, the chain moves while those answers
|
||
// are in flight, and healthy honest peers therefore name different blocks. Acceptance is
|
||
// cumulative, so peers at different heights have all accepted the lower blocks — a behind node
|
||
// asks about the tips it collected and learns which of them a stake quorum actually holds.
|
||
//
|
||
// ACCEPTED, not held. A block sitting in the store because it was gossiped ahead of acceptance is
|
||
// not on this node's chain; answering for it would vouch for a block this node never finalized,
|
||
// which is precisely the store-vs-acceptance distinction the frontier path turns on. The oracle is
|
||
// the one that path already uses.
|
||
//
|
||
// The id list is chosen by the sender, so it is bounded: a question naming more ids than one
|
||
// naming round can legitimately ask is refused whole rather than walked. An empty answer is still
|
||
// sent — "I have accepted none of these" is an answer, and it lets the asker finish its round
|
||
// instead of waiting out the window on a peer that has already replied.
|
||
func (b *blockHandler) GetAccepted(ctx context.Context, nodeID ids.NodeID, requestID uint32, deadline time.Time, containerIDs []ids.ID) error {
|
||
if b.vm == nil || b.net == nil || b.msgCreator == nil {
|
||
return nil
|
||
}
|
||
if len(containerIDs) > bootstrapAcceptedCandidates {
|
||
b.logger.Debug("refusing an oversized accepted question",
|
||
log.Stringer("from", nodeID),
|
||
log.Int("asked", len(containerIDs)),
|
||
log.Int("limit", bootstrapAcceptedCandidates))
|
||
return nil
|
||
}
|
||
accepted := make([]ids.ID, 0, len(containerIDs))
|
||
for _, id := range containerIDs {
|
||
if _, ok := b.acceptedHeightCtx(ctx, id); ok {
|
||
accepted = append(accepted, id)
|
||
}
|
||
}
|
||
msg, err := b.msgCreator.Accepted(b.chainID, requestID, accepted)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
nodeSet := set.NewSet[ids.NodeID](1)
|
||
nodeSet.Add(nodeID)
|
||
b.net.Send(msg, nodeSet, b.networkID, 0)
|
||
return nil
|
||
}
|
||
|
||
// decodeIDs reads a concatenated run of 32-byte ids — the wire form of a container-id list
|
||
// (message.GetContainerBytes writes GetAccepted and Accepted that way). It materialises at most
|
||
// `limit` ids, so a caller asking for one more than it will accept can tell an oversized list from
|
||
// a legitimate one without ever holding it. A trailing partial id names no block and is ignored.
|
||
func decodeIDs(data []byte, limit int) []ids.ID {
|
||
n := len(data) / len(ids.Empty)
|
||
if n > limit {
|
||
n = limit
|
||
}
|
||
out := make([]ids.ID, n)
|
||
for i := range out {
|
||
copy(out[i][:], data[i*len(ids.Empty):])
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (b *blockHandler) Put(ctx context.Context, nodeID ids.NodeID, requestID uint32, container []byte) error {
|
||
// Route incoming block through consensus engine instead of auto-accepting
|
||
// This enables proper quorum-based block acceptance
|
||
if b.engine == nil {
|
||
b.logger.Warn("no consensus engine - cannot process incoming block",
|
||
log.Stringer("from", nodeID))
|
||
return nil
|
||
}
|
||
|
||
// Use the consensus engine to handle the block properly:
|
||
// 1. Parse and verify the block
|
||
// 2. Add to pending blocks
|
||
// 3. Vote on the block
|
||
// 4. Accept only when quorum is reached
|
||
blk, err := b.engine.HandleIncomingBlock(ctx, container, nodeID)
|
||
if err != nil {
|
||
if isMissingContextError(err) {
|
||
if b.vm != nil {
|
||
missingBlk, parseErr := b.vm.ParseBlock(ctx, container)
|
||
if parseErr == nil {
|
||
b.requestContext(ctx, nodeID, missingBlk.ID())
|
||
} else if n, loud := catchupSample(&b.diag.blockUnparsed); loud {
|
||
b.logger.Warn("incoming block did not parse - dropped",
|
||
log.Stringer("from", nodeID),
|
||
log.Uint64("count", n),
|
||
log.Err(parseErr))
|
||
}
|
||
}
|
||
}
|
||
b.logger.Debug("failed to handle incoming block",
|
||
log.Stringer("from", nodeID),
|
||
log.Err(err))
|
||
return nil
|
||
}
|
||
|
||
if blk != nil {
|
||
blockID := blk.ID()
|
||
b.logger.Info("processed incoming block through consensus",
|
||
log.Stringer("from", nodeID),
|
||
log.Stringer("blockID", blockID),
|
||
log.Uint64("height", blk.Height()))
|
||
|
||
// Drain any buffered Qbits that were waiting for this block
|
||
b.onBlockArrived(ctx, blockID)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
func (b *blockHandler) PushQuery(ctx context.Context, nodeID ids.NodeID, requestID uint32, deadline time.Time, container []byte) error {
|
||
// PushQuery sends block data AND expects a Qbit response.
|
||
// CRITICAL: We must ALWAYS respond with Chits, even if we can't fully
|
||
// process the block. This is how Quasar consensus works - the proposer
|
||
// needs vote responses to reach quorum.
|
||
|
||
b.logger.Debug("received PushQuery",
|
||
log.Stringer("from", nodeID),
|
||
log.Uint32("requestID", requestID),
|
||
log.Int("containerLen", len(container)))
|
||
|
||
if b.net == nil || b.msgCreator == nil || b.vm == nil {
|
||
b.logger.Warn("missing net/msgCreator/vm - cannot respond")
|
||
return nil
|
||
}
|
||
|
||
// 1. Process the block via Put (may trigger context fetch asynchronously)
|
||
if err := b.Put(ctx, nodeID, requestID, container); err != nil {
|
||
b.logger.Debug("Put returned error (will still respond with Chits)",
|
||
log.Stringer("from", nodeID),
|
||
log.Err(err))
|
||
}
|
||
|
||
// 2. Get the correct block ID by parsing the block through the VM.
|
||
// CRITICAL: The block ID must match the proposer's vmBlock.ID() exactly.
|
||
// The VM computes block IDs using its own hash function (e.g., Keccak256 for EVM).
|
||
// Using SHA256(containerBytes) would produce a DIFFERENT ID, causing votes to
|
||
// never match the proposer's pending block, and consensus would never accept.
|
||
var preferredID ids.ID
|
||
if b.vm != nil {
|
||
if blk, err := b.vm.ParseBlock(ctx, container); err == nil {
|
||
preferredID = blk.ID()
|
||
} else {
|
||
b.logger.Warn("ParseBlock failed, cannot vote correctly",
|
||
log.Stringer("from", nodeID),
|
||
log.Err(err))
|
||
// Cannot vote correctly without the proper block ID - respond with last accepted
|
||
acceptedBlkID, _ := b.vm.LastAccepted(ctx)
|
||
preferredID = acceptedBlkID
|
||
}
|
||
}
|
||
|
||
b.logger.Debug("using VM block ID for vote",
|
||
log.Stringer("from", nodeID),
|
||
log.Stringer("blockID", preferredID))
|
||
|
||
// 3. Get the last accepted block info for the Chits response
|
||
acceptedBlkID, err := b.vm.LastAccepted(ctx)
|
||
if err != nil {
|
||
b.logger.Warn("failed to get last accepted - responding with empty accepted",
|
||
log.Stringer("from", nodeID),
|
||
log.Err(err))
|
||
// Use empty ID as fallback
|
||
acceptedBlkID = ids.Empty
|
||
}
|
||
|
||
var acceptedHeight uint64
|
||
if acceptedBlkID != ids.Empty {
|
||
acceptedBlk, err := b.vm.GetBlock(ctx, acceptedBlkID)
|
||
if err != nil {
|
||
b.logger.Debug("failed to get accepted block (using height 0)",
|
||
log.Stringer("from", nodeID),
|
||
log.Stringer("acceptedBlkID", acceptedBlkID),
|
||
log.Err(err))
|
||
// Use height 0 as fallback - this is OK for genesis state
|
||
} else {
|
||
acceptedHeight = acceptedBlk.Height()
|
||
}
|
||
}
|
||
|
||
// 4. Create Chits response (vote for the proposed block)
|
||
qbitMsg, err := b.msgCreator.Chits(b.chainID, requestID, preferredID, preferredID, acceptedBlkID, acceptedHeight)
|
||
if err != nil {
|
||
b.logger.Error("failed to create Chits message",
|
||
log.Stringer("from", nodeID),
|
||
log.Stringer("blockID", preferredID),
|
||
log.Err(err))
|
||
return nil
|
||
}
|
||
|
||
// 5. Send Chits response to the requesting node
|
||
nodeSet := set.NewSet[ids.NodeID](1)
|
||
nodeSet.Add(nodeID)
|
||
|
||
sentTo := b.net.Send(qbitMsg, nodeSet, b.networkID, 0)
|
||
b.logger.Info("responded with Chits",
|
||
log.Stringer("from", nodeID),
|
||
log.Stringer("preferredID", preferredID),
|
||
log.Stringer("acceptedID", acceptedBlkID),
|
||
log.Uint64("acceptedHeight", acceptedHeight),
|
||
log.Int("sentTo", sentTo.Len()))
|
||
|
||
return nil
|
||
}
|
||
func (b *blockHandler) PullQuery(ctx context.Context, nodeID ids.NodeID, requestID uint32, deadline time.Time, containerID ids.ID) error {
|
||
// PullQuery requests a preference signal on a block identified by containerID
|
||
// We respond with a Qbit (wire: p2p.Chits) containing our preference
|
||
|
||
if b.net == nil || b.msgCreator == nil {
|
||
b.logger.Debug("cannot respond to PullQuery - no network sender",
|
||
log.Stringer("from", nodeID),
|
||
log.Stringer("blockID", containerID))
|
||
return nil
|
||
}
|
||
|
||
// Try to get the block from pending blocks first, then VM storage.
|
||
// When we receive a PullQuery, the proposer has the block but it may not
|
||
// be in our VM storage yet. First check pending blocks (proposed but not accepted).
|
||
var blk chain.Block
|
||
var found bool
|
||
|
||
// First check pending blocks (for recently proposed but not yet accepted blocks)
|
||
if b.engine != nil {
|
||
blk, found = b.engine.GetPendingBlock(containerID)
|
||
if found {
|
||
b.logger.Debug("found block in pending for PullQuery",
|
||
log.Stringer("blockID", containerID),
|
||
log.Uint64("height", blk.Height()))
|
||
}
|
||
}
|
||
|
||
// If not in pending, try VM storage with retries
|
||
if !found {
|
||
var err error
|
||
const maxRetries = 3
|
||
const retryDelay = 20 * time.Millisecond
|
||
|
||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||
blk, err = b.vm.GetBlock(ctx, containerID)
|
||
if err == nil {
|
||
found = true
|
||
break // Found the block
|
||
}
|
||
if attempt < maxRetries-1 {
|
||
// Wait before retry to allow concurrent Put to complete
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
case <-time.After(retryDelay):
|
||
// Continue to next attempt
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if !found {
|
||
b.logger.Debug("cannot respond to PullQuery - block not found in pending or storage",
|
||
log.Stringer("from", nodeID),
|
||
log.Stringer("blockID", containerID))
|
||
// Block not found - request context (parent chain) from peer
|
||
b.requestContext(ctx, nodeID, containerID)
|
||
return nil
|
||
}
|
||
|
||
// Verify the block before voting for it
|
||
if err := blk.Verify(ctx); err != nil {
|
||
b.logger.Debug("cannot respond to PullQuery - block verification failed",
|
||
log.Stringer("from", nodeID),
|
||
log.Stringer("blockID", containerID),
|
||
log.Err(err))
|
||
// If verification failed due to missing context, request it from peer
|
||
if isMissingContextError(err) {
|
||
b.logger.Info("block missing context - requesting from peer",
|
||
log.Stringer("from", nodeID),
|
||
log.Stringer("blockID", containerID))
|
||
b.requestContext(ctx, nodeID, containerID)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Get the accepted block ID (last accepted) for the acceptedID field
|
||
acceptedBlkID := containerID
|
||
acceptedHeight := blk.Height()
|
||
|
||
// Try to get the last accepted block for more accurate acceptedID
|
||
if lastAccepted, err := b.vm.LastAccepted(ctx); err == nil && lastAccepted != ids.Empty {
|
||
acceptedBlkID = lastAccepted
|
||
if acceptedBlk, err := b.vm.GetBlock(ctx, lastAccepted); err == nil {
|
||
acceptedHeight = acceptedBlk.Height()
|
||
}
|
||
}
|
||
|
||
// Create Qbit response message (wire: p2p.Chits)
|
||
// preferredID: the block we prefer
|
||
// preferredIDAtHeight: same as preferredID
|
||
// acceptedID: the last accepted block
|
||
// acceptedHeight: height of the accepted block
|
||
qbitMsg, err := b.msgCreator.Chits(b.chainID, requestID, containerID, containerID, acceptedBlkID, acceptedHeight)
|
||
if err != nil {
|
||
b.logger.Error("failed to create Qbit message",
|
||
log.Stringer("from", nodeID),
|
||
log.Stringer("blockID", containerID),
|
||
log.Err(err))
|
||
return nil
|
||
}
|
||
|
||
// Send Qbit response to the requesting node
|
||
nodeSet := set.NewSet[ids.NodeID](1)
|
||
nodeSet.Add(nodeID)
|
||
|
||
sentTo := b.net.Send(qbitMsg, nodeSet, b.networkID, 0)
|
||
b.logger.Debug("responded to PullQuery with Qbit",
|
||
log.Stringer("from", nodeID),
|
||
log.Stringer("blockID", containerID),
|
||
log.Uint64("height", blk.Height()),
|
||
log.Int("sentTo", sentTo.Len()))
|
||
|
||
return nil
|
||
}
|
||
func (b *blockHandler) QueryFailed(ctx context.Context, nodeID ids.NodeID, requestID uint32) error {
|
||
return nil
|
||
}
|
||
func (b *blockHandler) CrossChainRequest(ctx context.Context, chainID ids.ID, requestID uint32, deadline time.Time, msg []byte) error {
|
||
return nil
|
||
}
|
||
func (b *blockHandler) CrossChainRequestFailed(ctx context.Context, chainID ids.ID, requestID uint32) error {
|
||
return nil
|
||
}
|
||
func (b *blockHandler) CrossChainResponse(ctx context.Context, chainID ids.ID, requestID uint32, msg []byte) error {
|
||
return nil
|
||
}
|
||
func (b *blockHandler) Request(ctx context.Context, nodeID ids.NodeID, requestID uint32, deadline time.Time, msg []byte) error {
|
||
return nil
|
||
}
|
||
func (b *blockHandler) RequestFailed(ctx context.Context, nodeID ids.NodeID, requestID uint32) error {
|
||
return nil
|
||
}
|
||
func (b *blockHandler) Response(ctx context.Context, nodeID ids.NodeID, requestID uint32, msg []byte) error {
|
||
return nil
|
||
}
|
||
func (b *blockHandler) Gossip(ctx context.Context, nodeID ids.NodeID, msg []byte) error {
|
||
// Receive demux: a quorum envelope (signed vote / finality cert) is
|
||
// routed to the engine's α-of-K topology; anything else is a plain block
|
||
// gossip handled by Put (backward-compatible — decode fails soft).
|
||
//
|
||
// The quorum path is consulted ONLY when the engine is in quorum-finality
|
||
// mode (K>1, live topology). A single-validator engine never emits vote/cert
|
||
// envelopes, so it always treats gossip as a block — this makes a chance
|
||
// collision of the envelope magic with block bytes impossible to misroute on
|
||
// the K==1 path.
|
||
if b.engine != nil && b.engine.Mode() == consensuschain.ModeQuorumFinality {
|
||
if kind, blockID, payload, err := decodeQuorumGossip(msg); err == nil {
|
||
switch kind {
|
||
case quorumKindVote:
|
||
if b.logger != nil {
|
||
// A vote for a block not in pendingBlocks cannot be verified yet: the
|
||
// engine parks it when it names this sender as its signer, and counts
|
||
// it only if its block lands. So "known" here is what decides whether
|
||
// this vote is counted now, and it is the difference between an ID-space
|
||
// mismatch (vote keyed differently from pendingBlocks) and an
|
||
// eviction race (block dropped before peers' votes land). Also
|
||
// report whether the VM holds the block at all, which separates
|
||
// those two: unknown to both = wrong ID, known to the VM but not
|
||
// pending = evicted.
|
||
known := b.engine.HasPendingBlock(blockID)
|
||
inVM := false
|
||
if b.vm != nil {
|
||
if _, err := b.vm.GetBlock(ctx, blockID); err == nil {
|
||
inVM = true
|
||
}
|
||
}
|
||
b.logger.Info("quorum: vote RECEIVED",
|
||
log.Stringer("from", nodeID), log.Stringer("blockID", blockID),
|
||
log.Bool("inPendingBlocks", known), log.Bool("inVM", inVM))
|
||
if !known {
|
||
b.logger.Warn("quorum: vote is for a block NOT in pendingBlocks — parked when it is the sender's own, counted if its block lands",
|
||
log.Stringer("from", nodeID), log.Stringer("blockID", blockID),
|
||
log.Bool("inVM", inVM))
|
||
}
|
||
}
|
||
b.engine.HandleIncomingVote(nodeID, blockID, payload)
|
||
case quorumKindCert:
|
||
if b.logger != nil {
|
||
b.logger.Info("quorum: CERT received",
|
||
log.Stringer("from", nodeID), log.Stringer("blockID", blockID))
|
||
}
|
||
b.engine.HandleIncomingCert(payload)
|
||
}
|
||
return nil
|
||
}
|
||
} else if b.engine != nil && b.logger != nil {
|
||
// The gate is silent by construction: outside quorum-finality mode every
|
||
// inbound vote and cert falls through to the plain-block path and is
|
||
// discarded, so a degraded engine looks exactly like a network with
|
||
// nothing to say. Name it once per gossip so the mode is visible.
|
||
b.logger.Warn("quorum: engine NOT in quorum-finality mode — vote/cert demux skipped",
|
||
log.Stringer("from", nodeID), log.Int("mode", int(b.engine.Mode())))
|
||
}
|
||
// Plain block gossip.
|
||
return b.Put(ctx, nodeID, 0, msg)
|
||
}
|
||
|
||
// GetStateSummaryFrontier answers with the summary this chain's VM holds at its
|
||
// frontier, so a peer whose gap is too deep to re-execute can adopt state instead.
|
||
//
|
||
// Serving does not consult whether THIS node would sync: a node's own preference
|
||
// says nothing about what its peers may ask for, and a chain that answers only
|
||
// when it would also ask leaves a fleet unable to repair itself the moment its
|
||
// healthy members have nothing to sync to.
|
||
//
|
||
// Silence is the answer when there is no summary. The requester closes its round
|
||
// on its own deadline, and a reply we cannot make is not an error.
|
||
func (b *blockHandler) GetStateSummaryFrontier(ctx context.Context, nodeID ids.NodeID, requestID uint32, deadline time.Time) error {
|
||
summary, err := statesync.NewServer[chain.StateSummary](b.vm).Frontier(ctx)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
msg, err := b.msgCreator.StateSummaryFrontier(b.chainID, requestID, summary)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
nodeSet := set.NewSet[ids.NodeID](1)
|
||
nodeSet.Add(nodeID)
|
||
b.net.Send(msg, nodeSet, b.networkID, 0)
|
||
return nil
|
||
}
|
||
|
||
// StateSummaryFrontier is a peer's answer to our own discovery request.
|
||
func (b *blockHandler) StateSummaryFrontier(ctx context.Context, nodeID ids.NodeID, requestID uint32, summaryBytes []byte) error {
|
||
b.deliverOffer(requestID, nodeID, summaryBytes)
|
||
return nil
|
||
}
|
||
|
||
// GetAcceptedStateSummary answers which of the named heights this VM holds.
|
||
//
|
||
// An empty reply is an answer, and a different one from silence: it says "none of
|
||
// those", which completes the requester's tally at once. Sending nothing instead
|
||
// costs it a timeout on every round.
|
||
func (b *blockHandler) GetAcceptedStateSummary(ctx context.Context, nodeID ids.NodeID, requestID uint32, deadline time.Time, heights []uint64) error {
|
||
summaryIDs, err := statesync.NewServer[chain.StateSummary](b.vm).Accepted(ctx, heights)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
msg, err := b.msgCreator.AcceptedStateSummary(b.chainID, requestID, summaryIDs)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
nodeSet := set.NewSet[ids.NodeID](1)
|
||
nodeSet.Add(nodeID)
|
||
b.net.Send(msg, nodeSet, b.networkID, 0)
|
||
return nil
|
||
}
|
||
|
||
// AcceptedStateSummary is a peer's ratification vote: which of the heights we
|
||
// named it actually holds.
|
||
func (b *blockHandler) AcceptedStateSummary(ctx context.Context, nodeID ids.NodeID, requestID uint32, summaryIDs []ids.ID) error {
|
||
b.deliverBallot(requestID, nodeID, summaryIDs)
|
||
return nil
|
||
}
|
||
|
||
// Connected satisfies handler.Handler, whose interface carries no peer version.
|
||
// The real delivery path is ConnectedWithVersion (chainRouter uses it via the
|
||
// versionedConnector capability); this nil-version entry exists only for the
|
||
// interface contract and any non-router caller.
|
||
func (b *blockHandler) Connected(ctx context.Context, nodeID ids.NodeID) error {
|
||
return b.connect(ctx, nodeID, nil)
|
||
}
|
||
|
||
// ConnectedWithVersion forwards a peer connection WITH its real application
|
||
// version to this chain's VM. chainRouter invokes this (detecting the
|
||
// versionedConnector capability) so the version survives to the inner VM:
|
||
// proposervm promotes Connected to the C-Chain EVM, whose state-sync peer
|
||
// tracker compares peer versions — a nil version there dereferences nil and
|
||
// panics a state-syncing node (a fresh join OR a validator rejoining after
|
||
// falling behind). The version must reach
|
||
// engine.Connected.
|
||
func (b *blockHandler) ConnectedWithVersion(ctx context.Context, nodeID ids.NodeID, nodeVersion *version.Application) error {
|
||
return b.connect(ctx, nodeID, nodeVersion)
|
||
}
|
||
|
||
// connect forwards a peer connection to this chain's VM exactly once, carrying
|
||
// nodeVersion (nil only on the interface path). The P-chain VM records it in its
|
||
// uptime tracker; the C-Chain/X-Chain VMs store the version for their peer sets.
|
||
// A nil connector makes this a no-op. On a forwarding error the dedup entry is
|
||
// rolled back so a subsequent dispatch of the same connection retries.
|
||
func (b *blockHandler) connect(ctx context.Context, nodeID ids.NodeID, nodeVersion *version.Application) error {
|
||
if b.connector == nil {
|
||
return nil
|
||
}
|
||
b.connMu.Lock()
|
||
if b.connectedNodes.Contains(nodeID) {
|
||
b.connMu.Unlock()
|
||
return nil
|
||
}
|
||
b.connectedNodes.Add(nodeID)
|
||
b.connMu.Unlock()
|
||
|
||
if err := b.connector.Connected(ctx, nodeID, nodeVersion); err != nil {
|
||
b.connMu.Lock()
|
||
b.connectedNodes.Remove(nodeID)
|
||
b.connMu.Unlock()
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Disconnected forwards a peer disconnection to this chain's VM exactly once.
|
||
func (b *blockHandler) Disconnected(ctx context.Context, nodeID ids.NodeID) error {
|
||
if b.connector == nil {
|
||
return nil
|
||
}
|
||
b.connMu.Lock()
|
||
if !b.connectedNodes.Contains(nodeID) {
|
||
b.connMu.Unlock()
|
||
return nil
|
||
}
|
||
b.connectedNodes.Remove(nodeID)
|
||
b.connMu.Unlock()
|
||
|
||
return b.connector.Disconnected(ctx, nodeID)
|
||
}
|
||
func (b *blockHandler) HealthCheck(ctx context.Context) (interface{}, error) { return nil, nil }
|
||
func (b *blockHandler) Stop(ctx context.Context) {
|
||
if b.pollerCancel != nil {
|
||
b.pollerCancel()
|
||
}
|
||
}
|
||
func (b *blockHandler) HandleInbound(ctx context.Context, msg handler.Message) error {
|
||
// Dispatch based on Op type
|
||
switch msg.Op {
|
||
case handler.PushQuery:
|
||
// PushQuery contains block data AND expects a Qbit (vote) response
|
||
if len(msg.Message) > 0 {
|
||
return b.PushQuery(ctx, msg.NodeID, msg.RequestID, time.Now().Add(10*time.Second), msg.Message)
|
||
}
|
||
case handler.Put:
|
||
// Put contains block data but does not expect a vote response
|
||
if len(msg.Message) > 0 {
|
||
return b.Put(ctx, msg.NodeID, msg.RequestID, msg.Message)
|
||
}
|
||
case handler.PullQuery:
|
||
// PullQuery asks for a preference signal on a block identified by ID
|
||
// Extract the blockID from the message and respond with Qbit
|
||
if len(msg.Message) >= 32 {
|
||
var containerID ids.ID
|
||
copy(containerID[:], msg.Message[:32])
|
||
return b.PullQuery(ctx, msg.NodeID, msg.RequestID, time.Now().Add(10*time.Second), containerID)
|
||
}
|
||
case handler.Vote:
|
||
// Wire op "Vote" is a MISNOMER carried over from the router: the message is
|
||
// an UNSIGNED Chits, i.e. a peer's PREFERENCE. It becomes a NovaPollResponse
|
||
// — a type with no Signature and no Accept field — and can no longer be
|
||
// widened into a consensuschain.Vote. See nova_poll.go.
|
||
//
|
||
// msg.Message already holds the extracted PreferredId (chain_router.go ->
|
||
// GetContainerBytes returns m.GetPreferredId()); AcceptedId/AcceptedHeight
|
||
// are discarded there and never reach this handler.
|
||
b.logger.Info("received Chits message",
|
||
log.Stringer("from", msg.NodeID),
|
||
log.Uint32("requestID", msg.RequestID),
|
||
log.Int("msgLen", len(msg.Message)))
|
||
if len(msg.Message) >= 32 {
|
||
var preferredID ids.ID
|
||
copy(preferredID[:], msg.Message[:32])
|
||
|
||
b.logger.Debug("extracted preferredID",
|
||
log.Stringer("from", msg.NodeID),
|
||
log.Stringer("preferredID", preferredID))
|
||
|
||
b.receivePollResponse(ctx, NovaPollResponse{
|
||
From: msg.NodeID,
|
||
PreferredID: preferredID,
|
||
RequestID: msg.RequestID,
|
||
ReceivedAt: time.Now(),
|
||
})
|
||
} else {
|
||
b.logger.Warn("message too short for preferredID",
|
||
log.Stringer("from", msg.NodeID),
|
||
log.Int("msgLen", len(msg.Message)))
|
||
}
|
||
case handler.GetAcceptedFrontier:
|
||
// A peer asks "what is your accepted tip?" — answer with our last-accepted
|
||
// block id (the frontier responder). This is the SERVE half of frontier-sync:
|
||
// it is how a behind node learns the network tip. Without this case the op
|
||
// reaches HandleInbound and falls through (dropped), so a node that fell
|
||
// behind could never discover it (the GAP-1 dead-handler, same class as the
|
||
// GossipOp/catch-up gaps).
|
||
return b.GetAcceptedFrontier(ctx, msg.NodeID, msg.RequestID, time.Now().Add(10*time.Second))
|
||
case handler.AcceptedFrontier:
|
||
// A peer's reply naming ITS accepted tip (a 32-byte container id). If we lack
|
||
// that block we are BEHIND → pull the gap through the catch-up transport. This
|
||
// is the LEARN half of frontier-sync; parse the id like the Context case.
|
||
if len(msg.Message) >= 32 {
|
||
var containerID ids.ID
|
||
copy(containerID[:], msg.Message[:32])
|
||
return b.AcceptedFrontier(ctx, msg.NodeID, msg.RequestID, containerID)
|
||
}
|
||
case handler.GetAccepted:
|
||
// A peer asks "which of these blocks have you accepted?" — the question that HAS an
|
||
// agreeing answer when the frontier tips do not. Decoding one more id than the responder
|
||
// will answer about leaves an oversized list detectable there, where the bound belongs.
|
||
return b.GetAccepted(ctx, msg.NodeID, msg.RequestID, time.Now().Add(10*time.Second),
|
||
decodeIDs(msg.Message, bootstrapAcceptedCandidates+1))
|
||
case handler.Accepted:
|
||
// A peer's answer. It is meaningful only to the naming round that asked, which is why the
|
||
// lane correlates on requestID; anything else — including every such message on a node
|
||
// that is not syncing — is dropped here.
|
||
b.deliverBootstrapAccepted(msg.RequestID, msg.NodeID, decodeIDs(msg.Message, bootstrapAcceptedCandidates))
|
||
return nil
|
||
case handler.GetStateSummaryFrontier:
|
||
// Asked what summary this chain holds. No body: the question is about the
|
||
// chain, not about anything in it.
|
||
return b.GetStateSummaryFrontier(ctx, msg.NodeID, msg.RequestID, time.Now().Add(10*time.Second))
|
||
case handler.StateSummaryFrontier:
|
||
return b.StateSummaryFrontier(ctx, msg.NodeID, msg.RequestID, msg.Message)
|
||
case handler.GetAcceptedStateSummary:
|
||
// Fixed-width heights, so the count is a division and the reader cannot
|
||
// disagree with the writer about where one ends. A trailing partial is a
|
||
// malformed request and the whole of it is refused.
|
||
if len(msg.Message)%8 != 0 {
|
||
return nil
|
||
}
|
||
heights := make([]uint64, 0, len(msg.Message)/8)
|
||
for i := 0; i+8 <= len(msg.Message); i += 8 {
|
||
heights = append(heights, binary.BigEndian.Uint64(msg.Message[i:i+8]))
|
||
}
|
||
return b.GetAcceptedStateSummary(ctx, msg.NodeID, msg.RequestID, time.Now().Add(30*time.Second), heights)
|
||
case handler.AcceptedStateSummary:
|
||
if len(msg.Message)%32 != 0 {
|
||
return nil
|
||
}
|
||
ids32 := make([]ids.ID, 0, len(msg.Message)/32)
|
||
for i := 0; i+32 <= len(msg.Message); i += 32 {
|
||
var id ids.ID
|
||
copy(id[:], msg.Message[i:i+32])
|
||
ids32 = append(ids32, id)
|
||
}
|
||
return b.AcceptedStateSummary(ctx, msg.NodeID, msg.RequestID, ids32)
|
||
case handler.GetContext:
|
||
// GetContext requests verification context (parent chain) for a block
|
||
if len(msg.Message) >= 32 {
|
||
var containerID ids.ID
|
||
copy(containerID[:], msg.Message[:32])
|
||
return b.GetContext(ctx, msg.NodeID, msg.RequestID, time.Now().Add(10*time.Second), containerID)
|
||
}
|
||
case handler.Context:
|
||
// Context contains prerequisite blocks - process each one via Put
|
||
return b.handleContext(ctx, msg.NodeID, msg.RequestID, msg.Message)
|
||
case handler.Gossip:
|
||
// App-gossip envelope carrying an α-of-K quorum vote or finality cert.
|
||
// Route to Gossip, which demuxes the quorum envelope into
|
||
// engine.HandleIncomingVote / HandleIncomingCert (the vote TRANSPORT that
|
||
// drives finality). Without this case the router delivers the message here
|
||
// but the switch drops it, so no vote is ever counted and the chain wedges.
|
||
// Non-quorum gossip falls through inside Gossip to a plain block Put.
|
||
return b.Gossip(ctx, msg.NodeID, msg.Message)
|
||
}
|
||
return nil
|
||
}
|
||
func (b *blockHandler) HandleOutbound(ctx context.Context, msg handler.Message) error {
|
||
return nil
|
||
}
|
||
|
||
// noopHandler implements handler.Handler interface
|
||
type noopHandler struct{}
|
||
|
||
func (p *noopHandler) Runtime() *runtime.Runtime { return nil }
|
||
func (p *noopHandler) Start(ctx context.Context, startReqID uint32) {}
|
||
func (p *noopHandler) Push(ctx context.Context, msg handler.Message) {}
|
||
func (p *noopHandler) Len() int { return 0 }
|
||
func (p *noopHandler) Get(ctx context.Context, nodeID ids.NodeID, requestID uint32, deadline time.Time, msg []byte) error {
|
||
return nil
|
||
}
|
||
func (p *noopHandler) GetContext(ctx context.Context, nodeID ids.NodeID, requestID uint32, deadline time.Time, containerID ids.ID) error {
|
||
return nil
|
||
}
|
||
func (p *noopHandler) GetAcceptedFrontier(ctx context.Context, nodeID ids.NodeID, requestID uint32, deadline time.Time) error {
|
||
return nil
|
||
}
|
||
func (p *noopHandler) GetAccepted(ctx context.Context, nodeID ids.NodeID, requestID uint32, deadline time.Time, containerIDs []ids.ID) error {
|
||
return nil
|
||
}
|
||
func (p *noopHandler) Put(ctx context.Context, nodeID ids.NodeID, requestID uint32, container []byte) error {
|
||
return nil
|
||
}
|
||
func (p *noopHandler) PushQuery(ctx context.Context, nodeID ids.NodeID, requestID uint32, deadline time.Time, container []byte) error {
|
||
return nil
|
||
}
|
||
func (p *noopHandler) PullQuery(ctx context.Context, nodeID ids.NodeID, requestID uint32, deadline time.Time, containerID ids.ID) error {
|
||
return nil
|
||
}
|
||
func (p *noopHandler) QueryFailed(ctx context.Context, nodeID ids.NodeID, requestID uint32) error {
|
||
return nil
|
||
}
|
||
func (p *noopHandler) CrossChainRequest(ctx context.Context, chainID ids.ID, requestID uint32, deadline time.Time, msg []byte) error {
|
||
return nil
|
||
}
|
||
func (p *noopHandler) CrossChainRequestFailed(ctx context.Context, chainID ids.ID, requestID uint32) error {
|
||
return nil
|
||
}
|
||
func (p *noopHandler) CrossChainResponse(ctx context.Context, chainID ids.ID, requestID uint32, msg []byte) error {
|
||
return nil
|
||
}
|
||
func (p *noopHandler) Request(ctx context.Context, nodeID ids.NodeID, requestID uint32, deadline time.Time, msg []byte) error {
|
||
return nil
|
||
}
|
||
func (p *noopHandler) RequestFailed(ctx context.Context, nodeID ids.NodeID, requestID uint32) error {
|
||
return nil
|
||
}
|
||
func (p *noopHandler) Response(ctx context.Context, nodeID ids.NodeID, requestID uint32, msg []byte) error {
|
||
return nil
|
||
}
|
||
func (p *noopHandler) Gossip(ctx context.Context, nodeID ids.NodeID, msg []byte) error {
|
||
return nil
|
||
}
|
||
func (p *noopHandler) GetStateSummaryFrontier(ctx context.Context, nodeID ids.NodeID, requestID uint32, deadline time.Time) error {
|
||
return nil
|
||
}
|
||
func (p *noopHandler) StateSummaryFrontier(ctx context.Context, nodeID ids.NodeID, requestID uint32, summary []byte) error {
|
||
return nil
|
||
}
|
||
func (p *noopHandler) GetAcceptedStateSummary(ctx context.Context, nodeID ids.NodeID, requestID uint32, deadline time.Time, heights []uint64) error {
|
||
return nil
|
||
}
|
||
func (p *noopHandler) AcceptedStateSummary(ctx context.Context, nodeID ids.NodeID, requestID uint32, summaryIDs []ids.ID) error {
|
||
return nil
|
||
}
|
||
func (p *noopHandler) Connected(ctx context.Context, nodeID ids.NodeID) error { return nil }
|
||
func (p *noopHandler) Disconnected(ctx context.Context, nodeID ids.NodeID) error { return nil }
|
||
func (p *noopHandler) HealthCheck(ctx context.Context) (interface{}, error) { return nil, nil }
|
||
func (p *noopHandler) Stop(ctx context.Context) {}
|
||
func (p *noopHandler) HandleInbound(ctx context.Context, msg handler.Message) error {
|
||
return nil
|
||
}
|
||
func (p *noopHandler) HandleOutbound(ctx context.Context, msg handler.Message) error {
|
||
return nil
|
||
}
|
||
|
||
// chainSender carries a chain's application messages out over the node's own
|
||
// mesh. An in-process chain has no plugin connection to send through, so
|
||
// without this it is handed nothing and builds a peer network with no peers in
|
||
// it — which does not fail at construction, it panics on the first peer that
|
||
// connects.
|
||
//
|
||
// The three sends are the three the seam names, over the two the mesh offers:
|
||
// a request and a response are point-to-point, gossip is a sample. Nothing is
|
||
// retried here — the mesh answers with the set it reached, and a message to a
|
||
// peer that is gone is a message that did not go, which is a fact for the
|
||
// caller and not an error to raise.
|
||
type chainSender struct {
|
||
net mesh.Network
|
||
msgCreator message.OutboundMsgBuilder
|
||
chainID ids.ID
|
||
log log.Logger
|
||
}
|
||
|
||
var _ warp.Sender = (*chainSender)(nil)
|
||
|
||
func (c *chainSender) SendRequest(ctx context.Context, nodeIDs set.Set[ids.NodeID], requestID uint32, request []byte) error {
|
||
deadline := time.Until(mustDeadline(ctx))
|
||
msg, err := c.msgCreator.Request(c.chainID, requestID, deadline, request)
|
||
if err != nil {
|
||
return fmt.Errorf("chain %s: build app request: %w", c.chainID, err)
|
||
}
|
||
c.net.Send(msg, nodeIDs, c.chainID, requestID)
|
||
return nil
|
||
}
|
||
|
||
func (c *chainSender) SendResponse(ctx context.Context, nodeID ids.NodeID, requestID uint32, response []byte) error {
|
||
msg, err := c.msgCreator.Response(c.chainID, requestID, response)
|
||
if err != nil {
|
||
return fmt.Errorf("chain %s: build app response: %w", c.chainID, err)
|
||
}
|
||
c.net.Send(msg, set.Of(nodeID), c.chainID, requestID)
|
||
return nil
|
||
}
|
||
|
||
// SendError answers a request this chain will not serve. The seam has no error
|
||
// frame of its own, so the code and the text go out as the response body: a
|
||
// requester that gets nothing cannot tell a refusal from a lost message, and
|
||
// waits out its deadline for an answer that was already decided.
|
||
func (c *chainSender) SendError(ctx context.Context, nodeID ids.NodeID, requestID uint32, errorCode int32, errorMessage string) error {
|
||
body := fmt.Appendf(nil, "%d %s", errorCode, errorMessage)
|
||
msg, err := c.msgCreator.Response(c.chainID, requestID, body)
|
||
if err != nil {
|
||
return fmt.Errorf("chain %s: build app error: %w", c.chainID, err)
|
||
}
|
||
c.net.Send(msg, set.Of(nodeID), c.chainID, requestID)
|
||
return nil
|
||
}
|
||
|
||
func (c *chainSender) SendGossip(ctx context.Context, config warp.SendConfig, gossipBytes []byte) error {
|
||
msg, err := c.msgCreator.Gossip(c.chainID, gossipBytes)
|
||
if err != nil {
|
||
return fmt.Errorf("chain %s: build app gossip: %w", c.chainID, err)
|
||
}
|
||
c.net.Gossip(msg, config.NodeIDs, c.chainID, config.Validators, config.NonValidators, config.Peers)
|
||
return nil
|
||
}
|
||
|
||
// mustDeadline is the request's deadline, or a bounded one when the caller set
|
||
// none. A request with no deadline is a slot held open forever.
|
||
func mustDeadline(ctx context.Context) time.Time {
|
||
if d, ok := ctx.Deadline(); ok {
|
||
return d
|
||
}
|
||
return time.Now().Add(defaultAppRequestDeadline)
|
||
}
|
||
|
||
const defaultAppRequestDeadline = 10 * time.Second
|
||
|
||
// networkGossiper implements consensuschain.Gossiper for Lux consensus integration.
|
||
// It adapts the node's network layer to the minimal Gossiper interface used by
|
||
// the integrated consensus engine.
|
||
// ancestorRequester is the one capability networkCatchup needs from the inbound
|
||
// handler: ask a peer for the chain of blocks ending at a missing block.
|
||
// *blockHandler provides it via requestContext (GetAncestors out; the Ancestors
|
||
// response flows handleContext -> Put -> engine). Depending on this one-method
|
||
// interface (not the concrete handler) keeps the bridge testable and narrow.
|
||
type ancestorRequester interface {
|
||
requestContext(ctx context.Context, from ids.NodeID, blockID ids.ID)
|
||
}
|
||
|
||
// networkCatchup is the node-side wire for the consensus engine's chain.Catchup
|
||
// interface. The engine DECIDES when to catch up — followVerifiedBlock sees a
|
||
// gossiped child whose parent it lacks and calls requestCatchup; networkCatchup
|
||
// ROUTES that to the handler's GetAncestors transport. Splitting "decide" (engine,
|
||
// no network types) from "transport" (handler, no consensus policy) keeps each in
|
||
// its lane. Without this wire a follower that falls behind during consensus loops
|
||
// on an unfinalizable orphan forever — the stranded-follower bug.
|
||
//
|
||
// The handler wraps the engine and is built after it, so `handler` is late-bound
|
||
// once both exist. Until then RequestAncestors is a harmless no-op: no block can
|
||
// be missing before the engine has even started.
|
||
type networkCatchup struct {
|
||
handler ancestorRequester
|
||
log log.Logger // nil before late-binding and in tests — every use is guarded
|
||
nWired gatomic.Uint64 // catch-up signals routed to the wire
|
||
nNoWire gatomic.Uint64 // catch-up signals that found no wire to route to
|
||
}
|
||
|
||
// RequestAncestors satisfies chain.Catchup. chainID/networkID are already fixed
|
||
// per-handler (one handler per chain), so the wire needs only the missing block
|
||
// and the peer that advertised its child.
|
||
func (c *networkCatchup) RequestAncestors(_ ids.ID, _ ids.ID, missingBlockID ids.ID, from ids.NodeID) error {
|
||
if c.handler == nil {
|
||
// The engine holds a VERIFIED cert for a block it does not track and asked for the
|
||
// gap, and there is no wire to ask over. That is the stranded-follower bug with its
|
||
// fix present but unbound, and it is INVISIBLE: a node that never fetches looks
|
||
// exactly like a node that was never asked to. Warn, not trace.
|
||
if n, loud := catchupSample(&c.nNoWire); loud && c.log != nil {
|
||
c.log.Warn("catch-up requested but the wire is NOT bound — no GetAncestors will be sent",
|
||
log.Stringer("missingBlockID", missingBlockID),
|
||
log.Stringer("from", from),
|
||
log.Uint64("count", n))
|
||
}
|
||
return nil
|
||
}
|
||
// from is EMPTY when the engine holds a verified cert for an untracked block and
|
||
// leaves peer selection to the node layer (requestContext samples), and a real peer
|
||
// when a gossiped child named its missing parent. Which one it is decides which
|
||
// selection path runs, so it is on the line.
|
||
if n, loud := catchupSample(&c.nWired); loud && c.log != nil {
|
||
c.log.Info("catch-up requested for a block we do not track",
|
||
log.Stringer("missingBlockID", missingBlockID),
|
||
log.Stringer("from", from),
|
||
log.Uint64("count", n))
|
||
}
|
||
c.handler.requestContext(context.Background(), from, missingBlockID)
|
||
return nil
|
||
}
|
||
|
||
type networkGossiper struct {
|
||
net mesh.Network
|
||
msgCreator message.OutboundMsgBuilder
|
||
networkID ids.ID
|
||
log log.Logger
|
||
}
|
||
|
||
// Compile-time check that networkGossiper implements Gossiper
|
||
var _ consensuschain.Gossiper = (*networkGossiper)(nil)
|
||
|
||
// Compile-time check that networkGossiper implements QuorumGossiper — the
|
||
// vote/cert distribution topology that makes α-of-K finality LIVE.
|
||
// Without this the consensus engine runs degraded (Mode() != ModeQuorumFinality)
|
||
// and value-DEX is refused.
|
||
var _ consensuschain.QuorumGossiper = (*networkGossiper)(nil)
|
||
|
||
// BroadcastVote sends this node's SIGNED accept vote for blockID to ALL
|
||
// validators on the network (not just the proposer). The signed vote rides on
|
||
// app-gossip framed in a quorum envelope (decoded by blockHandler.Gossip into
|
||
// engine.HandleIncomingVote). Broadcasting to ALL — rather than SendVote-to-
|
||
// proposer-only — is the structural fix for the proposer-freeze: any node that
|
||
// collects α distinct signed votes can assemble + gossip the cert, so finality
|
||
// no longer hinges on one node's inbound Chits.
|
||
func (g *networkGossiper) BroadcastVote(chainID ids.ID, networkID ids.ID, blockID ids.ID, voteBytes []byte) int {
|
||
// Every return of 0 here is a vote that never left this node. Said silently
|
||
// that is indistinguishable from "no vote was due", which is the question
|
||
// being asked when a chain stops finalizing.
|
||
if g.net == nil || g.msgCreator == nil {
|
||
if g.log != nil {
|
||
g.log.Warn("quorum: vote NOT broadcast — nil transport",
|
||
log.Stringer("blockID", blockID))
|
||
}
|
||
return 0
|
||
}
|
||
envelope := encodeQuorumGossip(quorumKindVote, blockID, voteBytes)
|
||
msg, err := g.msgCreator.Gossip(chainID, envelope)
|
||
if err != nil {
|
||
if g.log != nil {
|
||
g.log.Warn("quorum: vote NOT broadcast — gossip build failed",
|
||
log.Stringer("blockID", blockID), log.Err(err))
|
||
}
|
||
return 0
|
||
}
|
||
sent := g.net.Gossip(msg, nil, g.networkID, -1, 0, 0).Len()
|
||
if g.log != nil {
|
||
g.log.Info("quorum: vote broadcast",
|
||
log.Stringer("blockID", blockID),
|
||
log.Stringer("gossipNetworkID", g.networkID),
|
||
log.Int("sentToPeers", sent),
|
||
log.Int("voteBytes", len(voteBytes)))
|
||
}
|
||
return sent
|
||
}
|
||
|
||
// v1.36 "Nova": BroadcastPrevote was DELETED — the round-scoped view-change (prevote/POL/lock)
|
||
// it fed no longer exists in the consensus engine (174af3c31). Nova sampling decides; the ⅔
|
||
// Quasar attestation (a plain accept-vote, gossiped via BroadcastVote) trails it. Keep the braid dead.
|
||
|
||
// GossipCert broadcasts an assembled α-of-K finality cert to ALL validators so
|
||
// followers finalize blockID on a verifiable proof (HandleIncomingCert), not a
|
||
// fast-follow guess. Best effort: the gossiping node's own finality is already
|
||
// established by the verified cert.
|
||
func (g *networkGossiper) GossipCert(chainID ids.ID, networkID ids.ID, blockID ids.ID, certBytes []byte) int {
|
||
if g.net == nil || g.msgCreator == nil {
|
||
return 0
|
||
}
|
||
envelope := encodeQuorumGossip(quorumKindCert, blockID, certBytes)
|
||
msg, err := g.msgCreator.Gossip(chainID, envelope)
|
||
if err != nil {
|
||
if g.log != nil {
|
||
g.log.Warn("quorum: CERT assembled but not gossiped — build failed",
|
||
log.Stringer("blockID", blockID), log.Err(err))
|
||
}
|
||
return 0
|
||
}
|
||
sent := g.net.Gossip(msg, nil, g.networkID, -1, 0, 0).Len()
|
||
if g.log != nil {
|
||
g.log.Info("quorum: CERT assembled and gossiped",
|
||
log.Stringer("blockID", blockID), log.Int("sentToPeers", sent))
|
||
}
|
||
return sent
|
||
}
|
||
|
||
// GossipPut broadcasts a Put message with block data to validators.
|
||
func (g *networkGossiper) GossipPut(chainID ids.ID, networkID ids.ID, blockData []byte) int {
|
||
if g.net == nil || g.msgCreator == nil {
|
||
return 0
|
||
}
|
||
|
||
putMsg, err := g.msgCreator.Put(chainID, 0, blockData)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
|
||
// Gossip to all validators (-1 = all validators)
|
||
// Must use g.networkID (chain ID) not chainID (blockchain ID) —
|
||
// net.Gossip filters validators by chain ID in the validator manager.
|
||
sentTo := g.net.Gossip(putMsg, nil, g.networkID, -1, 0, 0)
|
||
return sentTo.Len()
|
||
}
|
||
|
||
// SendPullQuery sends a PullQuery (blockID only) to validators requesting votes.
|
||
// Prefer SendPushQuery when block bytes are available for immediate peer verification.
|
||
func (g *networkGossiper) SendPullQuery(chainID ids.ID, networkID ids.ID, blockID ids.ID, validators []ids.NodeID) int {
|
||
if g.net == nil || g.msgCreator == nil {
|
||
return 0
|
||
}
|
||
|
||
pullMsg, err := g.msgCreator.PullQuery(chainID, 0, 5*time.Second, blockID, 0)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
|
||
if len(validators) == 0 {
|
||
return g.net.Gossip(pullMsg, nil, chainID, -1, 0, 0).Len()
|
||
}
|
||
|
||
validatorSet := set.NewSet[ids.NodeID](len(validators))
|
||
for _, v := range validators {
|
||
validatorSet.Add(v)
|
||
}
|
||
return g.net.Send(pullMsg, validatorSet, chainID, 0).Len()
|
||
}
|
||
|
||
// SendPushQuery sends a PushQuery (block bytes + vote request) to validators.
|
||
// PushQuery includes the block data so peers can immediately verify and vote,
|
||
// unlike PullQuery which only sends the blockID requiring a separate fetch.
|
||
func (g *networkGossiper) SendPushQuery(chainID ids.ID, networkID ids.ID, blockData []byte, validators []ids.NodeID) int {
|
||
if g.net == nil || g.msgCreator == nil {
|
||
return 0
|
||
}
|
||
|
||
pushMsg, err := g.msgCreator.PushQuery(chainID, 0, 5*time.Second, blockData, 0)
|
||
if err != nil {
|
||
log.Warn("SendPushQuery: message creation failed",
|
||
"chainID", chainID,
|
||
"error", err,
|
||
)
|
||
return 0
|
||
}
|
||
|
||
log.Info("SendPushQuery: sending to network",
|
||
"chainID", chainID,
|
||
"networkID", networkID,
|
||
"blockDataLen", len(blockData),
|
||
"numValidators", len(validators),
|
||
)
|
||
|
||
if len(validators) == 0 {
|
||
return g.net.Gossip(pushMsg, nil, chainID, -1, 0, 0).Len()
|
||
}
|
||
|
||
validatorSet := set.NewSet[ids.NodeID](len(validators))
|
||
for _, v := range validators {
|
||
validatorSet.Add(v)
|
||
}
|
||
return g.net.Send(pushMsg, validatorSet, chainID, 0).Len()
|
||
}
|
||
|
||
// The outbound poll-response transport (SendPollResponse, and the SendVote
|
||
// misnomer the consensuschain.Gossiper interface still demands) lives with the
|
||
// rest of the Nova poll path in nova_poll.go. SendQbit was a byte-identical
|
||
// duplicate of it with zero callers and is gone — one implementation, one name.
|