diff --git a/eth/api_backend.go b/eth/api_backend.go index 5e3558d8eb..d88cc658c4 100644 --- a/eth/api_backend.go +++ b/eth/api_backend.go @@ -423,6 +423,10 @@ func (b *EthAPIBackend) SyncProgress(ctx context.Context) ethereum.SyncProgress return prog } +func (b *EthAPIBackend) ConsensusReady() bool { + return b.eth.ConsensusReady() +} + func (b *EthAPIBackend) SuggestGasTipCap(ctx context.Context) (*big.Int, error) { return b.gpo.SuggestTipCap(ctx) } diff --git a/eth/backend.go b/eth/backend.go index af8b04bda6..78aeb6dd0e 100644 --- a/eth/backend.go +++ b/eth/backend.go @@ -25,6 +25,7 @@ import ( "math/big" "runtime" "sync" + "sync/atomic" "time" "github.com/ethereum/go-ethereum/accounts" @@ -123,6 +124,21 @@ type Ethereum struct { lock sync.RWMutex // Protects the variadic fields (e.g. gas price and etherbase) shutdownTracker *shutdowncheck.ShutdownTracker // Tracks if and when the node has shutdown ungracefully + + clExpected atomic.Bool // Set when catalyst.Register attaches the Engine API + clContacted atomic.Bool // Set on first Engine API call (newPayload / FCU) +} + +// MarkCLExpected and MarkCLContacted are setters for the two clXxx flags; +// catalyst calls them from its package and so cannot reach the fields directly. +func (s *Ethereum) MarkCLExpected() { s.clExpected.Store(true) } +func (s *Ethereum) MarkCLContacted() { s.clContacted.Store(true) } + +// ConsensusReady reports whether eth_syncing should be allowed to return false. +// On nodes without an Engine API, always true. On nodes that expect a CL, true +// only after the CL has driven the node at least once. +func (s *Ethereum) ConsensusReady() bool { + return !s.clExpected.Load() || s.clContacted.Load() } // New creates a new Ethereum object (including the initialisation of the common Ethereum object), diff --git a/eth/catalyst/api.go b/eth/catalyst/api.go index 4109971dc8..b38e441102 100644 --- a/eth/catalyst/api.go +++ b/eth/catalyst/api.go @@ -49,6 +49,7 @@ import ( // Register adds the engine API and related APIs to the full node. func Register(stack *node.Node, backend *eth.Ethereum) error { + backend.MarkCLExpected() stack.RegisterAPIs([]rpc.API{ newTestingAPI(backend), { @@ -251,6 +252,7 @@ func (api *ConsensusAPI) forkchoiceUpdated(ctx context.Context, update engine.Fo } // Stash away the last update to warn the user if the beacon client goes offline api.lastForkchoiceUpdate.Store(time.Now().Unix()) + api.eth.MarkCLContacted() // Check whether we have the block yet in our database or not. If not, we'll // need to either trigger a sync, or to reject this forkchoice update for a @@ -875,6 +877,7 @@ func (api *ConsensusAPI) newPayload(ctx context.Context, params engine.Executabl } // Stash away the last update to warn the user if the beacon client goes offline api.lastNewPayloadUpdate.Store(time.Now().Unix()) + api.eth.MarkCLContacted() // If we already have the block locally, ignore the entire execution and just // return a fake success. diff --git a/internal/ethapi/api.go b/internal/ethapi/api.go index 6452fcf37c..2f02dcd9dc 100644 --- a/internal/ethapi/api.go +++ b/internal/ethapi/api.go @@ -161,8 +161,10 @@ func (api *EthereumAPI) BaseFee(ctx context.Context) *hexutil.Big { func (api *EthereumAPI) Syncing(ctx context.Context) (interface{}, error) { progress := api.b.SyncProgress(ctx) - // Return not syncing if the synchronisation already completed - if progress.Done() { + // Don't claim "synced" until the CL has driven us at least once (post-merge + // nodes with Engine API attached). Backends without a CL report ready + // immediately via ConsensusReady. Refs #33687. + if progress.Done() && api.b.ConsensusReady() { return false, nil } // Otherwise gather the block sync stats diff --git a/internal/ethapi/api_test.go b/internal/ethapi/api_test.go index 561ce2c2d2..550a3dae2c 100644 --- a/internal/ethapi/api_test.go +++ b/internal/ethapi/api_test.go @@ -493,6 +493,7 @@ func newTestBackend(t *testing.T, n int, gspec *core.Genesis, engine consensus.E func (b testBackend) SyncProgress(ctx context.Context) ethereum.SyncProgress { return ethereum.SyncProgress{} } +func (b testBackend) ConsensusReady() bool { return true } func (b testBackend) SuggestGasTipCap(ctx context.Context) (*big.Int, error) { return big.NewInt(0), nil } diff --git a/internal/ethapi/backend.go b/internal/ethapi/backend.go index f23be85782..a68430173a 100644 --- a/internal/ethapi/backend.go +++ b/internal/ethapi/backend.go @@ -42,6 +42,7 @@ import ( type Backend interface { // General Ethereum API SyncProgress(ctx context.Context) ethereum.SyncProgress + ConsensusReady() bool SuggestGasTipCap(ctx context.Context) (*big.Int, error) FeeHistory(ctx context.Context, blockCount uint64, lastBlock rpc.BlockNumber, rewardPercentiles []float64) (*big.Int, [][]*big.Int, []*big.Int, []float64, []*big.Int, []float64, error) diff --git a/internal/ethapi/syncing_test.go b/internal/ethapi/syncing_test.go new file mode 100644 index 0000000000..27f188163d --- /dev/null +++ b/internal/ethapi/syncing_test.go @@ -0,0 +1,75 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package ethapi + +import ( + "context" + "testing" + + "github.com/ethereum/go-ethereum" +) + +type syncingBackend struct { + Backend + progress ethereum.SyncProgress + ready bool +} + +func (b *syncingBackend) SyncProgress(_ context.Context) ethereum.SyncProgress { return b.progress } +func (b *syncingBackend) ConsensusReady() bool { return b.ready } + +// Issue #33687: a Done downloader but no CL handshake yet must report syncing. +func TestSyncingBeforeCLContact(t *testing.T) { + api := NewEthereumAPI(&syncingBackend{progress: ethereum.SyncProgress{}, ready: false}) + res, err := api.Syncing(context.Background()) + if err != nil { + t.Fatalf("Syncing returned error: %v", err) + } + if v, ok := res.(bool); ok && !v { + t.Fatal("expected truthy syncing payload before CL handshake, got false") + } +} + +func TestSyncingAfterCLContact(t *testing.T) { + api := NewEthereumAPI(&syncingBackend{progress: ethereum.SyncProgress{}, ready: true}) + res, err := api.Syncing(context.Background()) + if err != nil { + t.Fatalf("Syncing returned error: %v", err) + } + if v, ok := res.(bool); !ok || v { + t.Fatalf("expected false after CL handshake when sync is done, got %v", res) + } +} + +// Active sync stays truthy regardless of the CL gate. +func TestSyncingActiveSyncIgnoresCLGate(t *testing.T) { + api := NewEthereumAPI(&syncingBackend{ + progress: ethereum.SyncProgress{ + StartingBlock: 100, + CurrentBlock: 150, + HighestBlock: 200, + }, + ready: false, + }) + res, err := api.Syncing(context.Background()) + if err != nil { + t.Fatalf("Syncing returned error: %v", err) + } + if _, ok := res.(map[string]interface{}); !ok { + t.Fatalf("expected progress map during active sync, got %T", res) + } +} diff --git a/internal/ethapi/transaction_args_test.go b/internal/ethapi/transaction_args_test.go index ccb46a810d..8995440238 100644 --- a/internal/ethapi/transaction_args_test.go +++ b/internal/ethapi/transaction_args_test.go @@ -327,6 +327,7 @@ func (b *backendMock) ChainConfig() *params.ChainConfig { return b.config } func (b *backendMock) SyncProgress(ctx context.Context) ethereum.SyncProgress { return ethereum.SyncProgress{} } +func (b *backendMock) ConsensusReady() bool { return true } func (b *backendMock) FeeHistory(ctx context.Context, blockCount uint64, lastBlock rpc.BlockNumber, rewardPercentiles []float64) (*big.Int, [][]*big.Int, []*big.Int, []float64, []*big.Int, []float64, error) { return nil, nil, nil, nil, nil, nil, nil }