diff --git a/cmd/geth/main.go b/cmd/geth/main.go index 14e3a549cb..0108867f5a 100644 --- a/cmd/geth/main.go +++ b/cmd/geth/main.go @@ -174,6 +174,7 @@ var ( utils.RPCGlobalEVMTimeoutFlag, utils.RPCGlobalTxFeeCapFlag, utils.RPCGlobalLogQueryLimit, + utils.EngineMaxReorgDepthFlag, utils.AllowUnprotectedTxs, utils.BatchRequestLimit, utils.BatchResponseMaxSize, diff --git a/cmd/utils/flags.go b/cmd/utils/flags.go index fd9893fe81..4082717930 100644 --- a/cmd/utils/flags.go +++ b/cmd/utils/flags.go @@ -679,6 +679,12 @@ var ( Value: ethconfig.Defaults.RangeLimit, Category: flags.APICategory, } + EngineMaxReorgDepthFlag = &cli.Uint64Flag{ + Name: "engine.maxreorgdepth", + Usage: "Maximum depth the chain head can be rewound to a canonical ancestor via engine forkchoiceUpdated (0 = no limit)", + Value: ethconfig.Defaults.EngineMaxReorgDepth, + Category: flags.APICategory, + } // Authenticated RPC HTTP settings AuthListenFlag = &cli.StringFlag{ Name: "authrpc.addr", @@ -1915,6 +1921,14 @@ func SetEthConfig(ctx *cli.Context, stack *node.Node, cfg *ethconfig.Config) { if ctx.IsSet(RPCGlobalTxFeeCapFlag.Name) { cfg.RPCTxFeeCap = ctx.Float64(RPCGlobalTxFeeCapFlag.Name) } + if ctx.IsSet(EngineMaxReorgDepthFlag.Name) { + cfg.EngineMaxReorgDepth = ctx.Uint64(EngineMaxReorgDepthFlag.Name) + } + if cfg.EngineMaxReorgDepth != 0 { + log.Info("Engine API maximum reorg depth", "depth", cfg.EngineMaxReorgDepth) + } else { + log.Info("Engine API reorg depth limit disabled") + } if ctx.Bool(NoDiscoverFlag.Name) { cfg.EthDiscoveryURLs, cfg.SnapDiscoveryURLs = []string{}, []string{} } else if ctx.IsSet(DNSDiscoveryFlag.Name) { diff --git a/eth/backend.go b/eth/backend.go index 72bda3191f..c880e75f27 100644 --- a/eth/backend.go +++ b/eth/backend.go @@ -449,6 +449,7 @@ func (s *Ethereum) Downloader() *downloader.Downloader { return s.handler.downlo func (s *Ethereum) Synced() bool { return s.handler.synced.Load() } func (s *Ethereum) SetSynced() { s.handler.enableSyncedFeatures() } func (s *Ethereum) ArchiveMode() bool { return s.config.NoPruning } +func (s *Ethereum) EngineMaxReorgDepth() uint64 { return s.config.EngineMaxReorgDepth } // Protocols returns all the currently configured // network protocols to start. diff --git a/eth/catalyst/api.go b/eth/catalyst/api.go index bc8c300418..d66f96148d 100644 --- a/eth/catalyst/api.go +++ b/eth/catalyst/api.go @@ -82,14 +82,15 @@ const ( // beaconUpdateWarnFrequency is the frequency at which to warn the user that // the beacon client is offline. beaconUpdateWarnFrequency = 5 * time.Minute - - // maxReorgDepth is the maximum reorg depth accepted via forkchoiceUpdated. - maxReorgDepth = 32 ) type ConsensusAPI struct { eth *eth.Ethereum + // maxReorgDepth is the maximum reorg depth accepted via forkchoiceUpdated + // (0 = no limit). Configured via ethconfig.Config.EngineMaxReorgDepth. + maxReorgDepth uint64 + remoteBlocks *headerQueue // Cache of remote payloads received localBlocks *payloadQueue // Cache of local payloads generated @@ -145,6 +146,7 @@ func newConsensusAPIWithoutHeartbeat(eth *eth.Ethereum) *ConsensusAPI { } api := &ConsensusAPI{ eth: eth, + maxReorgDepth: eth.EngineMaxReorgDepth(), remoteBlocks: newHeaderQueue(), localBlocks: newPayloadQueue(), invalidBlocksHits: make(map[common.Hash]int), @@ -330,9 +332,9 @@ func (api *ConsensusAPI) forkchoiceUpdated(ctx context.Context, update engine.Fo return valid(nil), nil } depth := api.eth.BlockChain().CurrentBlock().Number.Uint64() - block.NumberU64() - if depth >= maxReorgDepth { + if api.maxReorgDepth > 0 && depth >= api.maxReorgDepth { log.Warn("Refusing too deep reorg", "depth", depth, "head", update.HeadBlockHash) - return engine.STATUS_INVALID, engine.TooDeepReorg.With(fmt.Errorf("reorg depth %d exceeds limit %d", depth, maxReorgDepth)) + return engine.STATUS_INVALID, engine.TooDeepReorg.With(fmt.Errorf("reorg depth %d exceeds limit %d", depth, api.maxReorgDepth)) } if !api.eth.Synced() { log.Info("Ignoring beacon update to old head while syncing", "number", block.NumberU64(), "hash", update.HeadBlockHash) diff --git a/eth/catalyst/api_test.go b/eth/catalyst/api_test.go index 783dda59b0..2d3c4929ea 100644 --- a/eth/catalyst/api_test.go +++ b/eth/catalyst/api_test.go @@ -427,8 +427,9 @@ func TestEth2DeepReorg(t *testing.T) { */ } -// startEthService creates a full node instance for testing. -func startEthService(t testing.TB, genesis *core.Genesis, blocks []*types.Block) (*node.Node, *eth.Ethereum) { +// startEthService creates a full node instance for testing. The default test +// configuration can be adjusted through optional modifier functions. +func startEthService(t testing.TB, genesis *core.Genesis, blocks []*types.Block, mods ...func(*ethconfig.Config)) (*node.Node, *eth.Ethereum) { t.Helper() n, err := node.New(&node.Config{ @@ -449,6 +450,9 @@ func startEthService(t testing.TB, genesis *core.Genesis, blocks []*types.Block) TrieCleanCache: 256, Miner: miner.DefaultConfig, } + for _, mod := range mods { + mod(ethcfg) + } ethservice, err := eth.New(n, ethcfg) if err != nil { t.Fatal("can't create eth service:", err) @@ -468,6 +472,54 @@ func startEthService(t testing.TB, genesis *core.Genesis, blocks []*types.Block) return n, ethservice } +// TestForkchoiceUpdatedReorgDepthLimit tests that forkchoiceUpdated refuses to +// rewind the chain head to a canonical ancestor deeper than the configured +// EngineMaxReorgDepth, and that the limit can be lifted via the configuration. +func TestForkchoiceUpdatedReorgDepthLimit(t *testing.T) { + genesis, blocks := generateMergeChain(10, true) + + t.Run("limited", func(t *testing.T) { + n, ethservice := startEthService(t, genesis, blocks, func(cfg *ethconfig.Config) { + cfg.EngineMaxReorgDepth = 5 + }) + defer n.Close() + + api := newConsensusAPIWithoutHeartbeat(ethservice) + + // Rewinding the head a few blocks within the limit is accepted. + shallow := engine.ForkchoiceStateV1{HeadBlockHash: blocks[6].Hash()} + if _, err := api.ForkchoiceUpdatedV1(context.Background(), shallow, nil); err != nil { + t.Fatalf("rewind within reorg depth limit failed: %v", err) + } + if head := ethservice.BlockChain().CurrentBlock().Number.Uint64(); head != blocks[6].NumberU64() { + t.Fatalf("chain head not rewound: have %d, want %d", head, blocks[6].NumberU64()) + } + // Rewinding beyond the limit is refused. + deep := engine.ForkchoiceStateV1{HeadBlockHash: genesis.ToBlock().Hash()} + _, err := api.ForkchoiceUpdatedV1(context.Background(), deep, nil) + var apiErr *engine.EngineAPIError + if !errors.As(err, &apiErr) || apiErr.ErrorCode() != engine.TooDeepReorg.ErrorCode() { + t.Fatalf("rewind beyond reorg depth limit: have error %v, want %v", err, engine.TooDeepReorg) + } + }) + t.Run("unlimited", func(t *testing.T) { + n, ethservice := startEthService(t, genesis, blocks, func(cfg *ethconfig.Config) { + cfg.EngineMaxReorgDepth = 0 // no limit + }) + defer n.Close() + + api := newConsensusAPIWithoutHeartbeat(ethservice) + + update := engine.ForkchoiceStateV1{HeadBlockHash: genesis.ToBlock().Hash()} + if _, err := api.ForkchoiceUpdatedV1(context.Background(), update, nil); err != nil { + t.Fatalf("rewind with disabled reorg depth limit failed: %v", err) + } + if head := ethservice.BlockChain().CurrentBlock().Number.Uint64(); head != 0 { + t.Fatalf("chain head not rewound to genesis: have %d, want 0", head) + } + }) +} + func TestFullAPI(t *testing.T) { genesis, preMergeBlocks := generateMergeChain(10, false) n, ethservice := startEthService(t, genesis, preMergeBlocks) diff --git a/eth/ethconfig/config.go b/eth/ethconfig/config.go index 378bb64a1e..c6f2fadde9 100644 --- a/eth/ethconfig/config.go +++ b/eth/ethconfig/config.go @@ -75,6 +75,7 @@ var Defaults = Config{ RPCEVMTimeout: 5 * time.Second, GPO: FullNodeGPO, RPCTxFeeCap: 1, // 1 ether + EngineMaxReorgDepth: 32, TxSyncDefaultTimeout: 20 * time.Second, TxSyncMaxTimeout: 1 * time.Minute, SlowBlockThreshold: -1, // Disabled by default; set via --debug.logslowblock flag @@ -203,6 +204,11 @@ type Config struct { // send-transaction variants. The unit is ether. RPCTxFeeCap float64 + // EngineMaxReorgDepth is the maximum depth the chain head can be rewound + // to an already-canonical ancestor by engine API forkchoiceUpdated calls + // (0 = no limit). + EngineMaxReorgDepth uint64 `toml:",omitempty"` + // OverrideOsaka (TODO: remove after the fork) OverrideOsaka *uint64 `toml:",omitempty"` diff --git a/eth/ethconfig/gen_config.go b/eth/ethconfig/gen_config.go index b24cde8a6a..167c93e685 100644 --- a/eth/ethconfig/gen_config.go +++ b/eth/ethconfig/gen_config.go @@ -63,6 +63,7 @@ func (c Config) MarshalTOML() (interface{}, error) { RPCGasCap uint64 RPCEVMTimeout time.Duration RPCTxFeeCap float64 + EngineMaxReorgDepth uint64 `toml:",omitempty"` OverrideOsaka *uint64 `toml:",omitempty"` OverrideAmsterdam *uint64 `toml:",omitempty"` OverrideBPO1 *uint64 `toml:",omitempty"` @@ -119,6 +120,7 @@ func (c Config) MarshalTOML() (interface{}, error) { enc.RPCGasCap = c.RPCGasCap enc.RPCEVMTimeout = c.RPCEVMTimeout enc.RPCTxFeeCap = c.RPCTxFeeCap + enc.EngineMaxReorgDepth = c.EngineMaxReorgDepth enc.OverrideOsaka = c.OverrideOsaka enc.OverrideAmsterdam = c.OverrideAmsterdam enc.OverrideBPO1 = c.OverrideBPO1 @@ -179,6 +181,7 @@ func (c *Config) UnmarshalTOML(unmarshal func(interface{}) error) error { RPCGasCap *uint64 RPCEVMTimeout *time.Duration RPCTxFeeCap *float64 + EngineMaxReorgDepth *uint64 `toml:",omitempty"` OverrideOsaka *uint64 `toml:",omitempty"` OverrideAmsterdam *uint64 `toml:",omitempty"` OverrideBPO1 *uint64 `toml:",omitempty"` @@ -330,6 +333,9 @@ func (c *Config) UnmarshalTOML(unmarshal func(interface{}) error) error { if dec.RPCTxFeeCap != nil { c.RPCTxFeeCap = *dec.RPCTxFeeCap } + if dec.EngineMaxReorgDepth != nil { + c.EngineMaxReorgDepth = *dec.EngineMaxReorgDepth + } if dec.OverrideOsaka != nil { c.OverrideOsaka = dec.OverrideOsaka }