Repository navigation
params: CheckConfigForkOrder misses a fork that is skipped rather than reordered #2771
Description
Summary
ChainConfig.CheckConfigForkOrder is the only validation on how forks are scheduled: genesis load calls it (core/genesis.go:854, core/genesis_load.go, core/genesis_setup.go). It compares a fork against the previous one only when both are set and adjacent in the order list (params/config.go:542-554), and every slot of that list is optional: alwaysOptionalChainConfigForkOrderField returns a constant true (params/config_backfill.go:319) and all 34 entries the generator emits carry it.
So the check rejects a fork that is declared too early, but not a fork that is not declared at all. A config can schedule a late fork while leaving an earlier one unset, pass validation, and start a node on it.
Why that matters
The rules end up with two independent sources of truth:
- The instruction set and its gas metering follow the highest active fork, by construction:
NewEVMselects the table with a cascade (core/vm/evm.go:147-158) and each level composes the one below it (core/vm/jump_table.go:103-136). - Every hand-written check reads the per-fork flags on
Rules(IsEIP158,IsEIP1559, ...), each computed on its own.
Leaving an earlier fork unset makes the two disagree: the VM runs the later fork's table while the flags still report that the earlier fork has not happened.
Reproduction
With ChainID and Ethash set and every other fork left unset:
cfg := ¶ms.ChainConfig{ChainID: big.NewInt(999999), Ethash: new(params.EthashConfig)}
cfg.OsakaBlock = big.NewInt(0)
cfg.CheckConfigForkOrder() // nil
rules := cfg.Rules(big.NewInt(0))
// rules.IsOsaka == true, rules.IsEIP1559 == false, rules.IsEIP158 == false
cfg.CancunBlock = big.NewInt(0) // in a fresh config
cfg.CheckConfigForkOrder() // nilOnly the reversed case is caught. OsakaBlock = 0 together with EIP1559Block = 100 returns wrong fork switch order: EIP1559Block 100 > OsakaBlock 0, while leaving EIP1559Block unset returns nil.
Measured on dev-upgrade (22a40a58f) and on the current tips (feat-amsterdam-fork-switch cbb82e996, fix-eip7954-amsterdam da3283555):
| config | validation | 1 MiB initcode | 1 MiB deployment |
|---|---|---|---|
OsakaBlock = 0 on dev-upgrade |
accepted | rejected at 65536 | rejected at 32768 |
OsakaBlock = 0 on da3283555 |
accepted | rejected at 49152 | rejected at 24576 |
CancunBlock = 0 |
accepted | accepted | accepted |
PragueBlock = 0 |
accepted | accepted | accepted |
Symptoms
- The code and initcode size limits are skipped entirely.
core/vm/common.gohangs the base limit onIsEIP1559andIsEIP158, so a chain whose highest fork is Cancun or Prague puts no upper bound on contract size at all. This is true ondev-upgradeand still true after fix(core,params): move the eip-7954 code size raise to amsterdam #33832 #35217 #2757, which only names Osaka in the fallback. - EIP-3541 is not enforced.
core/vm/evm.go:540gates the rejection of code starting with0xEFonIsEIP1559, so such a deployment is accepted while the Osaka table is the one executing. - Empty-account deletion follows the wrong rule.
IsEIP158is what decidesdeleteEmptyObjects:core/blockchain.go:2205,core/block_validator.go:124and theFinalizeof all four engines (consensus/XDPoS/engines/engine_v1/engine.go:877,consensus/XDPoS/engines/engine_v2/engine.go:497,consensus/clique/clique.go:590,consensus/ethash/consensus.go:440), withcore/vm/evm.go:252,503andcore/vm/gas_table.go:384,481reading it as well. The state root of such a chain is computed by rules no other EVM chain uses. - The fee market stays pre-EIP-1559.
core/state_transition.go:338,513,597,core/txpool/validation.go:81(legacy transactions only),core/types/transaction_signing.go:46andconsensus/misc/eip1559/eip1559.go:32all readIsEIP1559.
params/config_forks.go:219's GasTable reads the same flags but has no production consumer, so it is not part of the impact.
Upstream
go-ethereum does not have this hole, though it does not close it inside the comparison either: shanghaiTime is declared without optional (params/config.go:826) and the loop has a branch that rejects a fork which is set while the previous non-optional fork is not (params/config.go:842-849, added by ethereum#20169). Since cancun, prague, osaka and amsterdam are optional there, scheduling Osaka upstream necessarily means London and everything before it is declared, so the states above are unreachable.
In this repository every slot is optional, so that branch cannot fire, and the only existence constraint today is the OsakaBlock -> AmsterdamBlock special case added by #2756.
Impact
- Custom genesis only: no live chain is affected today. On every built-in network the flags that are read outside the instruction-table cascade - Homestead, EIP-150, EIP-155, EIP-158, Byzantium, EIP-1559 - are declared, and Petersburg and Istanbul resolve through
TIPXDCXCancellationFeeBlock(params/config_forks.go:60-81). Note that the built-in XDC configs do leaveconstantinopleBlock,petersburgBlockandistanbulBlockunset as fields (params/config_networks.go:302-303,:405-406,:467-468,:529-530), so a fix cannot key on the raw fields alone. - When it does happen it is silent, and it is a one-way door: once a chain runs under such a rule set, the blocks it produced cannot be reinterpreted later.
Notes
Found while reviewing the EIP-7954 work. The size-limit half for Osaka is fixed in #2757; the configuration-side hole and the Cancun/Prague size gap are not. Related: #2730.
Activity
fix(params): reject chain configs that skip a fork their rules depend…
fix(params): reject chain configs that skip a fork their rules depend…
fix(params): reject chain configs that skip a fork their rules depend…
fix(params): reject chain configs that skip a fork their rules depend…
fix(params): reject chain configs that skip a fork their rules depend…
fix(params): reject chain configs that skip a fork their rules depend…
fix(params): reject chain configs that skip a fork their rules depend…