ethereum/go-ethereum · error

invalid fork, simulated beacon needs to be started post-merg

Error message

invalid fork, simulated beacon needs to be started post-merge

What it means

payloadVersion maps the chain's latest fork at a given time to an engine-API payload version. The simulated beacon (dev mode) can only produce post-merge payloads (PayloadV2/V3/V4); if the latest active fork is anything pre-Paris (or an unknown fork), the switch falls through and the function panics because no merge payload format exists for that state.

Source

Thrown at eth/catalyst/simulated_beacon.go:114

	feeRecipient     common.Address
	feeRecipientLock sync.Mutex // lock gates concurrent access to the feeRecipient

	engineAPI          *ConsensusAPI
	curForkchoiceState engine.ForkchoiceStateV1
	lastBlockTime      uint64
}

func payloadVersion(config *params.ChainConfig, time uint64) engine.PayloadVersion {
	switch config.LatestFork(time) {
	case forks.Amsterdam:
		return engine.PayloadV4
	case forks.Bogota, forks.BPO5, forks.BPO4, forks.BPO3, forks.BPO2, forks.BPO1, forks.Osaka, forks.Prague, forks.Cancun:
		return engine.PayloadV3
	case forks.Paris, forks.Shanghai:
		return engine.PayloadV2
	}
	panic("invalid fork, simulated beacon needs to be started post-merge")
}

// NewSimulatedBeacon constructs a new simulated beacon chain.
func NewSimulatedBeacon(period uint64, feeRecipient common.Address, eth *eth.Ethereum) (*SimulatedBeacon, error) {
	block := eth.BlockChain().CurrentBlock()
	current := engine.ForkchoiceStateV1{
		HeadBlockHash:      block.Hash(),
		SafeBlockHash:      block.Hash(),
		FinalizedBlockHash: block.Hash(),
	}
	engineAPI := newConsensusAPIWithoutHeartbeat(eth)

	// if genesis block, send forkchoiceUpdated to trigger transition to PoS
	if block.Number.Sign() == 0 {
		version := payloadVersion(eth.BlockChain().Config(), block.Time)
		if _, err := engineAPI.forkchoiceUpdated(context.Background(), current, nil, version, false); err != nil {
			return nil, err
		}

View on GitHub (pinned to 6bb0588ad8)

Solutions

  1. Use the default dev genesis (geth --dev without a custom genesis file), which is post-merge by construction.
  2. If a custom genesis is required, set terminalTotalDifficulty to 0 so the chain starts post-merge, and ensure all fork timestamps are at or before the dev chain's start time.
  3. Verify with 'geth attach' that eth.getBlock('latest').difficulty == 0 before enabling the simulated beacon.

Example fix

// before: datadir/genesis.json
"config": { "chainId": 1337, "homesteadBlock": 0 /* no TTD */ }

// after
"config": { "chainId": 1337, "homesteadBlock": 0, "terminalTotalDifficulty": 0, " parisBlock": 0 }
Defensive patterns

Strategy: validation

Validate before calling

// ensure the chain is post-merge before enabling --dev / simulated beacon
head := eth.BlockChain().CurrentBlock()
if head.Difficulty.Cmp(common.Big0) > 0 && cfg.TerminalTotalDifficulty == nil {
    return errors.New("simulated beacon requires a post-merge genesis; set terminalTotalDifficulty: 0")
}

Try / catch

Not applicable — fix the genesis; recovering would leave dev mode without a payload version.

Prevention

When it happens

Trigger: Starting geth with --dev (or --dev.period) against a genesis whose merge (Paris/terminal total difficulty) is not active at the current block/time — e.g. TTD or mergeBlock set in the future, or a custom pre-merge genesis — so config.LatestFork(time) returns a pre-Paris fork.

Common situations: Custom dev genesis JSON with pre-merge fork settings or unset merge fields; using a copied mainnet genesis with a future Shanghai timestamp while the simulated beacon starts at genesis time; version drift where a fork enum is not covered by the switch.

Related errors


AI-assisted analysis of ethereum/go-ethereum@6bb0588ad8 (2026-08-15). Data as JSON: /api/errors/ea232c9d4ea3db2a. Report an issue: GitHub.