execution/chain: emit chain.Config ChainID and TTD as unquoted JSON numbers#21282
execution/chain: emit chain.Config ChainID and TTD as unquoted JSON numbers#21282yperbasis wants to merge 2 commits into
Conversation
…umbers #21119 migrated chain.Config.ChainID and chain.Config.TerminalTotalDifficulty from *big.Int to *uint256.Int. uint256.Int.UnmarshalJSON accepts both quoted and unquoted decimals, so Erigon reads either form. But its MarshalJSON emits a quoted string ("1"), and math/big.Int.UnmarshalJSON rejects that form. Release/3.4 (still on *big.Int for these fields) therefore panics at startup on any chaindata written by 3.5+: panic: math/big: cannot unmarshal "\"1\"" into a *big.Int Add a Config.MarshalJSON that emits ChainID and TerminalTotalDifficulty as unquoted JSON numbers via a small wrapper type, restoring the 3.4-compatible wire format. Read paths are unchanged. Limitation: chaindata already written by 3.5+ before this fix is not retroactively healed — it still contains the quoted-string form. Downgrades to 3.4 are only viable after a future 3.5+ run that rewrites the config.
There was a problem hiding this comment.
Pull request overview
Restores Erigon 3.4 wire compatibility for the chain.Config JSON encoding by adding a custom MarshalJSON that emits ChainID and TerminalTotalDifficulty as unquoted JSON numbers (matching big.Int's encoding) rather than uint256.Int's default quoted-decimal string form. Without this, chaindata written by 3.5+ panics when read back by 3.4's *big.Int-typed parser.
Changes:
- Introduce
legacyJSONUint256wrapper that marshals a*uint256.Intas an unquoted decimal (ornullwhen nil). - Add
Config.MarshalJSONthat re-emitschainId/terminalTotalDifficultythrough the wrapper via an anonymous-struct-with-embedded-alias pattern (field shadowing on JSON tags) so all other fields continue to be encoded by the default encoder. - Add tests pinning the unquoted-number wire format, verifying
*big.Intdecoding compatibility, the uint256 round-trip, and nil-field behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| execution/chain/chain_config.go | Adds legacyJSONUint256 and Config.MarshalJSON to emit ChainID/TTD as unquoted decimals for 3.4 read compatibility. |
| execution/chain/chain_config_test.go | New tests TestConfigJSONLegacyCompat and TestConfigJSONNilUint256Fields covering wire format, legacy *big.Int decode, round-trip, and nil/omitempty. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…fNotExist The chain.Config MarshalJSON fix only takes effect when something rewrites the config. WriteChainConfig runs unconditionally on every startup, so the genesis-hash-keyed ConfigTable entry is healed automatically. But the kv.GenesisKey-keyed Genesis blob is only ever written once (on first init), so on existing chaindata it stays in the legacy quoted-string form and the next 3.4 read still panics at ReadGenesis. Detect the legacy form (chainId/terminalTotalDifficulty as quoted strings) inside WriteGenesisIfNotExist and re-marshal in place. The rewrite is one-shot: once the unquoted form is on disk, subsequent calls short-circuit. Tested by hand-crafting a legacy blob, running WriteGenesisIfNotExist, and asserting the stored bytes are now in big.Int-compatible form.
|
Follow-up: extended the fix to also migrate the kv.GenesisKey blob in WriteGenesisIfNotExist. Without this, MarshalJSON only heals the genesis-hash-keyed ConfigTable entry (rewritten on every startup), but the kv.GenesisKey-keyed Genesis JSON stays in the legacy uint256-quoted form forever and the next 3.4 read still panics at ReadGenesis. Verified by reproducing the panic against a main-written datadir, applying the patch, and confirming 3.4 then boots cleanly. |
| if len(existing) > 0 { | ||
| // If the stored blob predates the chain.Config JSON-compat fix, the | ||
| // ChainID / TerminalTotalDifficulty fields are quoted strings, which | ||
| // older binaries (still on *big.Int for those fields) reject. Detect | ||
| // and re-marshal in place so subsequent downgrades can read the DB. | ||
| if needsLegacyGenesisRewrite(existing) { | ||
| var stored types.Genesis | ||
| if err := json.Unmarshal(existing, &stored); err != nil { | ||
| return fmt.Errorf("rewrite legacy genesis JSON: unmarshal: %w", err) | ||
| } | ||
| val, err := json.Marshal(&stored) | ||
| if err != nil { | ||
| return fmt.Errorf("rewrite legacy genesis JSON: marshal: %w", err) | ||
| } | ||
| return db.Put(kv.ConfigTable, kv.GenesisKey, val) | ||
| } |
| return bytes.Contains(blob, []byte(`"chainId":"`)) || | ||
| bytes.Contains(blob, []byte(`"terminalTotalDifficulty":"`)) | ||
| } | ||
|
|
Summary
Config.MarshalJSONthat emitsChainIDandTerminalTotalDifficultyas unquoted JSON numbers via a smalllegacyJSONUint256wrapper, restoring the 3.4-compatible wire format.uint256.Int.UnmarshalJSONalready accepts both quoted and unquoted decimals.Why
#21119 migrated
chain.Config.ChainIDandchain.Config.TerminalTotalDifficultyfrom*big.Intto*uint256.Int.uint256.Int.MarshalJSONemits a quoted decimal (\"1\"), andmath/big.Int.UnmarshalJSONrejects that form. Release/3.4, which still stores these as*big.Int, therefore panics at startup on any chaindata written by 3.5+:(Reproduced locally: pointing a fresh
release/3.4binary at a datadir last touched bymainaborts inWriteChainConfig's read-back path before the node starts.)Limitation
Chaindata already written by 3.5+ before this fix is not retroactively healed — it still contains the quoted-string form. Downgrades to 3.4 are only viable after a 3.5+ run with this fix re-writes the chain config.
Test plan
TestConfigJSONLegacyCompatpins the unquoted-number wire format and verifies the bytes decode into a*big.Int-typed struct (the 3.4 shape).TestConfigJSONNilUint256Fieldscovers nil/omitempty.make lintclean.make erigon integrationbuilds.go test -short ./execution/chain/... ./db/rawdb/... ./execution/state/genesiswrite/...passes.🤖 Generated with Claude Code