Files
consensus/engine/chain/integration.go
T
zeekay 726d939e83 consensus: permissionless launch readiness
BLOCKING fixes:
- Quasar: multi-validator finalization (require QThreshold sigs)
- Stake-weighted sampling (StakeWeightedCut replaces SimpleCut)
- FPC: configurable seed via DeriveEpochSeed (was hardcoded string)
- Slashing: equivocation detection (double-vote, double-sign, downtime)
- Prism: implement Propose/Vote (was nil-return stubs)

NON-BLOCKING fixes:
- Photon: Fisher-Yates shuffle in Emit() (was returning first N)
- Field: memoized isFullyFinalized (was O(n^2) DFS)
- Remove Snow/Avalanche references from production code
- Rename snowball_compat tests to threshold_compat
- Fix wave.New callers for (Wave, error) return

New: core/slashing package with Detector, Evidence, SlashingDB
All 26 packages pass. Zero failures.
2026-03-02 23:59:00 -08:00

696 lines
24 KiB
Go

// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package chain
import (
"context"
"sync"
"time"
"github.com/luxfi/consensus/config"
"github.com/luxfi/consensus/engine/chain/block"
"github.com/luxfi/ids"
"github.com/luxfi/log"
)
// ValidatorSampler provides access to the validator set for peer sampling.
// This is the minimal interface needed by consensus - avoids importing full validator package.
type ValidatorSampler interface {
// Sample returns k random validator NodeIDs from the network.
Sample(networkID ids.ID, k int) ([]ids.NodeID, error)
// Count returns the number of validators in the network.
Count(networkID ids.ID) int
}
// NetworkConfig holds parameters for integrating consensus with the node network.
type NetworkConfig struct {
// ChainID is the ID of this chain (used for chain-scoped messages like Put, PullQuery)
ChainID ids.ID
// NetworkID is the ID of the network whose validators secure this chain
// For primary network chains (P/X/C), this equals constants.PrimaryNetworkID
// For L1 chains, this is the L1's validator set ID
NetworkID ids.ID
// NodeID is this node's identifier (for excluding self from samples)
NodeID ids.NodeID
// Validators provides access to the validator set for peer sampling.
// If nil, the engine broadcasts to all peers (less efficient).
Validators ValidatorSampler
// Logger for consensus events
Logger log.Logger
// Gossiper broadcasts messages to validators
Gossiper Gossiper
// VM implements BlockBuilder for block creation
VM BlockBuilder
// Params are optional consensus parameters. If nil, DefaultParams() is used.
// For small validator sets (e.g., 5 nodes), use LocalParams() which has
// K=5, Beta=4 - appropriate thresholds for the validator count.
Params *config.Parameters
}
// Gossiper abstracts the network layer for consensus message broadcasting.
// This minimal interface avoids importing the full node/network package.
//
// Gossiper is the network-level interface. BlockProposer (in engine.go)
// is the consensus-level interface. The gossiperAdapter bridges them.
type Gossiper interface {
// GossipPut broadcasts a Put message with block data to validators.
// Returns the number of validators the message was sent to.
GossipPut(chainID ids.ID, networkID ids.ID, blockData []byte) int
// SendPullQuery sends a PullQuery to specific validators requesting votes.
SendPullQuery(chainID ids.ID, networkID ids.ID, blockID ids.ID, validators []ids.NodeID) int
// SendPushQuery sends a PushQuery (block data + vote request) to validators.
// Unlike PullQuery (which only sends blockID), PushQuery includes the block bytes
// so peers can immediately verify and respond with their vote.
// Returns the number of validators the message was sent to.
SendPushQuery(chainID ids.ID, networkID ids.ID, blockData []byte, validators []ids.NodeID) int
// SendVote sends a vote response back to the proposer node.
// Vote response back to the proposer
// This is called after fast-follow acceptance to notify the proposer
// that this node has accepted the block, enabling the proposer to
// reach vote threshold and finalize its own copy.
SendVote(chainID ids.ID, toNodeID ids.NodeID, blockID ids.ID) error
}
// Runtime wraps Transitive with network integration and VM notification handling.
// Use NewRuntime to create - this is the "one right way" to set up consensus.
type Runtime struct {
*Transitive
config NetworkConfig
// Validator sampling for k-peer polls
validators ValidatorSampler
nodeID ids.NodeID
// fastFollowMu serializes fast-follow block acceptance to prevent
// duplicate gossip deliveries from racing on the accept path.
fastFollowMu sync.Mutex
// fastFollowHeight tracks the highest block height accepted via fast-follow.
// We use height instead of parent ID matching because:
// 1. VM.LastAccepted() is stale after Accept() calls
// 2. Block-producing nodes don't update the tracker when they build blocks
// Height-based acceptance is safe because blocks are already verified.
fastFollowHeight uint64
}
// NewRuntime creates a fully wired consensus runtime ready for production use.
//
// This is the single, canonical way to create a chain consensus runtime for node integration.
// It:
// 1. Creates the Transitive engine with default parameters
// 2. Wires the network gossiper as the BlockProposer
// 3. Registers the VM for block building
// 4. Returns a ready-to-start runtime
//
// Usage in manager.go:
//
// runtime := chain.NewRuntime(chain.NetworkConfig{
// ChainID: chainParams.ID,
// NetworkID: chainParams.ChainID, // PrimaryNetworkID for P/X/C
// Logger: m.Log,
// Gossiper: &networkGossiper{net: m.Net, msgCreator: m.MsgCreator},
// VM: vm.(chain.BlockBuilder),
// })
// if err := runtime.Start(ctx, true); err != nil { return err }
// go runtime.ForwardVMNotifications(toEngine)
func NewRuntime(cfg NetworkConfig) *Runtime {
// Use provided params or default
params := config.DefaultParams()
if cfg.Params != nil {
params = *cfg.Params
}
engine := NewWithParams(params)
rt := &Runtime{
Transitive: engine,
config: cfg,
validators: cfg.Validators,
nodeID: cfg.NodeID,
}
// Log validator set status for debugging
hasLogger := cfg.Logger != nil && !cfg.Logger.IsZero()
if cfg.Validators != nil {
count := cfg.Validators.Count(cfg.NetworkID)
if hasLogger {
cfg.Logger.Info("consensus engine initialized with validator set",
log.Stringer("networkID", cfg.NetworkID),
log.Int("validatorCount", count),
log.Int("k", params.K),
log.Int("alpha", params.AlphaPreference))
}
} else {
if hasLogger {
cfg.Logger.Warn("consensus engine initialized WITHOUT validator set - will broadcast to all peers",
log.Stringer("networkID", cfg.NetworkID))
}
}
// Wire the proposer (adapts Gossiper to BlockProposer interface).
// In single-node mode (K=1, e.g. --dev), provide a self-voter callback
// so the proposer can accept its own blocks without network round-trips.
var selfVoter func(ids.ID)
if params.K == 1 {
selfVoter = func(blockID ids.ID) {
engine.ReceiveVote(Vote{
BlockID: blockID,
NodeID: cfg.NodeID,
Accept: true,
SignedAt: time.Now(),
})
}
}
engine.SetProposer(&gossiperProposer{
gossiper: cfg.Gossiper,
chainID: cfg.ChainID,
networkID: cfg.NetworkID,
logger: cfg.Logger,
validators: cfg.Validators,
nodeID: cfg.NodeID,
k: params.K,
selfVoter: selfVoter,
})
// Set the VM for block building
if cfg.VM != nil {
engine.SetVM(cfg.VM)
}
return rt
}
// SampleValidators returns k random validator NodeIDs for the network.
// This is used by the consensus engine for k-sampling polls.
// Returns nil if no validator sampler is configured (falls back to broadcast).
func (rt *Runtime) SampleValidators(k int) ([]ids.NodeID, error) {
if rt.validators == nil {
return nil, nil // Will broadcast to all
}
return rt.validators.Sample(rt.config.NetworkID, k)
}
// ValidatorCount returns the number of validators in the network.
// Returns 0 if no validator sampler is configured.
func (rt *Runtime) ValidatorCount() int {
if rt.validators == nil {
return 0
}
return rt.validators.Count(rt.config.NetworkID)
}
// ForwardVMNotifications reads from the VM's toEngine channel and forwards
// PendingTxs notifications to trigger block building through consensus.
//
// Call this as a goroutine after Start():
//
// go runtime.ForwardVMNotifications(toEngine)
//
// The goroutine exits when the channel is closed.
func (rt *Runtime) ForwardVMNotifications(toEngine <-chan block.Message) {
if !rt.config.Logger.IsZero() {
rt.config.Logger.Info("starting VM notification forwarder for Lux consensus",
log.Stringer("chainID", rt.config.ChainID))
}
for msg := range toEngine {
// Translate block.MessageType → engine.MessageType
// block.PendingTxs = 1 (iota+1), core.PendingTxs = 0
// block.StateSyncDone = 2, core.StateSyncDone = 1
var engineMsgType MessageType
switch msg.Type {
case block.PendingTxs:
engineMsgType = PendingTxs
case block.StateSyncDone:
engineMsgType = StateSyncDone
default:
if !rt.config.Logger.IsZero() {
rt.config.Logger.Warn("unknown VM message type, dropping",
log.Uint32("type", uint32(msg.Type)))
}
continue
}
if !rt.config.Logger.IsZero() {
rt.config.Logger.Debug("received VM notification, forwarding to consensus engine",
log.Uint32("vmType", uint32(msg.Type)),
log.Uint32("engineType", uint32(engineMsgType)))
}
ctx := context.Background()
if err := rt.Notify(ctx, Message{Type: engineMsgType}); err != nil {
if rt.config.Logger != nil && !rt.config.Logger.IsZero() {
rt.config.Logger.Warn("failed to notify consensus engine",
log.Uint32("type", uint32(engineMsgType)),
log.Err(err))
}
}
// In single-node mode, drain any accepted blocks and call VM.Accept().
// Notify → buildBlocksLocked → AddBlock + ProcessVote + Poll may have
// accepted blocks synchronously. The engine tracks them in pendingBlocks
// with Decided=false until we finalize here.
rt.drainAcceptedBlocks(ctx)
}
if !rt.config.Logger.IsZero() {
rt.config.Logger.Info("VM notification forwarder stopped")
}
}
// drainAcceptedBlocks finalizes any blocks that consensus has accepted.
// This is called after each VM notification to ensure accepted blocks have
// their VM.Accept() called promptly (especially important in single-node mode
// where blocks are accepted synchronously during buildBlocksLocked).
func (rt *Runtime) drainAcceptedBlocks(ctx context.Context) {
rt.Transitive.DrainAccepted(ctx)
}
// gossiperProposer adapts a Gossiper to the BlockProposer interface.
// This bridges the network layer (Gossiper) to the consensus layer (BlockProposer).
type gossiperProposer struct {
gossiper Gossiper
chainID ids.ID
networkID ids.ID
logger log.Logger
validators ValidatorSampler // For k-peer sampling
nodeID ids.NodeID // This node's ID (to exclude from samples)
k int // Sample size from consensus params
selfVoter func(ids.ID) // Callback for single-node self-voting (--dev mode)
}
var _ BlockProposer = (*gossiperProposer)(nil)
// Propose broadcasts a block proposal to validators via the network gossiper.
func (p *gossiperProposer) Propose(ctx context.Context, proposal BlockProposal) error {
if p.gossiper == nil {
if p.logger != nil && !p.logger.IsZero() {
p.logger.Warn("cannot propose block - gossiper is nil",
log.Stringer("blockID", proposal.BlockID))
}
return nil // Not fatal - local acceptance still works
}
sentTo := p.gossiper.GossipPut(p.chainID, p.networkID, proposal.BlockData)
if p.logger != nil && !p.logger.IsZero() {
p.logger.Info("proposed block to validators",
log.Stringer("blockID", proposal.BlockID),
log.Uint64("height", proposal.Height),
log.Int("sentTo", sentTo))
}
return nil
}
// RequestVotes asks specific validators to vote on a block.
// If req.Validators is nil and we have a ValidatorSampler, sample k validators.
// This implements Lux k-sampling: select k validators and request votes.
//
// Single-node mode (K=1, only validator is self): the proposer delivers a
// self-vote via the SelfVoter callback instead of polling the network.
// This is the standard path for --dev mode (local single-validator networks).
func (p *gossiperProposer) RequestVotes(ctx context.Context, req VoteRequest) error {
if p.gossiper == nil {
if p.logger != nil && !p.logger.IsZero() {
p.logger.Warn("cannot request votes - gossiper is nil",
log.Stringer("blockID", req.BlockID))
}
return nil
}
// Determine which validators to query
validators := req.Validators
if validators == nil && p.validators != nil && p.k > 0 {
// Sample k validators from the validator set (excluding self)
sampled, err := p.validators.Sample(p.networkID, p.k)
if err != nil {
if p.logger != nil && !p.logger.IsZero() {
p.logger.Warn("failed to sample validators, falling back to broadcast",
log.Stringer("blockID", req.BlockID),
log.Int("k", p.k),
log.Err(err))
}
// Fall back to broadcast (nil validators)
} else {
// Filter out self from sample
filtered := make([]ids.NodeID, 0, len(sampled))
for _, nodeID := range sampled {
if nodeID != p.nodeID {
filtered = append(filtered, nodeID)
}
}
validators = filtered
// Single-node mode: all sampled validators were self.
// Deliver a self-vote directly — no network round-trip needed.
if len(filtered) == 0 && p.k == 1 && p.selfVoter != nil {
if p.logger != nil && !p.logger.IsZero() {
p.logger.Info("single-node mode: self-voting for proposed block",
log.Stringer("blockID", req.BlockID))
}
p.selfVoter(req.BlockID)
return nil
}
if p.logger != nil && !p.logger.IsZero() {
p.logger.Debug("sampled k validators for poll",
log.Stringer("blockID", req.BlockID),
log.Int("k", p.k),
log.Int("sampled", len(validators)),
log.Int("totalValidators", p.validators.Count(p.networkID)))
}
}
}
var sentTo int
if len(req.BlockData) > 0 {
// PushQuery: send block bytes + vote request in one message.
// Peers can immediately verify and respond with their vote.
sentTo = p.gossiper.SendPushQuery(p.chainID, p.networkID, req.BlockData, validators)
} else {
// Fallback to PullQuery (blockID only) if no block data available.
sentTo = p.gossiper.SendPullQuery(p.chainID, p.networkID, req.BlockID, validators)
}
if p.logger != nil && !p.logger.IsZero() {
p.logger.Debug("requested votes from validators",
log.Stringer("blockID", req.BlockID),
log.Int("requested", len(validators)),
log.Int("sentTo", sentTo),
log.Bool("pushQuery", len(req.BlockData) > 0))
}
return nil
}
// HandleIncomingBlock processes a block received from network gossip.
// For follower nodes receiving blocks from the proposer, this uses a "fast-follow"
// pattern where verified blocks extending the accepted chain are accepted immediately.
//
// This is necessary because in the current architecture, votes are only sent back
// to the proposer (not gossiped to all validators). So followers would never reach
// the vote threshold on their own. Instead, followers trust that:
// 1. The proposer collected enough votes before gossiping the block
// 2. The block verifies correctly against their state
// 3. The block extends their current chain tip
//
// Returns the parsed block if successful, nil otherwise.
func (rt *Runtime) HandleIncomingBlock(ctx context.Context, blockData []byte, fromNodeID ids.NodeID) (block.Block, error) {
if rt.config.VM == nil {
return nil, nil
}
// Parse the block
blk, err := rt.config.VM.ParseBlock(ctx, blockData)
if err != nil {
if !rt.config.Logger.IsZero() {
rt.config.Logger.Debug("failed to parse incoming block",
log.Stringer("from", fromNodeID),
log.Err(err))
}
return nil, err
}
// Verify the block
if err := blk.Verify(ctx); err != nil {
if !rt.config.Logger.IsZero() {
rt.config.Logger.Debug("incoming block failed verification",
log.Stringer("blockID", blk.ID()),
log.Stringer("from", fromNodeID),
log.Err(err))
}
return blk, err
}
if !rt.config.Logger.IsZero() {
rt.config.Logger.Info("received and verified block from gossip",
log.Stringer("blockID", blk.ID()),
log.Uint64("height", blk.Height()),
log.Stringer("from", fromNodeID))
}
// Fast-follow pattern: Accept blocks that extend our chain from valid proposers
// This is safe because:
// 1. Block has been verified (cryptographic integrity, state transitions)
// 2. Block came from a known validator (fromNodeID)
// 3. Block extends our accepted chain (parent check below)
//
// The mutex serializes fast-follow acceptance to prevent concurrent
// Accept() calls from duplicate gossip delivery of the same block.
blockID := blk.ID()
rt.fastFollowMu.Lock()
defer rt.fastFollowMu.Unlock()
// Height-based fast-follow: accept verified gossip blocks that advance
// beyond our tracked height. This is robust because:
// 1. Blocks are already cryptographically verified before reaching here
// 2. The sender is a known validator
// 3. Height ordering prevents re-accepting old blocks or going backwards
// 4. The mutex prevents concurrent acceptance races
//
// We use height instead of parent-ID matching because VM.LastAccepted()
// is asynchronous and block-producing nodes don't update the tracker.
incomingHeight := blk.Height()
// Dedup: skip blocks at or below our tracked height
if incomingHeight <= rt.fastFollowHeight {
if !rt.config.Logger.IsZero() {
rt.config.Logger.Info("fast-follow: block at/below tracked height, skipping",
log.Stringer("blockID", blockID),
log.Uint64("incomingHeight", incomingHeight),
log.Uint64("trackedHeight", rt.fastFollowHeight))
}
return blk, nil
}
if !rt.config.Logger.IsZero() {
rt.config.Logger.Info("fast-follow: accepting block from validator",
log.Stringer("blockID", blockID),
log.Uint64("height", incomingHeight),
log.Stringer("from", fromNodeID),
log.Uint64("trackedHeight", rt.fastFollowHeight))
}
// Accept the block directly
if err := blk.Accept(ctx); err != nil {
if !rt.config.Logger.IsZero() {
rt.config.Logger.Warn("fast-follow: accept failed, falling through to consensus",
log.Stringer("blockID", blockID),
log.Uint64("height", incomingHeight),
log.Err(err))
}
// Accept failed - fall through to consensus tracking
} else {
// Update our height tracker immediately
rt.fastFollowHeight = incomingHeight
// Update VM preference to build on this block
if err := rt.config.VM.SetPreference(ctx, blockID); err != nil {
if !rt.config.Logger.IsZero() {
rt.config.Logger.Warn("fast-follow: failed to set preference",
log.Stringer("blockID", blockID),
log.Err(err))
}
}
rt.Transitive.blocksAccepted++
if !rt.config.Logger.IsZero() {
rt.config.Logger.Info("fast-follow: block accepted successfully",
log.Stringer("blockID", blockID),
log.Uint64("height", incomingHeight))
}
// Send vote back to proposer so they can finalize
if rt.config.Gossiper != nil {
if err := rt.config.Gossiper.SendVote(rt.config.ChainID, fromNodeID, blockID); err != nil {
if !rt.config.Logger.IsZero() {
rt.config.Logger.Warn("fast-follow: failed to send vote",
log.Stringer("blockID", blockID),
log.Stringer("proposer", fromNodeID),
log.Err(err))
}
}
}
return blk, nil
}
// If fast-follow doesn't apply, fall back to consensus tracking
// (This handles out-of-order blocks or conflicting chains)
if !rt.config.Logger.IsZero() {
rt.config.Logger.Debug("block does not extend chain tip, adding to consensus tracking",
log.Stringer("blockID", blk.ID()),
log.Uint64("height", blk.Height()))
}
// Create consensus block and add to tracking
consensusBlock := &Block{
id: blk.ID(),
parentID: blk.ParentID(),
height: blk.Height(),
timestamp: blk.Timestamp().Unix(),
data: blk.Bytes(),
}
// Add to consensus engine
if err := rt.Transitive.consensus.AddBlock(ctx, consensusBlock); err != nil {
if !rt.config.Logger.IsZero() {
rt.config.Logger.Debug("failed to add block to consensus",
log.Stringer("blockID", blk.ID()),
log.Err(err))
}
// Continue anyway - block may already exist
}
// Add to pending blocks for tracking
rt.Transitive.mu.Lock()
if _, exists := rt.Transitive.pendingBlocks[blk.ID()]; !exists {
rt.Transitive.pendingBlocks[blk.ID()] = &PendingBlock{
ConsensusBlock: consensusBlock,
VMBlock: blk,
ProposedAt: time.Now(),
VoteCount: 1, // Count our own vote
Decided: false,
}
} else {
// Block already tracked, increment vote count
rt.Transitive.pendingBlocks[blk.ID()].VoteCount++
}
rt.Transitive.mu.Unlock()
// Vote in favor of the block (process our vote)
responses := map[ids.ID]int{blk.ID(): 1}
if err := rt.Transitive.consensus.Poll(ctx, responses); err != nil {
if !rt.config.Logger.IsZero() {
rt.config.Logger.Debug("failed to vote on block",
log.Stringer("blockID", blk.ID()),
log.Err(err))
}
}
return blk, nil
}
// OnImportComplete must be called after admin_importChain (RLP import) completes.
// This reconciles the consensus engine's state with the VM's actual state after import.
//
// The problem this solves:
// - RLP import updates the EVM state database directly
// - But the consensus engine still thinks lastAccepted is the old block
// - This causes transactions to timeout (engine builds on wrong parent)
// - And causes "chains not bootstrapped" errors on node restart
//
// This method:
// 1. Queries VM.LastAccepted() to get the current chain tip after import
// 2. Updates consensus.finalizedTip to match
// 3. Updates VM preference to build on the new tip
// 4. Marks consensus as bootstrapped
//
// Usage in EVM admin API after successful import:
//
// if err := rt.OnImportComplete(ctx); err != nil {
// log.Warn("failed to sync consensus after import", "error", err)
// }
//
// This is idempotent - safe to call even if import didn't change state.
func (rt *Runtime) OnImportComplete(ctx context.Context) error {
logger := rt.config.Logger
hasLogger := logger != nil && !logger.IsZero()
if rt.config.VM == nil {
if hasLogger {
logger.Warn("OnImportComplete: VM is nil, cannot sync state")
}
return nil
}
// Step 1: Query VM for current last accepted block
lastAcceptedID, err := rt.config.VM.LastAccepted(ctx)
if err != nil {
if hasLogger {
logger.Warn("OnImportComplete: failed to get last accepted from VM",
log.Err(err))
}
return err
}
// Step 2: Get block details (height) for consensus state
var height uint64
if lastAcceptedID != ids.Empty {
blk, err := rt.config.VM.GetBlock(ctx, lastAcceptedID)
if err != nil {
if hasLogger {
logger.Warn("OnImportComplete: failed to get block details",
log.Stringer("blockID", lastAcceptedID),
log.Err(err))
}
// Continue with height 0 - consensus can recover
} else {
height = blk.Height()
}
}
// Step 3: Update VM preference to build on current tip
// This is critical: without this, the VM's Preferred() returns old block
// while GetLastAccepted() returns the imported block, causing state mismatch
if err := rt.config.VM.SetPreference(ctx, lastAcceptedID); err != nil {
if hasLogger {
logger.Warn("OnImportComplete: failed to set VM preference",
log.Stringer("blockID", lastAcceptedID),
log.Err(err))
}
// Non-fatal: continue with consensus sync
}
// Step 4: Sync consensus engine state
if err := rt.Transitive.SyncState(ctx, lastAcceptedID, height); err != nil {
if hasLogger {
logger.Warn("OnImportComplete: failed to sync consensus state",
log.Stringer("blockID", lastAcceptedID),
log.Uint64("height", height),
log.Err(err))
}
return err
}
if hasLogger {
logger.Info("OnImportComplete: consensus state synced with VM",
log.Stringer("lastAcceptedID", lastAcceptedID),
log.Uint64("height", height))
}
return nil
}
// SyncStateFromVM queries the VM for its current state and syncs the consensus
// engine to match. This is a lower-level version of OnImportComplete that can
// be called without a full Runtime (e.g., from standalone syncer usage).
//
// Returns the synced block ID and height, or error.
func SyncStateFromVM(ctx context.Context, vm BlockBuilder, consensus *Transitive) (ids.ID, uint64, error) {
if vm == nil {
return ids.Empty, 0, nil
}
// Get last accepted from VM
lastAcceptedID, err := vm.LastAccepted(ctx)
if err != nil {
return ids.Empty, 0, err
}
// Get height
var height uint64
if lastAcceptedID != ids.Empty {
blk, err := vm.GetBlock(ctx, lastAcceptedID)
if err == nil {
height = blk.Height()
}
}
// Set preference (non-fatal if this fails)
_ = vm.SetPreference(ctx, lastAcceptedID)
// Sync consensus
if consensus != nil {
if err := consensus.SyncState(ctx, lastAcceptedID, height); err != nil {
return lastAcceptedID, height, err
}
}
return lastAcceptedID, height, nil
}