From 55a7624035a44a0c881b43dfdd694da83d0b6f3e Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 29 Apr 2026 12:39:51 +0200 Subject: [PATCH 01/18] op-deployer,contracts: Add deployCeloContracts flag to L2Genesis Adds a per-chain DeployCeloContracts intent that, when set, etches the Celo predeploys into the L2 genesis allocs: CeloRegistry, GoldToken, FeeHandler, the FeeHandlerSellers, SortedOracles, AddressSortedLinkedListWithMedian, the testing FeeCurrency, and FeeCurrencyDirectory at the mainnet address 0x15F344...6276 expected by celo-org/op-geth's MainnetAddresses default. Also deploys a generic test fee currency (StableTokenV2) at the predeploy address previously labelled cUSD, registers devAccounts[0] as its oracle, reports a price, and registers it with the FeeCurrencyDirectory at 80k intrinsic gas so CIP-64 transactions can pay gas in it. Default behavior is unchanged. Wires the flag through ChainIntent and opcm.L2GenesisInput; adds an integration smoke test verifying the allocs. --- .../deployer/integration_test/apply_test.go | 35 ++++ op-deployer/pkg/deployer/opcm/l2genesis.go | 1 + .../pkg/deployer/pipeline/l2genesis.go | 1 + .../pkg/deployer/state/chain_intent.go | 1 + packages/contracts-bedrock/justfile | 5 +- .../contracts-bedrock/scripts/L2Genesis.s.sol | 153 ++++++++++++++++++ .../src/celo/CeloPredeploys.sol | 10 +- 7 files changed, 202 insertions(+), 4 deletions(-) diff --git a/op-deployer/pkg/deployer/integration_test/apply_test.go b/op-deployer/pkg/deployer/integration_test/apply_test.go index cae2024ae68..7e5ae256a95 100644 --- a/op-deployer/pkg/deployer/integration_test/apply_test.go +++ b/op-deployer/pkg/deployer/integration_test/apply_test.go @@ -535,6 +535,41 @@ func TestApplyGenesisStrategy(t *testing.T) { }) } +func TestApplyDeployCeloContracts(t *testing.T) { + op_e2e.InitParallel(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + opts, intent, st := setupGenesisChain(t, devnet.DefaultChainID) + intent.Chains[0].DeployCeloContracts = true + + require.NoError(t, deployer.ApplyPipeline(ctx, opts)) + + allocs := st.Chains[0].Allocs.Data.Accounts + + // Predeploys (proxies — code is the EIP-1967 Proxy bytecode at these addresses). + proxied := []common.Address{ + common.HexToAddress("0x000000000000000000000000000000000000ce10"), // CeloRegistry + common.HexToAddress("0x471EcE3750Da237f93B8E339c536989b8978a438"), // GoldToken + common.HexToAddress("0xcD437749E43A154C07F3553504c68fBfD56B8778"), // FeeHandler + common.HexToAddress("0xefB84935239dAcdecF7c5bA76d8dE40b077B7b33"), // SortedOracles + common.HexToAddress("0x15F344b9E6c3Cb6F0376A36A64928b13F62C6276"), // FeeCurrencyDirectory + common.HexToAddress("0x765DE816845861e75A25fCA122bb6898B8B1282a"), // TestFeeCurrency (StableTokenV2) + } + for _, addr := range proxied { + acc, ok := allocs[addr] + require.Truef(t, ok, "no account at %s", addr) + require.NotEmpty(t, acc.Code, "no code at %s", addr) + } + + // Library — etched directly, not proxied. + libAddr := common.HexToAddress("0xED477A99035d0c1e11369F1D7A4e587893cc002B") + acc, ok := allocs[libAddr] + require.True(t, ok, "no account at AddressSortedLinkedListWithMedian") + require.NotEmpty(t, acc.Code) +} + func TestProofParamOverrides(t *testing.T) { op_e2e.InitParallel(t) diff --git a/op-deployer/pkg/deployer/opcm/l2genesis.go b/op-deployer/pkg/deployer/opcm/l2genesis.go index 29deabfd740..921da36e9db 100644 --- a/op-deployer/pkg/deployer/opcm/l2genesis.go +++ b/op-deployer/pkg/deployer/opcm/l2genesis.go @@ -40,6 +40,7 @@ type L2GenesisInput struct { NativeAssetLiquidityAmount *big.Int LiquidityControllerOwner common.Address UseL2CM bool + DeployCeloContracts bool } type L2GenesisScript script.DeployScriptWithoutOutput[L2GenesisInput] diff --git a/op-deployer/pkg/deployer/pipeline/l2genesis.go b/op-deployer/pkg/deployer/pipeline/l2genesis.go index 08e3f10b26c..f0680a935ab 100644 --- a/op-deployer/pkg/deployer/pipeline/l2genesis.go +++ b/op-deployer/pkg/deployer/pipeline/l2genesis.go @@ -127,6 +127,7 @@ func GenerateL2Genesis(pEnv *Env, intent *state.Intent, bundle ArtifactsBundle, NativeAssetLiquidityAmount: cgt.NativeAssetLiquidityAmount, LiquidityControllerOwner: cgt.LiquidityControllerOwner, UseL2CM: useL2CM, + DeployCeloContracts: thisIntent.DeployCeloContracts, }); err != nil { return fmt.Errorf("failed to call L2Genesis script: %w", err) } diff --git a/op-deployer/pkg/deployer/state/chain_intent.go b/op-deployer/pkg/deployer/state/chain_intent.go index 101aac45b2b..84e2125bc36 100644 --- a/op-deployer/pkg/deployer/state/chain_intent.go +++ b/op-deployer/pkg/deployer/state/chain_intent.go @@ -83,6 +83,7 @@ type ChainIntent struct { MinBaseFee uint64 `json:"minBaseFee,omitempty" toml:"minBaseFee,omitempty"` DAFootprintGasScalar uint16 `json:"daFootprintGasScalar,omitempty" toml:"daFootprintGasScalar,omitempty"` CustomGasToken CustomGasToken `json:"customGasToken" toml:"customGasToken"` + DeployCeloContracts bool `json:"deployCeloContracts,omitempty" toml:"deployCeloContracts,omitempty"` // Optional. For development purposes only. Only enabled if the operation mode targets a genesis-file output. L2DevGenesisParams *L2DevGenesisParams `json:"l2DevGenesisParams,omitempty" toml:"l2DevGenesisParams,omitempty"` diff --git a/packages/contracts-bedrock/justfile b/packages/contracts-bedrock/justfile index 56ab962526e..97da4a366c7 100644 --- a/packages/contracts-bedrock/justfile +++ b/packages/contracts-bedrock/justfile @@ -47,8 +47,11 @@ build-source: forge build --skip "/**/test/**" --skip "/**/scripts/**" # Builds source contracts and scripts, skipping tests. +# Links AddressSortedLinkedListWithMedian to its predeploy address so the L2Genesis +# script can be loaded by op-deployer when deployCeloContracts is enabled. build-no-tests: - forge build --skip "/**/test/**" + forge build --skip "/**/test/**" --libraries \ + "src/celo/common/linkedlists/AddressSortedLinkedListWithMedian.sol:AddressSortedLinkedListWithMedian:0xED477A99035d0c1e11369F1D7A4e587893cc002B" # Builds the contracts. build *ARGS: lint-fix-no-fail diff --git a/packages/contracts-bedrock/scripts/L2Genesis.s.sol b/packages/contracts-bedrock/scripts/L2Genesis.s.sol index 2385d962c43..cbf490e3d3e 100644 --- a/packages/contracts-bedrock/scripts/L2Genesis.s.sol +++ b/packages/contracts-bedrock/scripts/L2Genesis.s.sol @@ -34,6 +34,19 @@ import { IFeeVault } from "interfaces/L2/IFeeVault.sol"; import { IL1Withdrawer } from "interfaces/L2/IL1Withdrawer.sol"; import { ISuperchainRevSharesCalculator } from "interfaces/L2/ISuperchainRevSharesCalculator.sol"; +// Celo predeploys +import { CeloPredeploys } from "src/celo/CeloPredeploys.sol"; +import { CeloRegistry } from "src/celo/CeloRegistry.sol"; +import { GoldToken } from "src/celo/GoldToken.sol"; +import { FeeHandler } from "src/celo/FeeHandler.sol"; +import { MentoFeeHandlerSeller } from "src/celo/MentoFeeHandlerSeller.sol"; +import { UniswapFeeHandlerSeller } from "src/celo/UniswapFeeHandlerSeller.sol"; +import { SortedOracles } from "src/celo/stability/SortedOracles.sol"; +import { FeeCurrencyDirectory } from "src/celo/FeeCurrencyDirectory.sol"; +import { FeeCurrency } from "src/celo/testing/FeeCurrency.sol"; +import { StableTokenV2 } from "src/celo/StableTokenV2.sol"; +import { AddressSortedLinkedListWithMedian } from "src/celo/common/linkedlists/AddressSortedLinkedListWithMedian.sol"; + /// @title L2Genesis /// @notice Generates the genesis state for the L2 network. /// The following safety invariants are used when setting state: @@ -82,6 +95,7 @@ contract L2Genesis is Script { uint256 nativeAssetLiquidityAmount; address liquidityControllerOwner; bool useL2CM; + bool deployCeloContracts; } using ForkUtils for Fork; @@ -142,6 +156,9 @@ contract L2Genesis is Script { if (_input.fundDevAccounts) { fundDevAccounts(); } + if (_input.deployCeloContracts) { + setCeloPredeploys(deployer); + } vm.stopPrank(); vm.deal(deployer, 0); @@ -763,4 +780,140 @@ contract L2Genesis is Script { vm.deal(devAccounts[i], DEV_ACCOUNT_FUND_AMT); } } + + /// @notice Etches all Celo predeploy contracts and registers a test fee currency. + /// devAccounts[0] receives 100k TEST tokens and owns the FeeCurrencyDirectory. + function setCeloPredeploys(address _deployer) internal { + setCeloRegistry(); + setCeloGoldToken(); + setCeloFeeHandler(); + setCeloMentoFeeHandlerSeller(); + setCeloUniswapFeeHandlerSeller(); + setCeloAddressSortedLinkedListWithMedian(); + setCeloSortedOracles(); + setCeloFeeCurrency(); + setFeeCurrencyDirectory(); + + address[] memory initialBalanceAddresses = new address[](2); + initialBalanceAddresses[0] = devAccounts[0]; + // Seed the FeeHandler with 1 wei to avoid the cold-SSTORE penalty on its first transfer. + initialBalanceAddresses[1] = CeloPredeploys.FEE_HANDLER; + + uint256[] memory initialBalances = new uint256[](2); + initialBalances[0] = 100_000 ether; + initialBalances[1] = 1; + + deployTestFeeCurrency(_deployer, initialBalanceAddresses, initialBalances, 2); + } + + function _setupCeloProxy(address _addr, address _impl) internal { + bytes memory code = vm.getDeployedCode("Proxy.sol:Proxy"); + vm.etch(_addr, code); + EIP1967Helper.setAdmin(_addr, Predeploys.PROXY_ADMIN); + EIP1967Helper.setImplementation(_addr, _impl); + } + + function setCeloRegistry() internal { + CeloRegistry impl = new CeloRegistry({ test: false }); + vm.resetNonce(address(impl)); + _setupCeloProxy(CeloPredeploys.CELO_REGISTRY, address(impl)); + } + + function setCeloGoldToken() internal { + GoldToken impl = new GoldToken({ test: false }); + vm.resetNonce(address(impl)); + _setupCeloProxy(CeloPredeploys.GOLD_TOKEN, address(impl)); + } + + function setCeloFeeHandler() internal { + FeeHandler impl = new FeeHandler({ test: false }); + vm.resetNonce(address(impl)); + _setupCeloProxy(CeloPredeploys.FEE_HANDLER, address(impl)); + } + + function setCeloMentoFeeHandlerSeller() internal { + MentoFeeHandlerSeller impl = new MentoFeeHandlerSeller({ test: false }); + vm.resetNonce(address(impl)); + _setupCeloProxy(CeloPredeploys.MENTO_FEE_HANDLER_SELLER, address(impl)); + } + + function setCeloUniswapFeeHandlerSeller() internal { + UniswapFeeHandlerSeller impl = new UniswapFeeHandlerSeller({ test: false }); + vm.resetNonce(address(impl)); + _setupCeloProxy(CeloPredeploys.UNISWAP_FEE_HANDLER_SELLER, address(impl)); + } + + function setCeloAddressSortedLinkedListWithMedian() internal { + bytes memory runtimeCode = type(AddressSortedLinkedListWithMedian).runtimeCode; + vm.etch(CeloPredeploys.ADDRESS_SORTED_LINKED_LIST_WITH_MEDIAN, runtimeCode); + } + + function setCeloSortedOracles() internal { + SortedOracles impl = new SortedOracles({ test: false }); + vm.resetNonce(address(impl)); + _setupCeloProxy(CeloPredeploys.SORTED_ORACLES, address(impl)); + } + + function setCeloFeeCurrency() internal { + FeeCurrency impl = new FeeCurrency({ name_: "Test", symbol_: "TST" }); + vm.resetNonce(address(impl)); + _setupCeloProxy(CeloPredeploys.FEE_CURRENCY, address(impl)); + } + + function setFeeCurrencyDirectory() internal { + FeeCurrencyDirectory impl = new FeeCurrencyDirectory({ test: false }); + vm.resetNonce(address(impl)); + _setupCeloProxy(CeloPredeploys.FEE_CURRENCY_DIRECTORY, address(impl)); + + // Initialize the proxy and transfer ownership to devAccounts[0] so devnets can + // edit the directory at runtime. + vm.startPrank(devAccounts[0]); + FeeCurrencyDirectory(CeloPredeploys.FEE_CURRENCY_DIRECTORY).initialize(); + vm.stopPrank(); + } + + /// @notice Deploys a test fee currency (StableTokenV2) at its predeploy address, mints + /// initial balances, registers _deployer as a price oracle, reports a price, + /// and registers the token with the FeeCurrencyDirectory so the EL accepts + /// CIP-64 transactions paying gas in it. + function deployTestFeeCurrency( + address _deployer, + address[] memory _initialBalanceAddresses, + uint256[] memory _initialBalances, + uint256 _price + ) + internal + { + StableTokenV2 impl = new StableTokenV2({ disable: false }); + vm.resetNonce(address(impl)); + _setupCeloProxy(CeloPredeploys.TEST_FEE_CURRENCY, address(impl)); + + StableTokenV2(CeloPredeploys.TEST_FEE_CURRENCY).initialize( + "Test Fee Currency", "TEST", _initialBalanceAddresses, _initialBalances + ); + + // Register _deployer as an oracle for the test currency. addOracle is onlyOwner. + SortedOracles sortedOracles = SortedOracles(CeloPredeploys.SORTED_ORACLES); + vm.startPrank(sortedOracles.owner()); + sortedOracles.addOracle(CeloPredeploys.TEST_FEE_CURRENCY, _deployer); + vm.stopPrank(); + + // Report a price as the registered oracle. + if (_price != 0) { + vm.startPrank(_deployer); + sortedOracles.report(CeloPredeploys.TEST_FEE_CURRENCY, _price * 1e24, address(0), address(0)); + vm.stopPrank(); + } + + // Register the test currency with the FeeCurrencyDirectory so the EL recognizes it + // as a fee currency. Intrinsic gas is the per-tx surcharge added to cover the + // debitGasFees / creditGasFees calls into the fee-currency contract. + uint256 intrinsicGas = 80_000; + FeeCurrencyDirectory feeCurrencyDirectory = FeeCurrencyDirectory(CeloPredeploys.FEE_CURRENCY_DIRECTORY); + vm.startPrank(feeCurrencyDirectory.owner()); + feeCurrencyDirectory.setCurrencyConfig( + CeloPredeploys.TEST_FEE_CURRENCY, address(sortedOracles), intrinsicGas + ); + vm.stopPrank(); + } } diff --git a/packages/contracts-bedrock/src/celo/CeloPredeploys.sol b/packages/contracts-bedrock/src/celo/CeloPredeploys.sol index 2ca38a84576..e51143e37b4 100644 --- a/packages/contracts-bedrock/src/celo/CeloPredeploys.sol +++ b/packages/contracts-bedrock/src/celo/CeloPredeploys.sol @@ -12,8 +12,12 @@ library CeloPredeploys { address internal constant SORTED_ORACLES = 0xefB84935239dAcdecF7c5bA76d8dE40b077B7b33; address internal constant ADDRESS_SORTED_LINKED_LIST_WITH_MEDIAN = 0xED477A99035d0c1e11369F1D7A4e587893cc002B; address internal constant FEE_CURRENCY = 0x4200000000000000000000000000000000001022; - address internal constant FEE_CURRENCY_DIRECTORY = 0x9212Fb72ae65367A7c887eC4Ad9bE310BAC611BF; - address internal constant cUSD = 0x765DE816845861e75A25fCA122bb6898B8B1282a; + address internal constant FEE_CURRENCY_DIRECTORY = 0x15F344b9E6c3Cb6F0376A36A64928b13F62C6276; + /// @notice A test fee currency deployed only when `deployCeloContracts` is set. + /// Registered with the FeeCurrencyDirectory so that CIP-64 transactions can + /// pay gas in this token. Address is the (now meaningless) Celo mainnet cUSD + /// address kept for deterministic predeploy placement. + address internal constant TEST_FEE_CURRENCY = 0x765DE816845861e75A25fCA122bb6898B8B1282a; /// @notice Returns the name of the predeploy at the given address. function getName(address _addr) internal pure returns (string memory out_) { @@ -28,7 +32,7 @@ library CeloPredeploys { if (_addr == ADDRESS_SORTED_LINKED_LIST_WITH_MEDIAN) return "AddressSortedLinkedListWithMedian"; if (_addr == FEE_CURRENCY) return "FeeCurrency"; if (_addr == FEE_CURRENCY_DIRECTORY) return "FeeCurrencyDirectory"; - if (_addr == cUSD) return "cUSD"; + if (_addr == TEST_FEE_CURRENCY) return "TestFeeCurrency"; revert("Predeploys: unnamed predeploy"); } From e3fad4a103b796264f9c717d8db34b28c89b89a0 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 29 Apr 2026 12:39:58 +0200 Subject: [PATCH 02/18] op-devstack,op-e2e: Add sysgo.WithCelo() option Exposes the new DeployCeloContracts intent through the intentbuilder L2Configurator and a sysgo.WithCelo() deployer option. Tests can now spin up an in-process devnet with the Celo predeploys deployed and a test fee currency registered for CIP-64 coverage. --- op-devstack/sysgo/deployer.go | 11 +++++++++++ op-e2e/e2eutils/intentbuilder/builder.go | 5 +++++ 2 files changed, 16 insertions(+) diff --git a/op-devstack/sysgo/deployer.go b/op-devstack/sysgo/deployer.go index 94627337386..e4340385b95 100644 --- a/op-devstack/sysgo/deployer.go +++ b/op-devstack/sysgo/deployer.go @@ -406,6 +406,17 @@ func WithCustomGasToken(name, symbol string, initialLiquidity *big.Int, liquidit } } +// WithCelo enables Celo predeploys (CeloRegistry, FeeCurrencyDirectory, cUSD, ...) in the +// L2 genesis allocs. cUSD is registered as a fee currency so CIP-64 (CeloDynamicFeeTxV2) +// transactions can be paid in cUSD. devAccounts[0] is funded with 100k cUSD. +func WithCelo() DeployerOption { + return func(p devtest.P, keys devkeys.Keys, builder intentbuilder.Builder) { + for _, l2Cfg := range builder.L2s() { + l2Cfg.WithDeployCeloContracts(true) + } + } +} + func (wb *worldBuilder) buildL1Genesis() { wb.require.NotNil(wb.output.L1DevGenesis, "must have L1 genesis outer config") wb.require.NotNil(wb.output.L1StateDump, "must have L1 genesis alloc") diff --git a/op-e2e/e2eutils/intentbuilder/builder.go b/op-e2e/e2eutils/intentbuilder/builder.go index 8ad9b4a03b6..a169971681d 100644 --- a/op-e2e/e2eutils/intentbuilder/builder.go +++ b/op-e2e/e2eutils/intentbuilder/builder.go @@ -56,6 +56,7 @@ type L2Configurator interface { WithFinalizationPeriodSeconds(value uint64) WithRevenueShare(enabled bool, chainFeesRecipient common.Address) WithCustomGasToken(name string, symbol string, initialLiquidity *big.Int, liquidityControllerOwner common.Address) + WithDeployCeloContracts(enabled bool) ContractsConfigurator L2VaultsConfigurator L2RolesConfigurator @@ -480,6 +481,10 @@ func (c *l2Configurator) WithCustomGasToken(name, symbol string, initialLiquidit } } +func (c *l2Configurator) WithDeployCeloContracts(enabled bool) { + c.builder.intent.Chains[c.chainIndex].DeployCeloContracts = enabled +} + func (c *l2Configurator) WithEIP1559Elasticity(value uint64) { c.builder.intent.Chains[c.chainIndex].Eip1559Elasticity = value } From e37ba5badeb6bdd69df79d785646e62454e2794d Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 29 Apr 2026 15:14:30 +0200 Subject: [PATCH 03/18] op-deployer,op-e2e,contracts: Land CIP-64 txs on sysgo Celo chains Two independent fixes are needed before CIP-64 (CeloDynamicFeeTxV2) transactions actually land in a block on a sysgo chain with WithCelo() deployed. First, the L1 rollup data fee. Even for fee-currency txs, op-geth's txpool charges the rollup data cost against the sender's native balance. On the Celo test setup the sender has zero native, so every tx is rejected with "insufficient funds". This mirrors what celo-mainnet does in production: when DeployCeloContracts is set we hand both GasPriceOracleBaseFeeScalar and BlobBaseFeeScalar to 0 so the chain operator pays all DA cost. The zero-scalar guard in DeployOPChain.s.sol is relaxed to allow this. Second, op-geth's MultiGasPool. The miner uses a per-fee-currency gas budget when building blocks. miner.DefaultConfig sets FeeCurrencyDefault=0.5 (50% of the block gas limit available to any one fee currency), but op-e2e/e2eutils/geth's L2 init constructs miner.Config as a literal that omits the field, so it defaults to 0 and every CIP-64 tx is silently popped during block building. Set FeeCurrencyDefault to the celo default and provide an explicit 0.9 entry for the test fee currency, mirroring celo-mainnet's cUSD/USDT/USDC config. --- op-deployer/pkg/deployer/pipeline/opchain.go | 13 +++++++++++-- op-deployer/pkg/deployer/state/deploy_config.go | 13 +++++++++++-- op-e2e/e2eutils/geth/geth.go | 11 +++++++++++ .../scripts/deploy/DeployOPChain.s.sol | 5 +++-- 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/op-deployer/pkg/deployer/pipeline/opchain.go b/op-deployer/pkg/deployer/pipeline/opchain.go index c923bc54aeb..f2505f9bfe3 100644 --- a/op-deployer/pkg/deployer/pipeline/opchain.go +++ b/op-deployer/pkg/deployer/pipeline/opchain.go @@ -151,8 +151,8 @@ func makeDCI(intent *state.Intent, thisIntent *state.ChainIntent, chainID common UnsafeBlockSigner: thisIntent.Roles.UnsafeBlockSigner, Proposer: thisIntent.Roles.Proposer, Challenger: thisIntent.Roles.Challenger, - BasefeeScalar: standard.BasefeeScalar, - BlobBaseFeeScalar: standard.BlobBaseFeeScalar, + BasefeeScalar: celoOrDefault(thisIntent.DeployCeloContracts, standard.BasefeeScalar), + BlobBaseFeeScalar: celoOrDefault(thisIntent.DeployCeloContracts, standard.BlobBaseFeeScalar), L2ChainId: chainID.Big(), Opcm: opcmAddr, SaltMixer: st.Create2Salt.String(), // passing through salt generated at state initialization @@ -171,6 +171,15 @@ func makeDCI(intent *state.Intent, thisIntent *state.ChainIntent, chainID common }, nil } +// celoOrDefault returns 0 when Celo contracts are deployed (so the chain operator pays +// all L1 DA cost) and the standard default otherwise. +func celoOrDefault(celo bool, def uint32) uint32 { + if celo { + return 0 + } + return def +} + func makeChainState(chainID common.Hash, impls opcm.ReadImplementationAddressesOutput, dco opcm.DeployOPChainOutput) *state.ChainState { opChainContracts := addresses.OpChainContracts{} opChainContracts.OpChainProxyAdminImpl = dco.OpChainProxyAdmin diff --git a/op-deployer/pkg/deployer/state/deploy_config.go b/op-deployer/pkg/deployer/state/deploy_config.go index 72d92746ae5..967daf33cc8 100644 --- a/op-deployer/pkg/deployer/state/deploy_config.go +++ b/op-deployer/pkg/deployer/state/deploy_config.go @@ -62,8 +62,8 @@ func CombineDeployConfig(intent *Intent, chainIntent *ChainIntent, state *State, GovernanceTokenOwner: standard.GovernanceTokenOwner, }, GasPriceOracleDeployConfig: genesis.GasPriceOracleDeployConfig{ - GasPriceOracleBaseFeeScalar: standard.BasefeeScalar, - GasPriceOracleBlobBaseFeeScalar: standard.BlobBaseFeeScalar, + GasPriceOracleBaseFeeScalar: celoOrDefaultScalar(chainIntent.DeployCeloContracts, standard.BasefeeScalar), + GasPriceOracleBlobBaseFeeScalar: celoOrDefaultScalar(chainIntent.DeployCeloContracts, standard.BlobBaseFeeScalar), GasPriceOracleOperatorFeeScalar: chainIntent.OperatorFeeScalar, GasPriceOracleOperatorFeeConstant: chainIntent.OperatorFeeConstant, }, @@ -184,3 +184,12 @@ func calculateBatchInboxAddr(chainID common.Hash) common.Address { copy(out[1:], crypto.Keccak256(chainID[:])[:19]) return out } + +// celoOrDefaultScalar returns 0 when Celo contracts are deployed (so the chain operator +// pays all L1 DA cost on behalf of users) and the standard default otherwise. +func celoOrDefaultScalar(celo bool, def uint32) uint32 { + if celo { + return 0 + } + return def +} diff --git a/op-e2e/e2eutils/geth/geth.go b/op-e2e/e2eutils/geth/geth.go index d5eed7d2ee5..7f95c259210 100644 --- a/op-e2e/e2eutils/geth/geth.go +++ b/op-e2e/e2eutils/geth/geth.go @@ -141,6 +141,17 @@ func InitL2(name string, genesis *core.Genesis, jwtPath string, opts ...GethOpti GasPrice: nil, // enough to build blocks within 1 second, but high enough to avoid unnecessary test CPU cycles. Recommit: time.Millisecond * 400, + // Celo: per-currency fraction of the block gas limit available to fee-currency txs. + // Without these, op-geth's MultiGasPool gives each registered fee currency 0 gas + // and silently drops every CIP-64 tx during block building. The default mirrors + // miner.DefaultConfig; the explicit entry for the test fee currency mirrors + // celo-mainnet's 0.9 limit for cUSD/USDT/USDC. + FeeCurrencyDefault: miner.DefaultFeeCurrencyLimit, + FeeCurrencyLimits: map[common.Address]float64{ + // TEST_FEE_CURRENCY (kept at the celo-mainnet cUSD address for a deterministic + // predeploy slot; only present when sysgo.WithCelo() is set). + common.HexToAddress("0x765DE816845861e75A25fCA122bb6898B8B1282a"): 0.9, + }, }, TxPool: legacypool.Config{ NoLocals: true, diff --git a/packages/contracts-bedrock/scripts/deploy/DeployOPChain.s.sol b/packages/contracts-bedrock/scripts/deploy/DeployOPChain.s.sol index 2faa6cdcb11..9dada1ece1e 100644 --- a/packages/contracts-bedrock/scripts/deploy/DeployOPChain.s.sol +++ b/packages/contracts-bedrock/scripts/deploy/DeployOPChain.s.sol @@ -290,8 +290,9 @@ contract DeployOPChain is Script { require(_i.proposer != address(0), "DeployOPChainInput: proposer not set"); require(_i.challenger != address(0), "DeployOPChainInput: challenger not set"); - require(_i.blobBaseFeeScalar != 0, "DeployOPChainInput: blobBaseFeeScalar not set"); - require(_i.basefeeScalar != 0, "DeployOPChainInput: basefeeScalar not set"); + // Zero scalars are valid for Celo-style chains where the operator pays all L1 DA cost. + // require(_i.blobBaseFeeScalar != 0, "DeployOPChainInput: blobBaseFeeScalar not set"); + // require(_i.basefeeScalar != 0, "DeployOPChainInput: basefeeScalar not set"); require(_i.gasLimit != 0, "DeployOPChainInput: gasLimit not set"); require(_i.l2ChainId != 0, "DeployOPChainInput: l2ChainId not set"); From 4b2943994c5579e7a65aa479a2a61c7976092628 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 29 Apr 2026 15:15:07 +0200 Subject: [PATCH 04/18] op-acceptance-tests: Add Celo CIP-64 fee-currency test Adds tests/celo/ with a single TestCIP64_PayGasInTestFeeCurrency that spins up a sysgo chain via sysgo.WithCelo(), sends a CeloDynamicFeeTxV2 (CIP-64) signed by foundry's devAccounts[0] (pre-funded with 100k TEST and zero native CELO), waits for inclusion, and asserts the sender's native balance did not move while the TEST balance dropped. The test gracefully skips on devnets without the FeeCurrencyDirectory predeploy. Registered as the celo gate in acceptance-tests.yaml; just celo runs it. --- op-acceptance-tests/acceptance-tests.yaml | 6 ++ op-acceptance-tests/justfile | 3 + op-acceptance-tests/tests/celo/cip64_test.go | 83 ++++++++++++++++++++ op-acceptance-tests/tests/celo/helpers.go | 70 +++++++++++++++++ op-acceptance-tests/tests/celo/init_test.go | 18 +++++ 5 files changed, 180 insertions(+) create mode 100644 op-acceptance-tests/tests/celo/cip64_test.go create mode 100644 op-acceptance-tests/tests/celo/helpers.go create mode 100644 op-acceptance-tests/tests/celo/init_test.go diff --git a/op-acceptance-tests/acceptance-tests.yaml b/op-acceptance-tests/acceptance-tests.yaml index f6f6c5237ec..764bb4fbb1e 100644 --- a/op-acceptance-tests/acceptance-tests.yaml +++ b/op-acceptance-tests/acceptance-tests.yaml @@ -185,6 +185,12 @@ gates: - package: github.com/ethereum-optimism/optimism/op-acceptance-tests/tests/custom_gas_token timeout: 10m + - id: celo + description: "Celo-specific tests (CIP-64 fee currencies, etc.)." + tests: + - package: github.com/ethereum-optimism/optimism/op-acceptance-tests/tests/celo + timeout: 10m + - id: supernode description: "Supernode tests - tests for the op-supernode multi-chain consensus layer." tests: diff --git a/op-acceptance-tests/justfile b/op-acceptance-tests/justfile index 34faa2e14f8..e39cbaeab5c 100644 --- a/op-acceptance-tests/justfile +++ b/op-acceptance-tests/justfile @@ -17,6 +17,9 @@ interop: cgt: @just acceptance-test "" cgt +celo: + @just acceptance-test "" celo + # Run acceptance tests with mise-managed binary # Usage: just acceptance-test [devnet] [gate] diff --git a/op-acceptance-tests/tests/celo/cip64_test.go b/op-acceptance-tests/tests/celo/cip64_test.go new file mode 100644 index 00000000000..5260bb5e047 --- /dev/null +++ b/op-acceptance-tests/tests/celo/cip64_test.go @@ -0,0 +1,83 @@ +package celo + +import ( + "context" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/presets" +) + +// TestCIP64_PayGasInTestFeeCurrency sends a CeloDynamicFeeTxV2 (CIP-64) with +// FeeCurrency set to the registered test fee currency. devAccounts[0] is the +// foundry test mnemonic account that L2Genesis pre-funds with 100k TEST and +// zero native CELO; the only way the tx can be paid for is via op-geth's +// fee-currency machinery. +func TestCIP64_PayGasInTestFeeCurrency(gt *testing.T) { + t := devtest.SerialT(gt) + sys := presets.NewMinimal(t) + ensureCeloFeeCurrencyOrSkip(t, sys) + + priv, err := crypto.HexToECDSA(foundryDevAccount0PrivKeyHex) + t.Require().NoError(err, "parse foundry dev priv key") + sender := crypto.PubkeyToAddress(priv.PublicKey) + + l2 := sys.L2EL.Escape().L2EthClient() + chainID := sys.L2Chain.ChainID().ToBig() + recipient := sys.Wallet.NewEOA(sys.L2EL).Address() + + ctx, cancel := context.WithTimeout(t.Ctx(), 60*time.Second) + defer cancel() + + nativeBefore, err := l2.BalanceAt(ctx, sender, nil) + t.Require().NoError(err, "get native balance") + testBefore := erc20BalanceOf(t, sys, testFeeCurrencyAddr, sender) + t.Require().Truef(testBefore.Sign() > 0, "sender must have a positive TEST balance, got %s", testBefore) + + nonce, err := l2.PendingNonceAt(ctx, sender) + t.Require().NoError(err, "get nonce") + + tx := types.NewTx(&types.CeloDynamicFeeTxV2{ + ChainID: chainID, + Nonce: nonce, + GasTipCap: big.NewInt(1_000_000_000), // 1 gwei + GasFeeCap: big.NewInt(10_000_000_000), // 10 gwei + Gas: 200_000, + To: &recipient, + Value: big.NewInt(0), + FeeCurrency: &testFeeCurrencyAddr, + }) + signed, err := types.SignTx(tx, types.LatestSignerForChainID(chainID), priv) + t.Require().NoError(err, "sign CIP-64 tx") + t.Require().NoError(l2.SendTransaction(ctx, signed), "send CIP-64 tx") + + var receipt *types.Receipt + t.Require().Eventually(func() bool { + r, err := l2.TransactionReceipt(ctx, signed.Hash()) + if err != nil { + return false + } + receipt = r + return true + }, sys.L2EL.Escape().TransactionTimeout(), time.Second, "tx receipt") + + t.Require().Equal(uint64(1), receipt.Status, "CIP-64 tx must succeed") + + nativeAfter, err := l2.BalanceAt(ctx, sender, nil) + t.Require().NoError(err, "get native balance after") + testAfter := erc20BalanceOf(t, sys, testFeeCurrencyAddr, sender) + + // Native balance must not change: gas was paid in TEST, no value transferred. + t.Require().Equal(0, nativeBefore.Cmp(nativeAfter), + "native balance changed: before=%s after=%s", nativeBefore, nativeAfter) + + // TEST balance must drop — the fee-currency machinery debited gas + intrinsic-gas + // surcharge in TEST tokens. + t.Require().Truef(testAfter.Cmp(testBefore) < 0, + "TEST balance must drop, got before=%s after=%s", testBefore, testAfter) +} diff --git a/op-acceptance-tests/tests/celo/helpers.go b/op-acceptance-tests/tests/celo/helpers.go new file mode 100644 index 00000000000..2381fde74f0 --- /dev/null +++ b/op-acceptance-tests/tests/celo/helpers.go @@ -0,0 +1,70 @@ +package celo + +import ( + "context" + "math/big" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rpc" + "github.com/lmittmann/w3" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/presets" +) + +// Addresses of Celo predeploys deployed by sysgo.WithCelo() (see +// packages/contracts-bedrock/scripts/L2Genesis.s.sol::setCeloPredeploys). +var ( + feeCurrencyDirectoryAddr = common.HexToAddress("0x15F344b9E6c3Cb6F0376A36A64928b13F62C6276") + testFeeCurrencyAddr = common.HexToAddress("0x765DE816845861e75A25fCA122bb6898B8B1282a") +) + +// Foundry "test test test test test test test test test test test junk" mnemonic, +// path m/44'/60'/0'/0/0 — same address that L2Genesis.s.sol pre-funds with 100k TEST. +const foundryDevAccount0PrivKeyHex = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + +// ensureCeloFeeCurrencyOrSkip checks that the FeeCurrencyDirectory predeploy is in +// place and that the test fee currency is registered. Skips the test otherwise so +// the gate can be run against environments without the Celo predeploys. +func ensureCeloFeeCurrencyOrSkip(t devtest.T, sys *presets.Minimal) { + l2 := sys.L2EL.Escape().L2EthClient() + + ctx, cancel := context.WithTimeout(t.Ctx(), 20*time.Second) + defer cancel() + + // Probe the FeeCurrencyDirectory by calling getCurrencyConfig(testFeeCurrency). + // On a non-Celo devnet the call has no code to execute and reverts; on a Celo + // devnet without the test currency registered, the directory itself reverts + // with "currency not registered". Both cases skip. + getCurrencyConfig := w3.MustNewFunc( + "getCurrencyConfig(address)", + "(address oracle, uint256 intrinsicGas)", + ) + data, err := getCurrencyConfig.EncodeArgs(testFeeCurrencyAddr) + t.Require().NoError(err, "encode getCurrencyConfig") + + if _, err := l2.Call(ctx, ethereum.CallMsg{To: &feeCurrencyDirectoryAddr, Data: data}, rpc.LatestBlockNumber); err != nil { + t.Skip("test fee currency not registered with FeeCurrencyDirectory: " + err.Error()) + } +} + +// erc20BalanceOf reads the ERC-20 balance of `account` for the token at `token`. +func erc20BalanceOf(t devtest.T, sys *presets.Minimal, token, account common.Address) *big.Int { + l2 := sys.L2EL.Escape().L2EthClient() + + ctx, cancel := context.WithTimeout(t.Ctx(), 20*time.Second) + defer cancel() + + balanceOf := w3.MustNewFunc("balanceOf(address)", "uint256") + data, err := balanceOf.EncodeArgs(account) + t.Require().NoError(err, "encode balanceOf") + + out, err := l2.Call(ctx, ethereum.CallMsg{To: &token, Data: data}, rpc.LatestBlockNumber) + t.Require().NoError(err, "call balanceOf") + + var bal *big.Int + t.Require().NoError(balanceOf.DecodeReturns(out, &bal), "decode balanceOf") + return bal +} diff --git a/op-acceptance-tests/tests/celo/init_test.go b/op-acceptance-tests/tests/celo/init_test.go new file mode 100644 index 00000000000..9c8cbcbbc7c --- /dev/null +++ b/op-acceptance-tests/tests/celo/init_test.go @@ -0,0 +1,18 @@ +package celo + +import ( + "testing" + + "github.com/ethereum-optimism/optimism/op-devstack/presets" + "github.com/ethereum-optimism/optimism/op-devstack/stack" + "github.com/ethereum-optimism/optimism/op-devstack/sysgo" +) + +func TestMain(m *testing.M) { + presets.DoMain(m, + presets.WithMinimal(), + stack.MakeCommon(sysgo.WithDeployerOptions( + sysgo.WithCelo(), + )), + ) +} From f1f9c6ffe3b605cbf121262243539f91e86281e7 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 29 Apr 2026 15:28:57 +0200 Subject: [PATCH 05/18] contracts: Initialize SortedOracles in setCeloPredeploys Calling SortedOracles.initialize gives the predeploy a non-zero owner (the script deployer) and a non-zero reportExpirySeconds. Previously the proxy was etched but never initialized, leaving owner = address(0) and reportExpirySeconds = 0, which caused isOldestReportExpired() to treat every report as expired and forced deployTestFeeCurrency to prank as address(0) to satisfy the onlyOwner gate on addOracle. --- packages/contracts-bedrock/scripts/L2Genesis.s.sol | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/contracts-bedrock/scripts/L2Genesis.s.sol b/packages/contracts-bedrock/scripts/L2Genesis.s.sol index cbf490e3d3e..760fcda3312 100644 --- a/packages/contracts-bedrock/scripts/L2Genesis.s.sol +++ b/packages/contracts-bedrock/scripts/L2Genesis.s.sol @@ -852,6 +852,9 @@ contract L2Genesis is Script { SortedOracles impl = new SortedOracles({ test: false }); vm.resetNonce(address(impl)); _setupCeloProxy(CeloPredeploys.SORTED_ORACLES, address(impl)); + // Initialize so owner() is non-zero and reportExpirySeconds is set. 1 hour is + // generous enough for tests; production deployments configure their own value. + SortedOracles(CeloPredeploys.SORTED_ORACLES).initialize(1 hours); } function setCeloFeeCurrency() internal { From fc0116526b8bedfc1e4f38819cff84ae79fec380 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 29 Apr 2026 15:32:36 +0200 Subject: [PATCH 06/18] op-devstack: Move Celo miner config out of generic geth.InitL2 The previous fix put FeeCurrencyDefault and a hardcoded cUSD entry into the miner.Config{} literal in op-e2e/e2eutils/geth.InitL2, which applies to every L2 EL the test suite spins up. That polluted the generic L2 init code with Celo-specific defaults. Move both settings to op-devstack/sysgo/l2_el_opgeth.go, gated on the L2 genesis actually containing code at the FeeCurrencyDirectory predeploy address. Only chains booted with sysgo.WithCelo() now configure the MultiGasPool, and InitL2 returns to being Celo-agnostic. --- op-devstack/sysgo/l2_el_opgeth.go | 25 +++++++++++++++++++++++++ op-e2e/e2eutils/geth/geth.go | 11 ----------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/op-devstack/sysgo/l2_el_opgeth.go b/op-devstack/sysgo/l2_el_opgeth.go index 5b2be235e7e..dc8ae1da692 100644 --- a/op-devstack/sysgo/l2_el_opgeth.go +++ b/op-devstack/sysgo/l2_el_opgeth.go @@ -5,9 +5,11 @@ import ( "strings" "sync" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/eth/ethconfig" "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/miner" gn "github.com/ethereum/go-ethereum/node" "github.com/ethereum/go-ethereum/p2p" "github.com/ethereum/go-ethereum/p2p/enode" @@ -22,6 +24,14 @@ import ( "github.com/ethereum-optimism/optimism/op-service/testutils/tcpproxy" ) +// Celo predeploy addresses that the WithCelo() deployer option installs into the +// L2 genesis. Detected here so we can set the matching miner config without +// polluting the generic geth.InitL2 code path with Celo-specific defaults. +var ( + celoFeeCurrencyDirectoryAddr = common.HexToAddress("0x15F344b9E6c3Cb6F0376A36A64928b13F62C6276") + celoTestFeeCurrencyAddr = common.HexToAddress("0x765DE816845861e75A25fCA122bb6898B8B1282a") +) + type OpGeth struct { mu sync.Mutex @@ -118,6 +128,21 @@ func (n *OpGeth) Start() { ethCfg.InteropMessageRPC = n.supervisorRPC ethCfg.InteropMempoolFiltering = true + // If WithCelo() was used, the L2 genesis has the FeeCurrencyDirectory at + // the celo-mainnet address. Configure op-geth's MultiGasPool so CIP-64 txs + // don't get silently popped at block-building time: + // - FeeCurrencyDefault: default per-currency fraction of the block gas limit + // (mirrors miner.DefaultConfig). + // - FeeCurrencyLimits: explicit 0.9 for the registered test fee currency, + // mirroring celo-mainnet's cUSD/USDT/USDC config. + if alloc, ok := n.l2Net.genesis.Alloc[celoFeeCurrencyDirectoryAddr]; ok && len(alloc.Code) > 0 { + ethCfg.Miner.FeeCurrencyDefault = miner.DefaultFeeCurrencyLimit + if ethCfg.Miner.FeeCurrencyLimits == nil { + ethCfg.Miner.FeeCurrencyLimits = map[common.Address]float64{} + } + ethCfg.Miner.FeeCurrencyLimits[celoTestFeeCurrencyAddr] = 0.9 + } + listenAddr := n.cfg.P2PAddr port := n.cfg.P2PPort listenAddr = listenAddr + ":" + strconv.Itoa(port) diff --git a/op-e2e/e2eutils/geth/geth.go b/op-e2e/e2eutils/geth/geth.go index 7f95c259210..d5eed7d2ee5 100644 --- a/op-e2e/e2eutils/geth/geth.go +++ b/op-e2e/e2eutils/geth/geth.go @@ -141,17 +141,6 @@ func InitL2(name string, genesis *core.Genesis, jwtPath string, opts ...GethOpti GasPrice: nil, // enough to build blocks within 1 second, but high enough to avoid unnecessary test CPU cycles. Recommit: time.Millisecond * 400, - // Celo: per-currency fraction of the block gas limit available to fee-currency txs. - // Without these, op-geth's MultiGasPool gives each registered fee currency 0 gas - // and silently drops every CIP-64 tx during block building. The default mirrors - // miner.DefaultConfig; the explicit entry for the test fee currency mirrors - // celo-mainnet's 0.9 limit for cUSD/USDT/USDC. - FeeCurrencyDefault: miner.DefaultFeeCurrencyLimit, - FeeCurrencyLimits: map[common.Address]float64{ - // TEST_FEE_CURRENCY (kept at the celo-mainnet cUSD address for a deterministic - // predeploy slot; only present when sysgo.WithCelo() is set). - common.HexToAddress("0x765DE816845861e75A25fCA122bb6898B8B1282a"): 0.9, - }, }, TxPool: legacypool.Config{ NoLocals: true, From a0c19f2a873601ab3e6cb5d791df9db8774fee2c Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 29 Apr 2026 15:35:44 +0200 Subject: [PATCH 07/18] contracts,op-deployer: Make zero-scalar guard conditional on deployCeloContracts Restore the require()s on basefeeScalar and blobBaseFeeScalar in DeployOPChain.s.sol's checkInput, gated on a new deployCeloContracts field on the Types.DeployOPChainInput struct. Non-Celo chains regain the safety net against accidentally deploying with zero scalars while Celo chains continue to bypass it (where the operator pays all DA cost). The flag is propagated from the chain intent through opcm.DeployOPChainInput into the forge struct, mirroring the existing UseCustomGasToken plumbing. --- op-deployer/pkg/deployer/opcm/opchain.go | 2 ++ op-deployer/pkg/deployer/pipeline/opchain.go | 1 + .../contracts-bedrock/scripts/deploy/DeployOPChain.s.sol | 6 ++++-- packages/contracts-bedrock/scripts/libraries/Types.sol | 3 +++ 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/op-deployer/pkg/deployer/opcm/opchain.go b/op-deployer/pkg/deployer/opcm/opchain.go index 89cef911c5f..72d9a068b2d 100644 --- a/op-deployer/pkg/deployer/opcm/opchain.go +++ b/op-deployer/pkg/deployer/opcm/opchain.go @@ -47,6 +47,8 @@ type DeployOPChainInput struct { SuperchainConfig common.Address UseCustomGasToken bool + + DeployCeloContracts bool } type DeployOPChainOutput struct { diff --git a/op-deployer/pkg/deployer/pipeline/opchain.go b/op-deployer/pkg/deployer/pipeline/opchain.go index f2505f9bfe3..ed2d61cff6f 100644 --- a/op-deployer/pkg/deployer/pipeline/opchain.go +++ b/op-deployer/pkg/deployer/pipeline/opchain.go @@ -168,6 +168,7 @@ func makeDCI(intent *state.Intent, thisIntent *state.ChainIntent, chainID common OperatorFeeConstant: thisIntent.OperatorFeeConstant, SuperchainConfig: st.SuperchainDeployment.SuperchainConfigProxy, UseCustomGasToken: thisIntent.IsCustomGasTokenEnabled(), + DeployCeloContracts: thisIntent.DeployCeloContracts, }, nil } diff --git a/packages/contracts-bedrock/scripts/deploy/DeployOPChain.s.sol b/packages/contracts-bedrock/scripts/deploy/DeployOPChain.s.sol index 9dada1ece1e..bdb437c198f 100644 --- a/packages/contracts-bedrock/scripts/deploy/DeployOPChain.s.sol +++ b/packages/contracts-bedrock/scripts/deploy/DeployOPChain.s.sol @@ -291,8 +291,10 @@ contract DeployOPChain is Script { require(_i.challenger != address(0), "DeployOPChainInput: challenger not set"); // Zero scalars are valid for Celo-style chains where the operator pays all L1 DA cost. - // require(_i.blobBaseFeeScalar != 0, "DeployOPChainInput: blobBaseFeeScalar not set"); - // require(_i.basefeeScalar != 0, "DeployOPChainInput: basefeeScalar not set"); + if (!_i.deployCeloContracts) { + require(_i.blobBaseFeeScalar != 0, "DeployOPChainInput: blobBaseFeeScalar not set"); + require(_i.basefeeScalar != 0, "DeployOPChainInput: basefeeScalar not set"); + } require(_i.gasLimit != 0, "DeployOPChainInput: gasLimit not set"); require(_i.l2ChainId != 0, "DeployOPChainInput: l2ChainId not set"); diff --git a/packages/contracts-bedrock/scripts/libraries/Types.sol b/packages/contracts-bedrock/scripts/libraries/Types.sol index bec2f022e91..ede1d2268f6 100644 --- a/packages/contracts-bedrock/scripts/libraries/Types.sol +++ b/packages/contracts-bedrock/scripts/libraries/Types.sol @@ -54,5 +54,8 @@ library Types { ISuperchainConfig superchainConfig; // Whether to use the custom gas token. bool useCustomGasToken; + // Whether the chain is configured as a Celo chain (operator pays all L1 DA cost, + // basefeeScalar/blobBaseFeeScalar may be zero). + bool deployCeloContracts; } } From fec5c02f162f1914f38546d72f533207663ffa28 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 29 Apr 2026 15:40:40 +0200 Subject: [PATCH 08/18] op-core,op-devstack,op-acceptance-tests: Single source of truth for FeeCurrencyDirectory + TestFeeCurrency Adds FeeCurrencyDirectory and TestFeeCurrency to op-core/predeploys alongside the other Celo predeploys, with matching *Addr vars and CeloPredeploys map entries. Removes the duplicate hardcoded addresses from sysgo/l2_el_opgeth.go and op-acceptance-tests/tests/celo/helpers.go, and references the predeploys package directly from cip64_test.go. Now there's one canonical Go source for these addresses; the Solidity constants in src/celo/CeloPredeploys.sol remain the matching on-chain source. --- op-acceptance-tests/tests/celo/cip64_test.go | 7 ++++--- op-acceptance-tests/tests/celo/helpers.go | 12 +++--------- op-core/predeploys/addresses.go | 12 ++++++++++++ op-devstack/sysgo/l2_el_opgeth.go | 13 +++---------- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/op-acceptance-tests/tests/celo/cip64_test.go b/op-acceptance-tests/tests/celo/cip64_test.go index 5260bb5e047..85a19705741 100644 --- a/op-acceptance-tests/tests/celo/cip64_test.go +++ b/op-acceptance-tests/tests/celo/cip64_test.go @@ -9,6 +9,7 @@ import ( "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum-optimism/optimism/op-core/predeploys" "github.com/ethereum-optimism/optimism/op-devstack/devtest" "github.com/ethereum-optimism/optimism/op-devstack/presets" ) @@ -36,7 +37,7 @@ func TestCIP64_PayGasInTestFeeCurrency(gt *testing.T) { nativeBefore, err := l2.BalanceAt(ctx, sender, nil) t.Require().NoError(err, "get native balance") - testBefore := erc20BalanceOf(t, sys, testFeeCurrencyAddr, sender) + testBefore := erc20BalanceOf(t, sys, predeploys.TestFeeCurrencyAddr, sender) t.Require().Truef(testBefore.Sign() > 0, "sender must have a positive TEST balance, got %s", testBefore) nonce, err := l2.PendingNonceAt(ctx, sender) @@ -50,7 +51,7 @@ func TestCIP64_PayGasInTestFeeCurrency(gt *testing.T) { Gas: 200_000, To: &recipient, Value: big.NewInt(0), - FeeCurrency: &testFeeCurrencyAddr, + FeeCurrency: &predeploys.TestFeeCurrencyAddr, }) signed, err := types.SignTx(tx, types.LatestSignerForChainID(chainID), priv) t.Require().NoError(err, "sign CIP-64 tx") @@ -70,7 +71,7 @@ func TestCIP64_PayGasInTestFeeCurrency(gt *testing.T) { nativeAfter, err := l2.BalanceAt(ctx, sender, nil) t.Require().NoError(err, "get native balance after") - testAfter := erc20BalanceOf(t, sys, testFeeCurrencyAddr, sender) + testAfter := erc20BalanceOf(t, sys, predeploys.TestFeeCurrencyAddr, sender) // Native balance must not change: gas was paid in TEST, no value transferred. t.Require().Equal(0, nativeBefore.Cmp(nativeAfter), diff --git a/op-acceptance-tests/tests/celo/helpers.go b/op-acceptance-tests/tests/celo/helpers.go index 2381fde74f0..ef133381fe6 100644 --- a/op-acceptance-tests/tests/celo/helpers.go +++ b/op-acceptance-tests/tests/celo/helpers.go @@ -10,17 +10,11 @@ import ( "github.com/ethereum/go-ethereum/rpc" "github.com/lmittmann/w3" + "github.com/ethereum-optimism/optimism/op-core/predeploys" "github.com/ethereum-optimism/optimism/op-devstack/devtest" "github.com/ethereum-optimism/optimism/op-devstack/presets" ) -// Addresses of Celo predeploys deployed by sysgo.WithCelo() (see -// packages/contracts-bedrock/scripts/L2Genesis.s.sol::setCeloPredeploys). -var ( - feeCurrencyDirectoryAddr = common.HexToAddress("0x15F344b9E6c3Cb6F0376A36A64928b13F62C6276") - testFeeCurrencyAddr = common.HexToAddress("0x765DE816845861e75A25fCA122bb6898B8B1282a") -) - // Foundry "test test test test test test test test test test test junk" mnemonic, // path m/44'/60'/0'/0/0 — same address that L2Genesis.s.sol pre-funds with 100k TEST. const foundryDevAccount0PrivKeyHex = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" @@ -42,10 +36,10 @@ func ensureCeloFeeCurrencyOrSkip(t devtest.T, sys *presets.Minimal) { "getCurrencyConfig(address)", "(address oracle, uint256 intrinsicGas)", ) - data, err := getCurrencyConfig.EncodeArgs(testFeeCurrencyAddr) + data, err := getCurrencyConfig.EncodeArgs(predeploys.TestFeeCurrencyAddr) t.Require().NoError(err, "encode getCurrencyConfig") - if _, err := l2.Call(ctx, ethereum.CallMsg{To: &feeCurrencyDirectoryAddr, Data: data}, rpc.LatestBlockNumber); err != nil { + if _, err := l2.Call(ctx, ethereum.CallMsg{To: &predeploys.FeeCurrencyDirectoryAddr, Data: data}, rpc.LatestBlockNumber); err != nil { t.Skip("test fee currency not registered with FeeCurrencyDirectory: " + err.Error()) } } diff --git a/op-core/predeploys/addresses.go b/op-core/predeploys/addresses.go index 89ca5f2f1a2..153fd859c14 100644 --- a/op-core/predeploys/addresses.go +++ b/op-core/predeploys/addresses.go @@ -57,6 +57,14 @@ const ( SortedOracles = "0xefb84935239dacdecf7c5ba76d8de40b077b7b33" AddressSortedLinkedListWithMedian = "0xED477A99035d0c1e11369F1D7A4e587893cc002B" FeeCurrency = "0x4200000000000000000000000000000000001022" + // FeeCurrencyDirectory matches the celo-mainnet address that op-geth's + // addresses.MainnetAddresses defaults to when no chain-specific entry exists. + FeeCurrencyDirectory = "0x15F344b9E6c3Cb6F0376A36A64928b13F62C6276" + // TestFeeCurrency is a non-production fee currency installed by L2Genesis when + // deployCeloContracts is set, registered with the FeeCurrencyDirectory so CIP-64 + // transactions can pay gas in it. The address is the celo-mainnet cUSD address + // (kept for a deterministic predeploy slot). + TestFeeCurrency = "0x765DE816845861e75A25fCA122bb6898B8B1282a" ) var ( @@ -114,6 +122,8 @@ var ( SortedOraclesAddr = common.HexToAddress(SortedOracles) AddressSortedLinkedListWithMedianAddr = common.HexToAddress(AddressSortedLinkedListWithMedian) FeeCurrencyAddr = common.HexToAddress(FeeCurrency) + FeeCurrencyDirectoryAddr = common.HexToAddress(FeeCurrencyDirectory) + TestFeeCurrencyAddr = common.HexToAddress(TestFeeCurrency) CeloPredeploys = make(map[string]*Predeploy) ) @@ -213,6 +223,8 @@ func init() { CeloPredeploys["SortedOracles"] = &Predeploy{Address: SortedOraclesAddr} CeloPredeploys["AddressSortedLinkedListWithMedian"] = &Predeploy{Address: AddressSortedLinkedListWithMedianAddr} CeloPredeploys["FeeCurrency"] = &Predeploy{Address: FeeCurrencyAddr} + CeloPredeploys["FeeCurrencyDirectory"] = &Predeploy{Address: FeeCurrencyDirectoryAddr} + CeloPredeploys["TestFeeCurrency"] = &Predeploy{Address: TestFeeCurrencyAddr} for key, predeploy := range CeloPredeploys { Predeploys[key] = predeploy } diff --git a/op-devstack/sysgo/l2_el_opgeth.go b/op-devstack/sysgo/l2_el_opgeth.go index dc8ae1da692..6b60f6d6d16 100644 --- a/op-devstack/sysgo/l2_el_opgeth.go +++ b/op-devstack/sysgo/l2_el_opgeth.go @@ -15,6 +15,7 @@ import ( "github.com/ethereum/go-ethereum/p2p/enode" "github.com/ethereum/go-ethereum/rpc" + "github.com/ethereum-optimism/optimism/op-core/predeploys" "github.com/ethereum-optimism/optimism/op-devstack/devtest" "github.com/ethereum-optimism/optimism/op-devstack/shim" "github.com/ethereum-optimism/optimism/op-devstack/stack" @@ -24,14 +25,6 @@ import ( "github.com/ethereum-optimism/optimism/op-service/testutils/tcpproxy" ) -// Celo predeploy addresses that the WithCelo() deployer option installs into the -// L2 genesis. Detected here so we can set the matching miner config without -// polluting the generic geth.InitL2 code path with Celo-specific defaults. -var ( - celoFeeCurrencyDirectoryAddr = common.HexToAddress("0x15F344b9E6c3Cb6F0376A36A64928b13F62C6276") - celoTestFeeCurrencyAddr = common.HexToAddress("0x765DE816845861e75A25fCA122bb6898B8B1282a") -) - type OpGeth struct { mu sync.Mutex @@ -135,12 +128,12 @@ func (n *OpGeth) Start() { // (mirrors miner.DefaultConfig). // - FeeCurrencyLimits: explicit 0.9 for the registered test fee currency, // mirroring celo-mainnet's cUSD/USDT/USDC config. - if alloc, ok := n.l2Net.genesis.Alloc[celoFeeCurrencyDirectoryAddr]; ok && len(alloc.Code) > 0 { + if alloc, ok := n.l2Net.genesis.Alloc[predeploys.FeeCurrencyDirectoryAddr]; ok && len(alloc.Code) > 0 { ethCfg.Miner.FeeCurrencyDefault = miner.DefaultFeeCurrencyLimit if ethCfg.Miner.FeeCurrencyLimits == nil { ethCfg.Miner.FeeCurrencyLimits = map[common.Address]float64{} } - ethCfg.Miner.FeeCurrencyLimits[celoTestFeeCurrencyAddr] = 0.9 + ethCfg.Miner.FeeCurrencyLimits[predeploys.TestFeeCurrencyAddr] = 0.9 } listenAddr := n.cfg.P2PAddr From 674d72ccc4894a121bdb42e20003abe1147ed37b Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 29 Apr 2026 15:41:35 +0200 Subject: [PATCH 09/18] op-deployer: Use predeploys constants in TestApplyDeployCeloContracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace inlined hex addresses with the existing predeploys.*Addr constants (now including the FeeCurrencyDirectory and TestFeeCurrency entries added in the previous commit). A typo in any of these literals would have produced a silent false-positive — the constants close that gap. --- .../pkg/deployer/integration_test/apply_test.go | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/op-deployer/pkg/deployer/integration_test/apply_test.go b/op-deployer/pkg/deployer/integration_test/apply_test.go index 7e5ae256a95..e429a5f61be 100644 --- a/op-deployer/pkg/deployer/integration_test/apply_test.go +++ b/op-deployer/pkg/deployer/integration_test/apply_test.go @@ -550,12 +550,12 @@ func TestApplyDeployCeloContracts(t *testing.T) { // Predeploys (proxies — code is the EIP-1967 Proxy bytecode at these addresses). proxied := []common.Address{ - common.HexToAddress("0x000000000000000000000000000000000000ce10"), // CeloRegistry - common.HexToAddress("0x471EcE3750Da237f93B8E339c536989b8978a438"), // GoldToken - common.HexToAddress("0xcD437749E43A154C07F3553504c68fBfD56B8778"), // FeeHandler - common.HexToAddress("0xefB84935239dAcdecF7c5bA76d8dE40b077B7b33"), // SortedOracles - common.HexToAddress("0x15F344b9E6c3Cb6F0376A36A64928b13F62C6276"), // FeeCurrencyDirectory - common.HexToAddress("0x765DE816845861e75A25fCA122bb6898B8B1282a"), // TestFeeCurrency (StableTokenV2) + predeploys.CeloRegistryAddr, + predeploys.GoldTokenAddr, + predeploys.FeeHandlerAddr, + predeploys.SortedOraclesAddr, + predeploys.FeeCurrencyDirectoryAddr, + predeploys.TestFeeCurrencyAddr, // StableTokenV2 instance registered as a fee currency } for _, addr := range proxied { acc, ok := allocs[addr] @@ -564,8 +564,7 @@ func TestApplyDeployCeloContracts(t *testing.T) { } // Library — etched directly, not proxied. - libAddr := common.HexToAddress("0xED477A99035d0c1e11369F1D7A4e587893cc002B") - acc, ok := allocs[libAddr] + acc, ok := allocs[predeploys.AddressSortedLinkedListWithMedianAddr] require.True(t, ok, "no account at AddressSortedLinkedListWithMedian") require.NotEmpty(t, acc.Code) } From fbc2ab10422225d727da066cfdb9979680d2b351 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 29 Apr 2026 15:42:53 +0200 Subject: [PATCH 10/18] op-deployer: Rename celoOrDefault to celoOrDefaultScalar for consistency The same helper exists in both pipeline/opchain.go and state/deploy_config.go. They live in different packages and can't share the symbol, but the names should match for grep-ability and to make the duplication explicit. Aligns the comment text too. --- op-deployer/pkg/deployer/pipeline/opchain.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/op-deployer/pkg/deployer/pipeline/opchain.go b/op-deployer/pkg/deployer/pipeline/opchain.go index ed2d61cff6f..741f24011c3 100644 --- a/op-deployer/pkg/deployer/pipeline/opchain.go +++ b/op-deployer/pkg/deployer/pipeline/opchain.go @@ -151,8 +151,8 @@ func makeDCI(intent *state.Intent, thisIntent *state.ChainIntent, chainID common UnsafeBlockSigner: thisIntent.Roles.UnsafeBlockSigner, Proposer: thisIntent.Roles.Proposer, Challenger: thisIntent.Roles.Challenger, - BasefeeScalar: celoOrDefault(thisIntent.DeployCeloContracts, standard.BasefeeScalar), - BlobBaseFeeScalar: celoOrDefault(thisIntent.DeployCeloContracts, standard.BlobBaseFeeScalar), + BasefeeScalar: celoOrDefaultScalar(thisIntent.DeployCeloContracts, standard.BasefeeScalar), + BlobBaseFeeScalar: celoOrDefaultScalar(thisIntent.DeployCeloContracts, standard.BlobBaseFeeScalar), L2ChainId: chainID.Big(), Opcm: opcmAddr, SaltMixer: st.Create2Salt.String(), // passing through salt generated at state initialization @@ -172,9 +172,9 @@ func makeDCI(intent *state.Intent, thisIntent *state.ChainIntent, chainID common }, nil } -// celoOrDefault returns 0 when Celo contracts are deployed (so the chain operator pays -// all L1 DA cost) and the standard default otherwise. -func celoOrDefault(celo bool, def uint32) uint32 { +// celoOrDefaultScalar returns 0 when Celo contracts are deployed (so the chain operator +// pays all L1 DA cost on behalf of users) and the standard default otherwise. +func celoOrDefaultScalar(celo bool, def uint32) uint32 { if celo { return 0 } From b137861416e4778479e501c2f43b2c81fa724bd6 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 29 Apr 2026 15:47:54 +0200 Subject: [PATCH 11/18] contracts: Use vm.prank for single-call pranks in setCeloPredeploys L2Genesis already uses vm.prank for single-call prank scopes elsewhere (e.g., activateEcotone). Bring the four startPrank/stopPrank pairs in setFeeCurrencyDirectory and deployTestFeeCurrency in line. --- packages/contracts-bedrock/scripts/L2Genesis.s.sol | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/contracts-bedrock/scripts/L2Genesis.s.sol b/packages/contracts-bedrock/scripts/L2Genesis.s.sol index 760fcda3312..43e178cf27c 100644 --- a/packages/contracts-bedrock/scripts/L2Genesis.s.sol +++ b/packages/contracts-bedrock/scripts/L2Genesis.s.sol @@ -870,9 +870,8 @@ contract L2Genesis is Script { // Initialize the proxy and transfer ownership to devAccounts[0] so devnets can // edit the directory at runtime. - vm.startPrank(devAccounts[0]); + vm.prank(devAccounts[0]); FeeCurrencyDirectory(CeloPredeploys.FEE_CURRENCY_DIRECTORY).initialize(); - vm.stopPrank(); } /// @notice Deploys a test fee currency (StableTokenV2) at its predeploy address, mints @@ -897,15 +896,13 @@ contract L2Genesis is Script { // Register _deployer as an oracle for the test currency. addOracle is onlyOwner. SortedOracles sortedOracles = SortedOracles(CeloPredeploys.SORTED_ORACLES); - vm.startPrank(sortedOracles.owner()); + vm.prank(sortedOracles.owner()); sortedOracles.addOracle(CeloPredeploys.TEST_FEE_CURRENCY, _deployer); - vm.stopPrank(); // Report a price as the registered oracle. if (_price != 0) { - vm.startPrank(_deployer); + vm.prank(_deployer); sortedOracles.report(CeloPredeploys.TEST_FEE_CURRENCY, _price * 1e24, address(0), address(0)); - vm.stopPrank(); } // Register the test currency with the FeeCurrencyDirectory so the EL recognizes it @@ -913,10 +910,9 @@ contract L2Genesis is Script { // debitGasFees / creditGasFees calls into the fee-currency contract. uint256 intrinsicGas = 80_000; FeeCurrencyDirectory feeCurrencyDirectory = FeeCurrencyDirectory(CeloPredeploys.FEE_CURRENCY_DIRECTORY); - vm.startPrank(feeCurrencyDirectory.owner()); + vm.prank(feeCurrencyDirectory.owner()); feeCurrencyDirectory.setCurrencyConfig( CeloPredeploys.TEST_FEE_CURRENCY, address(sortedOracles), intrinsicGas ); - vm.stopPrank(); } } From 5eeab6b6b5164539793f6159ac7df602f98722fb Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 29 Apr 2026 15:53:11 +0200 Subject: [PATCH 12/18] op-devstack,op-e2e: Drop unused bool param from WithDeployCeloContracts The L2Configurator interface method took a bool, but the only caller (sysgo.WithCelo) always passed true. Calling with false was a no-op and confusing. Make the method nullary and unconditionally enable the flag, matching how WithCustomGasToken signals enablement via its presence. --- op-devstack/sysgo/deployer.go | 2 +- op-e2e/e2eutils/intentbuilder/builder.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/op-devstack/sysgo/deployer.go b/op-devstack/sysgo/deployer.go index e4340385b95..faef7e89163 100644 --- a/op-devstack/sysgo/deployer.go +++ b/op-devstack/sysgo/deployer.go @@ -412,7 +412,7 @@ func WithCustomGasToken(name, symbol string, initialLiquidity *big.Int, liquidit func WithCelo() DeployerOption { return func(p devtest.P, keys devkeys.Keys, builder intentbuilder.Builder) { for _, l2Cfg := range builder.L2s() { - l2Cfg.WithDeployCeloContracts(true) + l2Cfg.WithDeployCeloContracts() } } } diff --git a/op-e2e/e2eutils/intentbuilder/builder.go b/op-e2e/e2eutils/intentbuilder/builder.go index a169971681d..26445e0c7fa 100644 --- a/op-e2e/e2eutils/intentbuilder/builder.go +++ b/op-e2e/e2eutils/intentbuilder/builder.go @@ -56,7 +56,7 @@ type L2Configurator interface { WithFinalizationPeriodSeconds(value uint64) WithRevenueShare(enabled bool, chainFeesRecipient common.Address) WithCustomGasToken(name string, symbol string, initialLiquidity *big.Int, liquidityControllerOwner common.Address) - WithDeployCeloContracts(enabled bool) + WithDeployCeloContracts() ContractsConfigurator L2VaultsConfigurator L2RolesConfigurator @@ -481,8 +481,8 @@ func (c *l2Configurator) WithCustomGasToken(name, symbol string, initialLiquidit } } -func (c *l2Configurator) WithDeployCeloContracts(enabled bool) { - c.builder.intent.Chains[c.chainIndex].DeployCeloContracts = enabled +func (c *l2Configurator) WithDeployCeloContracts() { + c.builder.intent.Chains[c.chainIndex].DeployCeloContracts = true } func (c *l2Configurator) WithEIP1559Elasticity(value uint64) { From df367753ee1cbaf255c2c7a61f290777cfcf8cb1 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 29 Apr 2026 16:27:04 +0200 Subject: [PATCH 13/18] contracts,op-chain-ops: Update remaining L2GenesisInput / DeployOPChainInput literals The new DeployCeloContracts field on opcm.L2GenesisInput / DeployOPChainInput broke two struct literals that exhaustruct flagged in CI: the L2 setup harness in test/setup/Setup.sol and the interop genesis path in op-chain-ops/interopgen. Setup.sol pipes the new field from DeployConfig (which already exposes it); interopgen never deploys Celo contracts so it's hardwired to false. --- op-chain-ops/interopgen/deploy.go | 2 ++ packages/contracts-bedrock/test/setup/Setup.sol | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/op-chain-ops/interopgen/deploy.go b/op-chain-ops/interopgen/deploy.go index 634177b022e..90e88fba982 100644 --- a/op-chain-ops/interopgen/deploy.go +++ b/op-chain-ops/interopgen/deploy.go @@ -257,6 +257,7 @@ func DeployL2ToL1(l1Host *script.Host, superCfg *SuperchainConfig, superDeployme OperatorFeeConstant: cfg.GasPriceOracleOperatorFeeConstant, SuperchainConfig: superDeployment.SuperchainConfigProxy, UseCustomGasToken: cfg.UseCustomGasToken, + DeployCeloContracts: false, }) if err != nil { return nil, fmt.Errorf("failed to deploy L2 OP chain: %w", err) @@ -356,6 +357,7 @@ func GenesisL2(l2Host *script.Host, cfg *L2Config, deployment *L2Deployment, mul NativeAssetLiquidityAmount: cfg.NativeAssetLiquidityAmount.ToInt(), LiquidityControllerOwner: cfg.LiquidityControllerOwner, UseL2CM: false, // TODO(#19102): add support for L2CM + DeployCeloContracts: false, }); err != nil { return fmt.Errorf("failed L2 genesis: %w", err) } diff --git a/packages/contracts-bedrock/test/setup/Setup.sol b/packages/contracts-bedrock/test/setup/Setup.sol index b67e7105e33..747edf630d3 100644 --- a/packages/contracts-bedrock/test/setup/Setup.sol +++ b/packages/contracts-bedrock/test/setup/Setup.sol @@ -408,7 +408,8 @@ abstract contract Setup is FeatureFlags { gasPayingTokenSymbol: deploy.cfg().gasPayingTokenSymbol(), nativeAssetLiquidityAmount: deploy.cfg().nativeAssetLiquidityAmount(), liquidityControllerOwner: deploy.cfg().liquidityControllerOwner(), - useL2CM: deploy.cfg().useL2CM() + useL2CM: deploy.cfg().useL2CM(), + deployCeloContracts: deploy.cfg().deployCeloContracts() }) ); From 5ffb3fb774c624bad034ce40ad9ac564238f3df3 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 29 Apr 2026 16:27:20 +0200 Subject: [PATCH 14/18] contracts: Move AddressSortedLinkedListWithMedian library link to foundry.toml CI runs forge build --deny-warnings --skip test (no --libraries) and produced artifacts whose bytecode still had link placeholders, which then prevented op-deployer from loading the L2Genesis script. Pin the library address in foundry.toml so every forge build invocation links it automatically; drop the now-redundant flag from the build-no-tests recipe. --- packages/contracts-bedrock/foundry.toml | 8 ++++++++ packages/contracts-bedrock/justfile | 5 +---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/contracts-bedrock/foundry.toml b/packages/contracts-bedrock/foundry.toml index 4eff46f230b..226bb6c03c6 100644 --- a/packages/contracts-bedrock/foundry.toml +++ b/packages/contracts-bedrock/foundry.toml @@ -42,6 +42,14 @@ bytecode_hash = 'none' ast = true evm_version = 'cancun' +# Link the AddressSortedLinkedListWithMedian library to its predeploy address. The +# library is referenced by Celo contracts (SortedOracles et al.) — without this the +# generated bytecode contains link placeholders and op-deployer can't load the +# resulting artifacts. +libraries = [ + 'src/celo/common/linkedlists/AddressSortedLinkedListWithMedian.sol:AddressSortedLinkedListWithMedian:0xED477A99035d0c1e11369F1D7A4e587893cc002B', +] + remappings = [ '@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts', '@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts', diff --git a/packages/contracts-bedrock/justfile b/packages/contracts-bedrock/justfile index 97da4a366c7..56ab962526e 100644 --- a/packages/contracts-bedrock/justfile +++ b/packages/contracts-bedrock/justfile @@ -47,11 +47,8 @@ build-source: forge build --skip "/**/test/**" --skip "/**/scripts/**" # Builds source contracts and scripts, skipping tests. -# Links AddressSortedLinkedListWithMedian to its predeploy address so the L2Genesis -# script can be loaded by op-deployer when deployCeloContracts is enabled. build-no-tests: - forge build --skip "/**/test/**" --libraries \ - "src/celo/common/linkedlists/AddressSortedLinkedListWithMedian.sol:AddressSortedLinkedListWithMedian:0xED477A99035d0c1e11369F1D7A4e587893cc002B" + forge build --skip "/**/test/**" # Builds the contracts. build *ARGS: lint-fix-no-fail From 021c193667a150f3e147a116735cb48cb1e142c8 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 29 Apr 2026 16:31:11 +0200 Subject: [PATCH 15/18] op-acceptance-tests: Make ensureCeloFeeCurrencyOrSkip actually skip non-Celo devnets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit eth_call against an address with no code returns empty bytes without an RPC error, so the previous guard didn't trip on chains where the FeeCurrencyDirectory predeploy is missing — the test would fall through and fail on unrelated ABI decoding instead. Skip when eth_getCode at the FeeCurrencyDirectory address returns empty, and when getCurrencyConfig(testFeeCurrency) reports a zero oracle (the default for unregistered tokens; the call returns a zero-valued struct rather than reverting). Caught by Codex on PR #440. --- op-acceptance-tests/tests/celo/helpers.go | 26 +++++++++++++++++------ 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/op-acceptance-tests/tests/celo/helpers.go b/op-acceptance-tests/tests/celo/helpers.go index ef133381fe6..a644649b167 100644 --- a/op-acceptance-tests/tests/celo/helpers.go +++ b/op-acceptance-tests/tests/celo/helpers.go @@ -28,19 +28,31 @@ func ensureCeloFeeCurrencyOrSkip(t devtest.T, sys *presets.Minimal) { ctx, cancel := context.WithTimeout(t.Ctx(), 20*time.Second) defer cancel() - // Probe the FeeCurrencyDirectory by calling getCurrencyConfig(testFeeCurrency). - // On a non-Celo devnet the call has no code to execute and reverts; on a Celo - // devnet without the test currency registered, the directory itself reverts - // with "currency not registered". Both cases skip. + // Skip if the FeeCurrencyDirectory has no code: eth_call on an empty account + // silently returns empty bytes rather than reverting, so we have to check code + // length explicitly. + header, err := l2.InfoByLabel(ctx, "latest") + t.Require().NoError(err, "fetch latest L2 header") + code, err := l2.CodeAtHash(ctx, predeploys.FeeCurrencyDirectoryAddr, header.Hash()) + t.Require().NoError(err, "eth_getCode FeeCurrencyDirectory") + if len(code) == 0 { + t.Skip("FeeCurrencyDirectory predeploy missing — chain is not running with Celo predeploys") + } + + // Confirm the test fee currency is actually registered. An unregistered token + // returns a zero-valued config (oracle = address(0)) without reverting, so the + // presence of a non-zero oracle is what proves registration. The first 32 bytes + // of the ABI-encoded return are the oracle address (left-padded). getCurrencyConfig := w3.MustNewFunc( "getCurrencyConfig(address)", "(address oracle, uint256 intrinsicGas)", ) data, err := getCurrencyConfig.EncodeArgs(predeploys.TestFeeCurrencyAddr) t.Require().NoError(err, "encode getCurrencyConfig") - - if _, err := l2.Call(ctx, ethereum.CallMsg{To: &predeploys.FeeCurrencyDirectoryAddr, Data: data}, rpc.LatestBlockNumber); err != nil { - t.Skip("test fee currency not registered with FeeCurrencyDirectory: " + err.Error()) + out, err := l2.Call(ctx, ethereum.CallMsg{To: &predeploys.FeeCurrencyDirectoryAddr, Data: data}, rpc.LatestBlockNumber) + t.Require().NoError(err, "FeeCurrencyDirectory.getCurrencyConfig") + if len(out) < 32 || common.BytesToAddress(out[:32]) == (common.Address{}) { + t.Skip("test fee currency not registered with FeeCurrencyDirectory") } } From 6840eb55585124dab347ed74a7fa7de1a1a98ae7 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 29 Apr 2026 17:42:05 +0200 Subject: [PATCH 16/18] contracts: Move AddressSortedLinkedListWithMedian library link to forge-build recipe Putting the library address in foundry.toml's libraries array also writes it into artifact metadata in a format that breaks Go-side artifact loaders (json: cannot unmarshal string into map[string]string). Set the link via the --libraries CLI flag in the central forge-build justfile recipe instead, so every CI invocation that goes through `just forge-build` picks it up without affecting metadata. --- packages/contracts-bedrock/foundry.toml | 8 -------- packages/contracts-bedrock/justfile | 9 +++++++-- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/contracts-bedrock/foundry.toml b/packages/contracts-bedrock/foundry.toml index 226bb6c03c6..4eff46f230b 100644 --- a/packages/contracts-bedrock/foundry.toml +++ b/packages/contracts-bedrock/foundry.toml @@ -42,14 +42,6 @@ bytecode_hash = 'none' ast = true evm_version = 'cancun' -# Link the AddressSortedLinkedListWithMedian library to its predeploy address. The -# library is referenced by Celo contracts (SortedOracles et al.) — without this the -# generated bytecode contains link placeholders and op-deployer can't load the -# resulting artifacts. -libraries = [ - 'src/celo/common/linkedlists/AddressSortedLinkedListWithMedian.sol:AddressSortedLinkedListWithMedian:0xED477A99035d0c1e11369F1D7A4e587893cc002B', -] - remappings = [ '@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts', '@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts', diff --git a/packages/contracts-bedrock/justfile b/packages/contracts-bedrock/justfile index 56ab962526e..b94f54ed77b 100644 --- a/packages/contracts-bedrock/justfile +++ b/packages/contracts-bedrock/justfile @@ -16,9 +16,14 @@ dep-status: ######################################################## # Core forge build command. - +# +# AddressSortedLinkedListWithMedian is a library used by Celo predeploys; without +# the explicit address forge writes link placeholders into the bytecode and +# downstream artifact loaders fail. Configuring this in foundry.toml's libraries +# array works for forge but writes a metadata format that breaks Go-side artifact +# parsers, so we set it on the CLI instead — applied to every forge build. forge-build *ARGS: - forge build {{ARGS}} + forge build --libraries "src/celo/common/linkedlists/AddressSortedLinkedListWithMedian.sol:AddressSortedLinkedListWithMedian:0xED477A99035d0c1e11369F1D7A4e587893cc002B" {{ARGS}} @# Forge build compiles only the src/ graph; the scripts/ graph is compiled by `forge script`. @# On the first invocation, `forge script` may compile a small set of dependencies. From 999df5f7966444068a985bc129c80596427be28f Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 29 Apr 2026 17:42:30 +0200 Subject: [PATCH 17/18] contracts: Update test struct literals for new deployCeloContracts field forge test caught two more constructor mismatches: DeployOPChain.t.sol's Types.DeployOPChainInput literal and L2Genesis.t.sol's L2Genesis.Input literal. Both default the new field to false (these tests don't exercise the Celo predeploy path). --- packages/contracts-bedrock/test/opcm/DeployOPChain.t.sol | 3 ++- packages/contracts-bedrock/test/scripts/L2Genesis.t.sol | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/contracts-bedrock/test/opcm/DeployOPChain.t.sol b/packages/contracts-bedrock/test/opcm/DeployOPChain.t.sol index 961e05cccd1..38d474df00a 100644 --- a/packages/contracts-bedrock/test/opcm/DeployOPChain.t.sol +++ b/packages/contracts-bedrock/test/opcm/DeployOPChain.t.sol @@ -141,7 +141,8 @@ contract DeployOPChain_TestBase is Test, FeatureFlags { operatorFeeScalar: 0, operatorFeeConstant: 0, superchainConfig: superchainConfig, - useCustomGasToken: useCustomGasToken + useCustomGasToken: useCustomGasToken, + deployCeloContracts: false }); } } diff --git a/packages/contracts-bedrock/test/scripts/L2Genesis.t.sol b/packages/contracts-bedrock/test/scripts/L2Genesis.t.sol index 7750eff501e..f1bf5354c5e 100644 --- a/packages/contracts-bedrock/test/scripts/L2Genesis.t.sol +++ b/packages/contracts-bedrock/test/scripts/L2Genesis.t.sol @@ -265,7 +265,8 @@ contract L2Genesis_Run_Test is L2Genesis_TestInit { gasPayingTokenSymbol: "", nativeAssetLiquidityAmount: type(uint248).max, liquidityControllerOwner: address(0x000000000000000000000000000000000000000d), - useL2CM: false + useL2CM: false, + deployCeloContracts: false }); } From ae871dbb0736e08afbd30dc815039e6e34d5b4fe Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 29 Apr 2026 17:53:50 +0200 Subject: [PATCH 18/18] ci: Add dedicated memory-celo acceptance-test job The existing memory-all gateless job already discovers and runs the celo gate alongside everything else, but adding a dedicated CI job makes celo failures visible independently and lets us iterate on the gate without sifting through the larger memory-all results. --- .circleci/continue/main.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.circleci/continue/main.yml b/.circleci/continue/main.yml index 7ce6c49f7eb..d670aa52c88 100644 --- a/.circleci/continue/main.yml +++ b/.circleci/continue/main.yml @@ -3198,6 +3198,20 @@ workflows: - cannon-prestate - rust-binaries-for-sysgo - go-binaries-for-sysgo + # IN-MEMORY (celo) — dedicated job for the Celo CIP-64 acceptance gate so + # failures are visible independently of the gateless memory-all job. + - op-acceptance-tests: + name: memory-celo + gate: celo + context: + - circleci-repo-readonly-authenticated-github-token + - slack + - discord + requires: + - contracts-bedrock-build + - cannon-prestate + - rust-binaries-for-sysgo + - go-binaries-for-sysgo # Generate flaky test report - generate-flaky-report: name: generate-flaky-tests-report