From c2b1e26f2ae8d26c303d8cb7d6149e08d8a1cc8b Mon Sep 17 00:00:00 2001 From: Jerry Date: Fri, 10 Apr 2026 18:03:25 -0700 Subject: [PATCH 1/2] consensus/bor, miner: fix stale pending state for non-validator nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nodes whose signer is configured but not in the active validator set (e.g. after a validator rotation) returned "insufficient funds for transfer" on eth_estimateGas and other RPC calls against the "pending" block. The root cause was that Prepare() returned UnauthorizedSignerError when the signer was not in the validator set, which caused prepareWork() to fail on every block. Since commit() was never called, the pending snapshot was never refreshed, and its underlying pathdb trie layers were eventually garbage-collected — making all state reads return zero. Fix Prepare() to not fail for unauthorized signers. Instead, default the succession number to 0 and let the block building pipeline continue normally. Seal() already independently rejects blocks from unauthorized signers, so no invalid blocks can be produced. This ensures the pending snapshot is refreshed on every chain head, keeping RPC queries functional regardless of whether the node is actively producing blocks. Also remove a redundant second call to GetSignerSuccessionNumber in the Giugliano waitOnPrepare block, reusing the succession value already computed earlier in Prepare(). --- consensus/bor/bor.go | 13 +++++------ miner/worker.go | 8 ++++++- miner/worker_test.go | 51 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/consensus/bor/bor.go b/consensus/bor/bor.go index 2b0ca4fcb7..a881e6698b 100644 --- a/consensus/bor/bor.go +++ b/consensus/bor/bor.go @@ -1104,7 +1104,10 @@ func (c *Bor) Prepare(chain consensus.ChainHeaderReader, header *types.Header, w if currentSigner.signer != (common.Address{}) { succession, err = snap.GetSignerSuccessionNumber(currentSigner.signer) if err != nil { - return err + // If the signer is not in the active validator set, use succession 0 + // so that the pending block header is still valid for RPC queries. + // Seal() will independently reject the block if unauthorized. + succession = 0 } } @@ -1154,15 +1157,9 @@ func (c *Bor) Prepare(chain consensus.ChainHeaderReader, header *types.Header, w // Wait before start the block production if needed (previously this wait was on Seal) if c.config.IsGiugliano(header.Number) && waitOnPrepare { - var successionNumber int // if signer is not empty (RPC nodes have empty signer) if currentSigner.signer != (common.Address{}) { - var err error - successionNumber, err = snap.GetSignerSuccessionNumber(currentSigner.signer) - if err != nil { - return err - } - if successionNumber == 0 { + if succession == 0 { <-time.After(delay) } } diff --git a/miner/worker.go b/miner/worker.go index 9a633a12f8..85fd7b72c7 100644 --- a/miner/worker.go +++ b/miner/worker.go @@ -973,7 +973,12 @@ func (w *worker) taskLoop() { w.pendingMu.Unlock() if err := w.engine.Seal(w.chain, task.block, task.state.Witness(), w.resultCh, stopCh); err != nil { - log.Warn("Block sealing failed", "err", err) + switch err.(type) { + case *bor.UnauthorizedSignerError: + log.Debug("Block sealing skipped (not in validator set)", "err", err) + default: + log.Warn("Block sealing failed", "err", err) + } w.pendingMu.Lock() delete(w.pendingTasks, sealHash) w.pendingMu.Unlock() @@ -1216,6 +1221,7 @@ func (w *worker) updateSnapshot(env *environment) { w.snapshotMu.Lock() defer w.snapshotMu.Unlock() + w.snapshotBlock = types.NewBlock( env.header, &types.Body{ diff --git a/miner/worker_test.go b/miner/worker_test.go index 8da774b8e4..ffe6986ac2 100644 --- a/miner/worker_test.go +++ b/miner/worker_test.go @@ -59,6 +59,57 @@ import ( borSpan "github.com/ethereum/go-ethereum/consensus/bor/heimdall/span" ) +// TestPendingStateNotStaleForNonValidator verifies that a Bor node whose signer +// is NOT in the active validator set still keeps its pending snapshot fresh. +// Regression test: previously, Prepare() returned UnauthorizedSignerError for +// non-validators, which caused prepareWork to fail and the snapshot to never +// update, leading to stale trie errors on "pending" RPC queries. +func TestPendingStateNotStaleForNonValidator(t *testing.T) { + chainConfig := *params.BorUnittestChainConfig + + engine, ctrl := getFakeBorFromConfig(t, &chainConfig) + defer engine.Close() + defer ctrl.Finish() + + // Set up the worker normally, then re-authorize with a key NOT in the + // validator set. newTestWorkerBackend authorizes with testBankAddress + // (which IS the validator), so we must override AFTER backend creation. + nonValidatorKey, _ := crypto.GenerateKey() + nonValidatorAddr := crypto.PubkeyToAddress(nonValidatorKey.PublicKey) + + db := rawdb.NewMemoryDatabase() + backend := newTestWorkerBackend(t, &chainConfig, engine, db) + w := newWorker(DefaultTestConfig(), &chainConfig, engine, backend, new(event.TypeMux), nil, false, false) + defer w.close() + + // Override the signer to one NOT in the validator set. + engine.(*bor.Bor).Authorize(nonValidatorAddr, func(account accounts.Account, s string, data []byte) ([]byte, error) { + return crypto.Sign(crypto.Keccak256(data), nonValidatorKey) + }) + w.setEtherbase(nonValidatorAddr) + + // Start the worker. It will call commitWork which calls Prepare. + // Before the fix: Prepare fails with UnauthorizedSignerError, snapshot + // is never set, pending() returns nil. + // After the fix: Prepare succeeds (defaults succession to 0), snapshot + // is updated, pending() returns valid state. + w.start() + + // Give the worker time to process the start event and run commitWork. + time.Sleep(1 * time.Second) + + pendingBlock, _, pendingState := w.pending() + require.NotNil(t, pendingBlock, "pending block should not be nil for non-validator node") + require.NotNil(t, pendingState, "pending state should not be nil for non-validator node") + + // The pending state must be readable without errors (not a stale trie). + balance := pendingState.GetBalance(testBankAddress) + require.False(t, balance.IsZero(), + "pending state balance for funded account should not be zero (would indicate stale trie)") + require.NoError(t, pendingState.Error(), + "pending state should have no database errors") +} + // nolint : paralleltest func TestGenerateBlockAndImportClique(t *testing.T) { testGenerateBlockAndImport(t, true, false) From 817694c3f1e2c57f5b114ca8f70ad07fb7a7188c Mon Sep 17 00:00:00 2001 From: Jerry Date: Mon, 13 Apr 2026 08:30:54 -0700 Subject: [PATCH 2/2] fix linter --- miner/worker.go | 1 - 1 file changed, 1 deletion(-) diff --git a/miner/worker.go b/miner/worker.go index 85fd7b72c7..33a5094b0b 100644 --- a/miner/worker.go +++ b/miner/worker.go @@ -1221,7 +1221,6 @@ func (w *worker) updateSnapshot(env *environment) { w.snapshotMu.Lock() defer w.snapshotMu.Unlock() - w.snapshotBlock = types.NewBlock( env.header, &types.Body{