From c7da5a2328a0214785297a4cb121f09e4e489c46 Mon Sep 17 00:00:00 2001 From: Karl Bartel Date: Mon, 13 Jul 2026 16:28:31 +0200 Subject: [PATCH] op-node,op-chain-ops: add Upgrade 18 (CGT v2) activation fork plumbing Provisional activation trigger for the CGT v2 predeploy migration (celo-blockchain-planning#1410). The migration itself is an irregular state transition performed entirely by the execution layer; the op-node only needs to carry the schedule and expose the predicate. Upgrade18 is registered as an *optional* fork (forks.AllOpt, like PectraBlobSchedule) rather than a mainline one: it is a one-shot migration trigger for the two live Celo networks, so it must not be scheduled implicitly by ActivateAtGenesis/ActivateForkAtOffset chains on unrelated chains, has no allocs mode, and does not belong in the proof-test fork matrices. Cel2Time set the same precedent. - forks.go: Upgrade18 name in AllOpt. Via OverridableForks this also provides an --override.upgrade18 CLI flag / env var for free. - rollup.Config: Upgrade18Time. The JSON key is pinned to celo-kona's CeloRollupConfig (upgrade18_time): the canonical rollup.json of a migrating chain carries it for the proof programs, and ParseRollupConfig's DisallowUnknownFields would otherwise reject that file. The four artifact param overrides are deliberately NOT added: they are a dev/e2e proof-host channel (real networks use the artifact's per-network constants), so op-node never sees them. IsUpgrade18 and IsUpgrade18ActivationBlock give #1412/#1413 an identical predicate to key off. - DeployConfig: l2GenesisUpgrade18TimeOffset, threaded into RollupConfig() so generated rollup.json files can schedule the fork. --- op-chain-ops/genesis/config.go | 11 ++++++ op-chain-ops/genesis/config_test.go | 13 +++++++ op-core/forks/forks.go | 5 +++ op-node/rollup/types.go | 30 ++++++++++++++++ op-node/rollup/types_test.go | 53 +++++++++++++++++++++++++++++ 5 files changed, 112 insertions(+) diff --git a/op-chain-ops/genesis/config.go b/op-chain-ops/genesis/config.go index abb37483c5f..95df3b01b4d 100644 --- a/op-chain-ops/genesis/config.go +++ b/op-chain-ops/genesis/config.go @@ -406,6 +406,12 @@ type UpgradeScheduleDeployConfig struct { // Set it to 0 to activate at genesis. Nil to disable the PectraBlobSchedule fix. L2GenesisPectraBlobScheduleTimeOffset *hexutil.Uint64 `json:"l2GenesisPectraBlobScheduleTimeOffset,omitempty"` + // L2GenesisUpgrade18TimeOffset is the number of seconds after genesis block that the + // Upgrade 18 (CGT v2) migration activates. Nil to disable Upgrade 18. Activation at genesis + // is meaningless: the migration only happens at the boundary between a pre-fork and a + // post-fork block. + L2GenesisUpgrade18TimeOffset *hexutil.Uint64 `json:"l2GenesisUpgrade18TimeOffset,omitempty"` + // When Cancun activates. Relative to L1 genesis. L1CancunTimeOffset *hexutil.Uint64 `json:"l1CancunTimeOffset,omitempty"` // When Prague activates. Relative to L1 genesis. @@ -556,6 +562,10 @@ func (d *UpgradeScheduleDeployConfig) PectraBlobScheduleTime(genesisTime uint64) return offsetToUpgradeTime(d.L2GenesisPectraBlobScheduleTimeOffset, genesisTime) } +func (d *UpgradeScheduleDeployConfig) Upgrade18Time(genesisTime uint64) *uint64 { + return offsetToUpgradeTime(d.L2GenesisUpgrade18TimeOffset, genesisTime) +} + func (d *UpgradeScheduleDeployConfig) IsthmusTime(genesisTime uint64) *uint64 { return offsetToUpgradeTime(d.L2GenesisIsthmusTimeOffset, genesisTime) } @@ -1145,6 +1155,7 @@ func (d *DeployConfig) RollupConfig(l1StartBlock *eth.BlockRef, l2GenesisBlockHa PectraBlobScheduleTime: d.PectraBlobScheduleTime(l1StartTime), IsthmusTime: d.IsthmusTime(l1StartTime), JovianTime: d.JovianTime(l1StartTime), + Upgrade18Time: d.Upgrade18Time(l1StartTime), KarstTime: d.KarstTime(l1StartTime), InteropTime: d.InteropTime(l1StartTime), AltDAConfig: altDA, diff --git a/op-chain-ops/genesis/config_test.go b/op-chain-ops/genesis/config_test.go index daef54bbe63..e1dabd7c4a1 100644 --- a/op-chain-ops/genesis/config_test.go +++ b/op-chain-ops/genesis/config_test.go @@ -91,6 +91,19 @@ func TestCanyonTimeOffset(t *testing.T) { require.Equal(t, uint64(1234+1500), *config.CanyonTime(1234)) } +func TestUpgrade18TimeOffset(t *testing.T) { + upgrade18Offset := hexutil.Uint64(1500) + config := &DeployConfig{ + L2InitializationConfig: L2InitializationConfig{ + UpgradeScheduleDeployConfig: UpgradeScheduleDeployConfig{ + L2GenesisUpgrade18TimeOffset: &upgrade18Offset, + }, + }, + } + require.Equal(t, uint64(1234+1500), *config.Upgrade18Time(1234)) + require.Nil(t, (&DeployConfig{}).Upgrade18Time(1234)) +} + func TestForksCantActivateAtSamePostGenesisBlock(t *testing.T) { postGenesisOffset := uint64(1500) config := &UpgradeScheduleDeployConfig{} diff --git a/op-core/forks/forks.go b/op-core/forks/forks.go index 546360ac1c1..bffba8a42a3 100644 --- a/op-core/forks/forks.go +++ b/op-core/forks/forks.go @@ -23,6 +23,10 @@ const ( // Optional Forks - not part of mainline PectraBlobSchedule Name = "pectrablobschedule" + // Celo: Upgrade 18 (CGT v2) is a one-shot migration trigger for the live Celo networks; + // fresh CGT chains start from a v2 genesis and never activate it. + Upgrade18 Name = "upgrade18" + None Name = "" ) @@ -46,6 +50,7 @@ var All = []Name{ // AllOpt lists all optional forks in chronological order. var AllOpt = []Name{ PectraBlobSchedule, + Upgrade18, // ADD NEW OPTIONAL FORKS HERE! } diff --git a/op-node/rollup/types.go b/op-node/rollup/types.go index 47dd86cb1cb..32c0cc6f108 100644 --- a/op-node/rollup/types.go +++ b/op-node/rollup/types.go @@ -185,6 +185,13 @@ type Config struct { // BatchAuthenticatorAddress is the L1 address of the BatchAuthenticator contract whose // BatchInfoAuthenticated(bytes32,address) events the derivation pipeline scans post-Espresso. BatchAuthenticatorAddress common.Address `json:"batch_authenticator_address,omitempty,omitzero"` + + // Celo: Upgrade18Time sets the activation time of the Upgrade 18 (CGT v2) migration, an + // irregular state transition performed by the execution layer; the op-node has no boundary + // behavior of its own. The JSON key must stay identical to celo-kona's `CeloRollupConfig`, + // which parses the same rollup.json. The field name is provisional until the final + // activation trigger is decided (celo-blockchain-planning#1407). + Upgrade18Time *uint64 `json:"upgrade18_time,omitempty"` } // ValidateL1Config checks L1 config variables for errors. @@ -537,6 +544,12 @@ func (c *Config) IsCel2(timestamp uint64) bool { return c.Cel2Time != nil && timestamp >= *c.Cel2Time } +// IsUpgrade18 returns true if the Upgrade 18 (CGT v2) migration is active at or past the given +// timestamp. +func (c *Config) IsUpgrade18(timestamp uint64) bool { + return c.IsForkActive(forks.Upgrade18, timestamp) +} + func (c *Config) IsRegolithActivationBlock(l2BlockTime uint64) bool { return c.IsRegolith(l2BlockTime) && l2BlockTime >= c.BlockTime && @@ -617,6 +630,15 @@ func (c *Config) IsInteropActivationBlock(l2BlockTime uint64) bool { !c.IsInterop(l2BlockTime-c.BlockTime) } +// IsUpgrade18ActivationBlock returns whether the specified block is the first block subject to +// the Upgrade 18 (CGT v2) migration. Activation at genesis does not count: the migration only +// exists as a boundary between a pre-fork and a post-fork block. +func (c *Config) IsUpgrade18ActivationBlock(l2BlockTime uint64) bool { + return c.IsUpgrade18(l2BlockTime) && + l2BlockTime >= c.BlockTime && + !c.IsUpgrade18(l2BlockTime-c.BlockTime) +} + func (c *Config) ActivationTime(fork ForkName) *uint64 { // NEW FORKS MUST BE ADDED HERE switch fork { @@ -646,6 +668,8 @@ func (c *Config) ActivationTime(fork ForkName) *uint64 { // Optional forks case forks.PectraBlobSchedule: return c.PectraBlobScheduleTime + case forks.Upgrade18: + return c.Upgrade18Time default: panic(fmt.Sprintf("unknown fork: %v", fork)) @@ -681,6 +705,8 @@ func (c *Config) SetActivationTime(fork ForkName, timestamp *uint64) { // Optional forks case forks.PectraBlobSchedule: c.PectraBlobScheduleTime = timestamp + case forks.Upgrade18: + c.Upgrade18Time = timestamp default: panic(fmt.Sprintf("unknown fork: %v", fork)) @@ -903,6 +929,10 @@ func (c *Config) forEachFork(callback func(name string, logName string, time *ui } callback("Isthmus", "isthmus_time", c.IsthmusTime) callback("Jovian", "jovian_time", c.JovianTime) + if c.Upgrade18Time != nil { + // only report if config is set + callback("Upgrade 18 (CGT v2)", "upgrade18_time", c.Upgrade18Time) + } callback("Karst", "karst_time", c.KarstTime) callback("Interop", "interop_time", c.InteropTime) if c.EspressoTime != nil { diff --git a/op-node/rollup/types_test.go b/op-node/rollup/types_test.go index ff8467a1a98..b7d182d3a7c 100644 --- a/op-node/rollup/types_test.go +++ b/op-node/rollup/types_test.go @@ -1,6 +1,7 @@ package rollup import ( + "bytes" "context" "encoding/json" "errors" @@ -1148,3 +1149,55 @@ func TestConfig_ActivateAtGenesis(t *testing.T) { require.Zero(t, cfg) }) } + +// TestConfig_Upgrade18 covers the optional Celo Upgrade 18 (CGT v2) migration fork: boundary +// semantics of the predicates and the ActivationTime/SetActivationTime wiring (which also backs +// the --override.upgrade18 CLI flag). +func TestConfig_Upgrade18(t *testing.T) { + cfg := Config{BlockTime: 2} + + // Unset: never active. + require.False(t, cfg.IsUpgrade18(^uint64(0))) + require.Nil(t, cfg.ActivationTime(forks.Upgrade18)) + + ts := uint64(1000) + cfg.SetActivationTime(forks.Upgrade18, &ts) + require.Equal(t, &ts, cfg.Upgrade18Time) + require.Equal(t, &ts, cfg.ActivationTime(forks.Upgrade18)) + require.False(t, cfg.IsUpgrade18(999)) + require.True(t, cfg.IsUpgrade18(1000)) + require.True(t, cfg.IsForkActive(forks.Upgrade18, 1000)) + + // The activation block is the first post-fork block, and only that one. + require.False(t, cfg.IsUpgrade18ActivationBlock(998)) + require.True(t, cfg.IsUpgrade18ActivationBlock(1000)) + require.False(t, cfg.IsUpgrade18ActivationBlock(1002)) + + // Activation at genesis is not an activation block: the migration only exists as a + // boundary between a pre-fork and a post-fork block. + genesisActive := Config{BlockTime: 2, Upgrade18Time: ptr.Zero64} + require.False(t, genesisActive.IsUpgrade18ActivationBlock(0)) + require.False(t, genesisActive.IsUpgrade18ActivationBlock(2)) +} + +// TestConfig_Upgrade18JSON pins the exact JSON key of Upgrade18Time: celo-kona's +// `CeloRollupConfig` keys the migration off `upgrade18_time` in the same rollup.json, so the two +// sides must stay key-identical. ParseRollupConfig rejects unknown fields, which makes this the +// cross-client contract test — before the field existed, op-node refused a rollup.json +// carrying it. +func TestConfig_Upgrade18JSON(t *testing.T) { + config := randConfig() + ts := uint64(4242) + config.Upgrade18Time = &ts + + data, err := json.Marshal(config) + require.NoError(t, err) + + var keys map[string]json.RawMessage + require.NoError(t, json.Unmarshal(data, &keys)) + require.Contains(t, keys, "upgrade18_time") + + var roundTripped Config + require.NoError(t, roundTripped.ParseRollupConfig(bytes.NewReader(data))) + require.Equal(t, config, &roundTripped) +}