Skip to content

feat(chainspec): binary-authoritative hardfork schedule for gravity chains#763

Open
nekomoto911 wants to merge 2 commits into
Galxe:mainfrom
nekomoto911:d1-sdk-side-hardfork-override
Open

feat(chainspec): binary-authoritative hardfork schedule for gravity chains#763
nekomoto911 wants to merge 2 commits into
Galxe:mainfrom
nekomoto911:d1-sdk-side-hardfork-override

Conversation

@nekomoto911

Copy link
Copy Markdown
Contributor

Summary

Make the gravity-sdk binary the authoritative source of hardfork
activation for the chain ids it owns (today: gravity mainnet
127001). Introduces GravityChainSpecParser in
bin/gravity_node/src/chainspec.rs that wraps upstream
EthereumChainSpecParser and applies a compile-time override table to
the alloy_genesis::Genesis value on the file / inline-JSON fallthrough
path, before From<Genesis> for ChainSpec runs in greth.

Initial table on GRAVITY_MAINNET_CHAIN_ID = 127001:

  • prague_time: Some(1_782_709_200) — governance-pinned to the value in gravity-mainet-gitops/genesis.json.
  • alpha_time: None — forward-defence: no timestamp has been chosen onchain yet; operator-supplied alphaTime in mainnet genesis is dropped before EL or CL reads it.

For any chain id not in the table, the parser is a no-op and current genesis-driven behaviour is unchanged. Named chains (mainnet/sepolia/holesky/hoodi/dev) delegate to upstream unchanged — none of those are gravity-owned.

Why mutate Genesis before construction?

Two big properties fall out of running the override at parse time:

  1. The constructed Arc<ChainSpec> is correct from t=0. genesis_header, fork_id, fork_filter and the inner SealedHeader::hash OnceLock all derive from the post-override hardforks, with no post-construction mutation, no Arc::get_mut/Arc::make_mut, no recompute path.
  2. EL and CL see one consistent view. The CL side (gaptos ConsensusHardforks::from_genesis_extra_fields, bin/gravity_node/src/main.rs:107-118) reads from chain_spec.genesis.config.extra_fields. Because we mutate extra_fields at parse time, a single alpha_time entry in the table forward-defends both layers — no parallel CL table, no CL-side code change. When governance picks a timestamp for Alpha, flipping alpha_time: None to Some(ts) pins both layers to the same authoritative value.

How does it plug in?

The Cli<C> framework was already generic over C: ChainSpecParser with EthereumChainSpecParser only as the default. Swap the default + three type annotations:

-pub(crate) struct Cli<C: ChainSpecParser = EthereumChainSpecParser, ...>
+pub(crate) struct Cli<C: ChainSpecParser = GravityChainSpecParser, ...>

Also fixes a stray hardcoded FnLauncher::new::<EthereumChainSpecParser, _> inside the generic impl that should have been FnLauncher::new::<C, _>.

Test plan

  • RUSTFLAGS='--cfg tokio_unstable' cargo test -p gravity_node --bin gravity_node chainspec:: → 12/12 pass
    • Named-chain delegation (mainnet/sepolia/holesky/hoodi/dev all parse)
    • Prague override (genesis supplies a different value / omits it / testnet passthrough)
    • Shanghai/Cancun untouched
    • Alpha forward-defence (genesis supplies alphaTime / omits it / testnet passthrough)
    • Post-override chain_spec.genesis reflects the table (unified-view invariant)
    • From<Genesis> is idempotent on the same parsed JSON
    • ChainSpecBuilder::mainnet() bypass tripwire (currently safe: chain id 1, not 127001)
    • Governance pin tripwire: Prague timestamp == 1_782_709_200
  • cargo fmt -p gravity_node --check
  • CI

Relationship to gravity-reth PR #369

This is the "Option D-1" alternative to Galxe/gravity-reth#369, which placed the same override inside greth's impl From<Genesis> for ChainSpec. D-1 trades:

Gain:

  • One repo, one table, naturally unifies EL+CL — no greth fork burden, no parallel CL table.
  • Fixes a latent admin_nodeInfo RPC inconsistency in feat: use pending txn as iterator #369 (where operator-supplied pragueTime wins over the override in the spread at crates/rpc/rpc/src/admin.rs:114-141).

Lose:

  • From<Genesis> is no longer the chokepoint. Any greth-internal code path that constructs a ChainSpec from a Genesis on chainId 127001 without going through the sdk parser silently skips the override. Today no such production path exists; the tripwire test covers the most likely accidental case.

The two PRs are mutually exclusive — only one should land. Discussion welcome.

Maintenance

Adding or modifying an entry in the table for a shipped chain id is a consensus change. The PR doing so must reference the onchain governance / coordination record that authorised the timestamp. Existing entries must never be removed; promoting None to Some(ts) is the only allowed transition.

…hains

Add `GravityChainSpecParser` (gravity_node/src/chainspec.rs) wrapping
upstream `EthereumChainSpecParser`. On the file / inline-JSON fallthrough
path, the parser mutates the `alloy_genesis::Genesis` struct in place
BEFORE `From<Genesis> for ChainSpec` runs in greth, so:

- The constructed `Arc<ChainSpec>` (including derived `genesis_header`,
  `fork_id`, `fork_filter`) reflects the binary's authoritative values
  from the moment it is built — no post-construction mutation, no
  `Arc::get_mut`, no `OnceLock` timing pain.
- The CL side (gaptos `ConsensusHardforks::from_genesis_extra_fields`,
  main.rs:107-118) reads from the SAME mutated `extra_fields`, so EL and
  CL see one consistent view from a single source of truth. No CL-side
  code change needed.

Entry semantics per gated fork:
  Some(ts) → write into the genesis field, overriding any operator value
  None     → remove the operator-supplied value if present; fork inactive

Initial table covers only `GRAVITY_MAINNET_CHAIN_ID = 127001`:
  prague_time: Some(1_782_709_200)   // governance-pinned to
                                     // gravity-mainet-gitops genesis.json
  alpha_time:  None                  // forward-defence: no ts chosen
                                     // onchain yet; operator-supplied
                                     // alphaTime in mainnet genesis is
                                     // dropped before EL or CL reads it

Named chains (`mainnet/sepolia/holesky/hoodi/dev`) delegate to upstream
unchanged — none of those LazyLock statics are gravity-owned chain ids,
so delegation is safe and required for `--chain dev` etc. to keep working.

The `Cli<C>` framework was already generic over `C: ChainSpecParser` with
`EthereumChainSpecParser` only as the default — swapping to
`GravityChainSpecParser` is a one-line default-param change plus three
type-annotation swaps. Also fixes a stray hardcoded
`FnLauncher::new::<EthereumChainSpecParser, _>` inside the generic impl
that should have been `FnLauncher::new::<C, _>`.

12 unit tests cover: named-chain delegation, Prague override (supply /
omit / testnet passthrough), Alpha forward-defence (supply / omit /
testnet passthrough), post-override genesis preservation, idempotency,
ChainSpecBuilder bypass tripwire, and the consensus-affecting Prague
timestamp pin tripwire.

Discussion: this PR is the "Option D-1" alternative to greth PR Galxe#369,
which applied the override inside `From<Genesis> for ChainSpec`. D-1
moves the override to gravity-sdk (one repo) and naturally unifies CL+EL
under one table at the cost of losing greth's `From<Genesis>` chokepoint
(any greth-internal code path that constructs ChainSpec from Genesis on
chainId 127001 bypasses — today none exist in production).
@nekomoto911

Copy link
Copy Markdown
Contributor Author

Tracked from review:

Neither is a blocker for this PR as it stands today (mainnet extraFields: {} makes both no-ops), but both should be resolved before promoting alpha_time off None.

Address review feedback from gravity-reth Galxe#369: the override is silent
when it differs from genesis, which papers over root causes like a
genesis regen that drops a hardfork field.

Refactor `apply_overrides` to return a `Vec<OverrideEvent>` listing
every fork whose effective condition was actually changed by the
override (i.e. `genesis_value != table_value`). `parse()` iterates the
list and writes one `WARN [GravityChainSpecParser] ...` line per event
to stderr — including the chain id, the fork name, the genesis-supplied
value (or `<unset>`), and the binary-forced value (or `<unset>`).

`tracing` is intentionally NOT used here: clap calls `parse()` inside
`Cli::parse()`, before `main()` reaches `cli.run()` and installs the
subscriber. `eprintln!` lands in stderr, which production `start.sh`
redirects into `${LOG_DIR}/debug.log` — operators can grep for
`WARN [GravityChainSpecParser]` to find any genesis/binary disagreement
at startup.

If `genesis_value == table_value` on every gated fork, the override is
fully silent — no spurious noise on a correctly-configured node.

Adds 7 new unit tests (19 total in `chainspec::tests`):
- both forks disagree → both events fire, in declared table order
- single-fork mismatch (genesis differs / genesis omits) → one event
- genesis already matches table → silent
- chain id not in HARDCODED → entirely silent
- stderr line format pinned: `WARN [GravityChainSpecParser]` prefix,
  `chainId <id>`, fork name, raw values, `<unset>` for None
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant