Skip to content

params: CheckConfigForkOrder misses a fork that is skipped rather than reordered #2771

Open

Description

@gzliudan

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: NewEVM selects 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 := &params.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()      // nil

Only 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.go hangs the base limit on IsEIP1559 and IsEIP158, so a chain whose highest fork is Cancun or Prague puts no upper bound on contract size at all. This is true on dev-upgrade and 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:540 gates the rejection of code starting with 0xEF on IsEIP1559, so such a deployment is accepted while the Osaka table is the one executing.
  • Empty-account deletion follows the wrong rule. IsEIP158 is what decides deleteEmptyObjects: core/blockchain.go:2205, core/block_validator.go:124 and the Finalize of 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), with core/vm/evm.go:252,503 and core/vm/gas_table.go:384,481 reading 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:46 and consensus/misc/eip1559/eip1559.go:32 all read IsEIP1559.

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 leave constantinopleBlock, petersburgBlock and istanbulBlock unset 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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions