From 0179e8ac43b7e0702944392de56b2513293a5260 Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Thu, 7 May 2026 09:58:06 +0200 Subject: [PATCH 01/29] test(withdraw-post-karst): acceptance test for withdrawing on karst --- .../base/withdrawal/withdrawal_test_helper.go | 12 ++++++------ .../tests/karst/withdrawal_test.go | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 op-acceptance-tests/tests/karst/withdrawal_test.go diff --git a/op-acceptance-tests/tests/base/withdrawal/withdrawal_test_helper.go b/op-acceptance-tests/tests/base/withdrawal/withdrawal_test_helper.go index bcbb0ec6ab6..6a3f40a641b 100644 --- a/op-acceptance-tests/tests/base/withdrawal/withdrawal_test_helper.go +++ b/op-acceptance-tests/tests/base/withdrawal/withdrawal_test_helper.go @@ -11,7 +11,7 @@ import ( "github.com/ethereum-optimism/optimism/op-service/eth" ) -func withdrawalOpts(gameType gameTypes.GameType) []presets.Option { +func withdrawalOpts(gameType gameTypes.GameType, extra ...presets.Option) []presets.Option { opts := []presets.Option{ presets.WithTimeTravelEnabled(), presets.WithDeployerOptions( @@ -27,16 +27,16 @@ func withdrawalOpts(gameType gameTypes.GameType) []presets.Option { cfg.DisputeGameType = uint32(gameType) }), } - return opts + return append(opts, extra...) } -func newSystem(t devtest.T, gameType gameTypes.GameType) *presets.Minimal { - return presets.NewMinimal(t, withdrawalOpts(gameType)...) +func newSystem(t devtest.T, gameType gameTypes.GameType, extra ...presets.Option) *presets.Minimal { + return presets.NewMinimal(t, withdrawalOpts(gameType, extra...)...) } -func TestWithdrawal(gt *testing.T, gameType gameTypes.GameType) { +func TestWithdrawal(gt *testing.T, gameType gameTypes.GameType, extra ...presets.Option) { t := devtest.ParallelT(gt) - sys := newSystem(t, gameType) + sys := newSystem(t, gameType, extra...) bridge := sys.StandardBridge() bridge.VerifyRespectedGameType(gameType) diff --git a/op-acceptance-tests/tests/karst/withdrawal_test.go b/op-acceptance-tests/tests/karst/withdrawal_test.go new file mode 100644 index 00000000000..5eab1a1c7e3 --- /dev/null +++ b/op-acceptance-tests/tests/karst/withdrawal_test.go @@ -0,0 +1,18 @@ +package karst + +import ( + "testing" + + "github.com/ethereum-optimism/optimism/op-acceptance-tests/tests/base/withdrawal" + gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types" + "github.com/ethereum-optimism/optimism/op-devstack/presets" + "github.com/ethereum-optimism/optimism/op-devstack/sysgo" +) + +// TestWithdrawal_Karst creates a withdrawal from the L2StandardBridge and +// observes the full withdrawal flow, including finalization on L1. +func TestWithdrawal_Karst(gt *testing.T) { + withdrawal.TestWithdrawal(gt, gameTypes.CannonGameType, + presets.WithDeployerOptions(sysgo.WithKarstAtGenesis), + ) +} From 9782ee95c2278e042bb2e3e12d4662fe06fe0feb Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Mon, 11 May 2026 17:04:49 +0200 Subject: [PATCH 02/29] test(acceptance-test-l2cm-karst): add acceptance test for doing a minimal upgrade with l2cm --- .../tests/karst/upgrade_test.go | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 op-acceptance-tests/tests/karst/upgrade_test.go diff --git a/op-acceptance-tests/tests/karst/upgrade_test.go b/op-acceptance-tests/tests/karst/upgrade_test.go new file mode 100644 index 00000000000..d5f952ae7eb --- /dev/null +++ b/op-acceptance-tests/tests/karst/upgrade_test.go @@ -0,0 +1,197 @@ +package karst + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/ethereum-optimism/optimism/op-chain-ops/devkeys" + "github.com/ethereum-optimism/optimism/op-chain-ops/genesis" + gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types" + "github.com/ethereum-optimism/optimism/op-core/predeploys" + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-devstack/presets" + "github.com/ethereum-optimism/optimism/op-devstack/sysgo" + ps "github.com/ethereum-optimism/optimism/op-proposer/proposer" + opservice "github.com/ethereum-optimism/optimism/op-service" + "github.com/ethereum-optimism/optimism/op-service/apis" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/txplan" + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" +) + +var conditionalDeployerAddr = common.HexToAddress("0x420000000000000000000000000000000000002C") + +func loadSchemaRegistryInitcode(t devtest.T) []byte { + wd, err := os.Getwd() + t.Require().NoError(err) + root, err := opservice.FindMonorepoRoot(wd) + t.Require().NoError(err) + + artifactPath := filepath.Join(root, "packages", "contracts-bedrock", "forge-artifacts", "SchemaRegistry.sol", "SchemaRegistry.json") + data, err := os.ReadFile(artifactPath) + t.Require().NoError(err, "failed to read SchemaRegistry artifact") + + var artifact struct { + Bytecode struct { + Object string `json:"object"` + } `json:"bytecode"` + } + t.Require().NoError(json.Unmarshal(data, &artifact)) + t.Require().NotEmpty(artifact.Bytecode.Object) + return common.FromHex(artifact.Bytecode.Object) +} + +// callVersionString calls version() on a contract and returns the decoded string. +func callVersionString(t devtest.T, client apis.EthClient, addr common.Address) string { + selector := crypto.Keccak256([]byte("version()"))[:4] + raw, err := client.Call(t.Ctx(), ethereum.CallMsg{To: &addr, Data: selector}, rpc.LatestBlockNumber) + t.Require().NoError(err, "version() call failed on %s", addr) + stringType, _ := abi.NewType("string", "", nil) + decoded, err := abi.Arguments{{Type: stringType}}.Unpack(raw) + t.Require().NoError(err) + return decoded[0].(string) +} + +// patchVersionInInitcode patches the version string inside initcode. +// +// Solidity stores a string constant via two instructions: +// - PUSH1 — the string length, a few bytes before PUSH32 +// - PUSH32 — the string data +// +// We locate both by searching for the PUSH32 pattern (0x7f + currentVersion +// left-aligned in 32 zero-padded bytes) and scanning backwards for the +// length PUSH1. This makes the patch independent of the current version value. +func patchVersionInInitcode(t devtest.T, initcode []byte, currentVersion, newVersion string) []byte { + t.Require().LessOrEqual(len([]byte(newVersion)), 32, "new version must fit in a PUSH32 (max 32 bytes)") + + currentVersionBytes := []byte(currentVersion) + newVersionBytes := []byte(newVersion) + + // Build the expected 32-byte PUSH32 argument for the current version. + currentPush32Arg := make([]byte, 32) + copy(currentPush32Arg, currentVersionBytes) + + push32Pattern := append([]byte{0x7f}, currentPush32Arg...) + idx := bytes.Index(initcode, push32Pattern) + t.Require().GreaterOrEqual(idx, 0, "PUSH32 version pattern not found in initcode (current version: %q)", currentVersion) + + result := make([]byte, len(initcode)) + copy(result, initcode) + + // Overwrite the 32-byte PUSH32 argument with the new version (left-aligned, zero-padded). + newPush32Arg := make([]byte, 32) + copy(newPush32Arg, newVersionBytes) + copy(result[idx+1:idx+33], newPush32Arg) + + // Scan backwards from the PUSH32 to find the PUSH1 instruction. + searchStart := idx - 30 + if searchStart < 0 { + searchStart = 0 + } + found := false + for i := idx - 1; i >= searchStart; i-- { + if result[i] == 0x60 && int(result[i+1]) == len(currentVersionBytes) { + result[i+1] = byte(len(newVersionBytes)) + found = true + break + } + } + t.Require().True(found, "PUSH1 length byte not found near version PUSH32 (current version: %q)", currentVersion) + + return result +} + +// TestL2CMUpgrade_Karst deploys a patched SchemaRegistry implementation via +// ConditionalDeployer, upgrades the proxy via L2ProxyAdmin, and verifies the +// change is observable both on L2 (version string, ERC-1967 slot) and on L1 +// (dispute game covering the post-upgrade block). +func TestL2CMUpgrade_Karst(gt *testing.T) { + t := devtest.ParallelT(gt) + sys := presets.NewMinimal(t, + presets.WithDeployerOptions(sysgo.WithKarstAtGenesis), + presets.WithGameTypeAdded(gameTypes.CannonGameType), + presets.WithRespectedGameTypeOverride(gameTypes.CannonGameType), + presets.WithProposerOption(func(_ sysgo.ComponentTarget, cfg *ps.CLIConfig) { + cfg.DisputeGameType = uint32(gameTypes.CannonGameType) + }), + ) + + devKeys, err := devkeys.NewMnemonicDevKeys(devkeys.TestMnemonic) + t.Require().NoError(err) + paOwnerKey := devkeys.L2ProxyAdminOwnerRole.Key(sys.L2Chain.ChainID().ToBig()) + privKey, err := devKeys.Secret(paOwnerKey) + t.Require().NoError(err) + l2PAO := dsl.NewEOA(dsl.NewKey(t, privKey), sys.L2EL) + sys.FunderL2.Fund(l2PAO, eth.OneEther) + + currentVersion := callVersionString(t, sys.L2EL.EthClient(), predeploys.SchemaRegistryAddr) + t.Logger().Info("Current SchemaRegistry version", "version", currentVersion) + + const newVersion = "999.999.999" + initcode := loadSchemaRegistryInitcode(t) + initcode = patchVersionInInitcode(t, initcode, currentVersion, newVersion) + + var salt [32]byte + copy(salt[:], []byte("karst-upgrade-test-v1")) + + deterministicProxy := predeploys.DeterministicDeploymentProxyAddr + newImplAddr := crypto.CreateAddress2(deterministicProxy, salt, crypto.Keccak256(initcode)) + + cdABI, err := abi.JSON(bytes.NewReader([]byte( + `[{"inputs":[{"name":"_salt","type":"bytes32"},{"name":"_code","type":"bytes"}],"name":"deploy","outputs":[{"name":"implementation_","type":"address"}],"stateMutability":"nonpayable","type":"function"}]`, + ))) + t.Require().NoError(err) + deployCalldata, err := cdABI.Pack("deploy", salt, initcode) + t.Require().NoError(err) + + cdAddr := conditionalDeployerAddr + l2PAO.Transact(l2PAO.Plan(), txplan.WithTo(&cdAddr), txplan.WithData(deployCalldata)) + t.Logger().Info("Deployed patched SchemaRegistry implementation", "impl", newImplAddr) + + paABI, err := abi.JSON(bytes.NewReader([]byte( + `[{"inputs":[{"name":"_proxy","type":"address"},{"name":"_implementation","type":"address"}],"name":"upgrade","outputs":[],"stateMutability":"nonpayable","type":"function"}]`, + ))) + t.Require().NoError(err) + upgradeCalldata, err := paABI.Pack("upgrade", predeploys.SchemaRegistryAddr, newImplAddr) + t.Require().NoError(err) + + proxyAdminAddr := predeploys.ProxyAdminAddr + upgradeTx := l2PAO.Transact(l2PAO.Plan(), txplan.WithTo(&proxyAdminAddr), txplan.WithData(upgradeCalldata)) + upgradeReceipt, err := upgradeTx.Included.Eval(t.Ctx()) + t.Require().NoError(err) + upgradeBlockNumber := upgradeReceipt.BlockNumber.Uint64() + t.Logger().Info("Upgraded SchemaRegistry proxy", "block", upgradeBlockNumber, "newImpl", newImplAddr) + + // Verify ERC-1967 slot and version() on L2. + implSlot, err := sys.L2EL.EthClient().GetStorageAt( + t.Ctx(), predeploys.SchemaRegistryAddr, genesis.ImplementationSlot, "latest", + ) + t.Require().NoError(err) + t.Require().Equal(newImplAddr, common.BytesToAddress(implSlot[:])) + t.Require().Equal(newVersion, callVersionString(t, sys.L2EL.EthClient(), predeploys.SchemaRegistryAddr)) + t.Logger().Info("Verified version() on L2", "version", newVersion) + + // Wait for a dispute game on L1 that covers the post-upgrade block. + dgf := sys.DisputeGameFactory() + initialGameCount := dgf.GameCount() + t.Require().Eventually(func() bool { + count := dgf.GameCount() + for i := initialGameCount; i < count; i++ { + if dgf.GameAtIndex(i).L2SequenceNumber() >= upgradeBlockNumber { + return true + } + } + return false + }, 5*time.Minute, 5*time.Second, fmt.Sprintf("L1 must have a dispute game covering upgrade block %d", upgradeBlockNumber)) + t.Logger().Info("Verified L1 dispute game covers upgrade block", "block", upgradeBlockNumber) +} From a118a9c0575b513507443e1d98a7a143559be0c2 Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Mon, 11 May 2026 17:05:51 +0200 Subject: [PATCH 03/29] test(karst-fork-tests): skip upgrade executing when already on karst, and add flags to support running against betanet --- packages/contracts-bedrock/justfile | 9 +++++++ .../scripts/libraries/Config.sol | 10 +++++++ .../test/L2/fork/L2ForkUpgrade.t.sol | 24 ++++++++++++----- .../contracts-bedrock/test/setup/Setup.sol | 27 ++++++++++++++++--- 4 files changed, 60 insertions(+), 10 deletions(-) diff --git a/packages/contracts-bedrock/justfile b/packages/contracts-bedrock/justfile index 75080995ca2..96dd58a6cac 100644 --- a/packages/contracts-bedrock/justfile +++ b/packages/contracts-bedrock/justfile @@ -227,6 +227,15 @@ test-l2-fork-upgrade *ARGS: test-l2-fork-upgrade-rerun *ARGS: just test-l2-fork-upgrade {{ARGS}} --rerun -vvvv +# Runs L2 fork upgrade tests against a Karst betanet L2 fork. +# Skips bundle execution and only runs verification steps. +# Usage: KARST_BETANET_L2_FORK_RPC_URL= just test-karst-betanet-l2-fork-upgrade [ARGS] +test-karst-betanet-l2-fork-upgrade *ARGS: + KARST_BETANET_L2_FORK_TEST=true just prepare-l2-upgrade-env "just test {{ARGS}}" + +test-karst-betanet-l2-fork-upgrade-rerun *ARGS: + just test-karst-betanet-l2-fork-upgrade {{ARGS}} --rerun -vvvv + ######################################################## # DEPLOY # ######################################################## diff --git a/packages/contracts-bedrock/scripts/libraries/Config.sol b/packages/contracts-bedrock/scripts/libraries/Config.sol index 7099f00f1e5..08483c3d3b0 100644 --- a/packages/contracts-bedrock/scripts/libraries/Config.sol +++ b/packages/contracts-bedrock/scripts/libraries/Config.sol @@ -289,11 +289,21 @@ library Config { return vm.envOr("L2_FORK_TEST", false); } + /// @notice Returns true if this is a Karst betanet L2 fork test. + function karstBetanetL2ForkTest() internal view returns (bool) { + return vm.envOr("KARST_BETANET_L2_FORK_TEST", false); + } + /// @notice Returns the L2 RPC URL for forking. function l2ForkRpcUrl() internal view returns (string memory) { return vm.envString("L2_FORK_RPC_URL"); } + /// @notice Returns the Karst betanet L2 RPC URL for forking. + function karstBetanetL2ForkRpcUrl() internal view returns (string memory) { + return vm.envString("KARST_BETANET_L2_FORK_RPC_URL"); + } + /// @notice Returns the L2 block number to fork at. Defaults to 0 (latest). function l2ForkBlockNumber() internal view returns (uint256) { return vm.envOr("L2_FORK_BLOCK_NUMBER", uint256(0)); diff --git a/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol b/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol index cc4bb9b2502..8c490796ef3 100644 --- a/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol +++ b/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol @@ -146,8 +146,10 @@ contract L2ForkUpgrade_Versions_Test is L2ForkUpgrade_TestInit { // Capture pre-upgrade version state PreUpgradeVersionState memory preState = _capturePreUpgradeVersionState(); - // Execute bundle on forked L2 - executeScript.execute(); + // Execute upgrade (skipped when the network already has the upgrade applied) + if (!isKarstForkActivated()) { + executeScript.execute(); + } // Verify all versions were updated _verifyAllVersionsUpdated(preState); @@ -251,8 +253,10 @@ contract L2ForkUpgrade_Initialization_Test is L2ForkUpgrade_TestInit { // Capture pre-upgrade initialization state PreUpgradeInitializationState memory preState = _capturePreUpgradeInitializationState(); - // Execute bundle on forked L2 - executeScript.execute(); + // Execute upgrade (skipped when the network already has the upgrade applied) + if (!isKarstForkActivated()) { + executeScript.execute(); + } // Verify initialization state was preserved _verifyInitializationState(preState); @@ -606,8 +610,10 @@ contract L2ForkUpgrade_Implementations_Test is L2ForkUpgrade_TestInit { // Skip if running with an unoptimized Foundry profile skipIfUnoptimized(); - // Execute upgrade - executeScript.execute(); + // Execute upgrade (skipped when the network already has the upgrade applied) + if (!isKarstForkActivated()) { + executeScript.execute(); + } // Get all upgradeable predeploys address[] memory predeploys = Predeploys.getUpgradeablePredeploys(); @@ -658,6 +664,12 @@ contract L2ForkUpgrade_Events_Test is L2ForkUpgrade_TestInit { // Skip if running with an unoptimized Foundry profile skipIfUnoptimized(); + // Events were emitted in the past and cannot be replayed on an already-upgraded network + if (isKarstForkActivated()) { + vm.skip(true); + return; + } + // Get StorageSetter implementation to filter out intermediate upgrade events (address storageSetterImpl,,,) = generateScript.implementationConfigs("StorageSetter"); diff --git a/packages/contracts-bedrock/test/setup/Setup.sol b/packages/contracts-bedrock/test/setup/Setup.sol index dcf7eba5df9..2d8a2fc44ef 100644 --- a/packages/contracts-bedrock/test/setup/Setup.sol +++ b/packages/contracts-bedrock/test/setup/Setup.sol @@ -173,6 +173,11 @@ abstract contract Setup is FeatureFlags { return Config.l2ForkTest(); } + /// @notice Indicates whether a test is running against a Karst betanet L2 fork test. + function isKarstBetanetL2ForkTest() public view returns (bool) { + return Config.karstBetanetL2ForkTest(); + } + /// @dev Deploys either the Deploy.s.sol or Fork.s.sol contract, by fetching the bytecode dynamically using /// `vm.getDeployedCode()` and etching it into the state. /// This enables us to avoid including the bytecode of those contracts in the bytecode of this contract. @@ -184,12 +189,20 @@ abstract contract Setup is FeatureFlags { console.log("Setup: L1 setup start!"); // Handle L2 fork test (takes precedence over L1 fork) - if (isL2ForkTest()) { + if (isL2ForkTest() || isKarstBetanetL2ForkTest()) { uint256 l2ForkBlock = Config.l2ForkBlockNumber(); - if (l2ForkBlock == 0) { - vm.createSelectFork(Config.l2ForkRpcUrl()); + if (isKarstBetanetL2ForkTest()) { + if (l2ForkBlock == 0) { + vm.createSelectFork(Config.karstBetanetL2ForkRpcUrl()); + } else { + vm.createSelectFork(Config.karstBetanetL2ForkRpcUrl(), l2ForkBlock); + } } else { - vm.createSelectFork(Config.l2ForkRpcUrl(), l2ForkBlock); + if (l2ForkBlock == 0) { + vm.createSelectFork(Config.l2ForkRpcUrl()); + } else { + vm.createSelectFork(Config.l2ForkRpcUrl(), l2ForkBlock); + } } console.log("Setup: L2 fork selected!"); } else if (isL1ForkTest()) { @@ -353,6 +366,12 @@ abstract contract Setup is FeatureFlags { } } + /// @dev Returns true if the forked L2 network has the Karst upgrade applied, by checking if the ConditionalDeployer predeploy exists. + /// When true, fork upgrade tests skip bundle execution but still run verification. + function isKarstForkActivated() public view returns (bool) { + return Predeploys.CONDITIONAL_DEPLOYER.code.length > 0; + } + /// @dev Sets up the L1 contracts. function L1() public { console.log("Setup: creating L1 deployments"); From 329e54d64094ca190d0c7257074d3f18cdd357d9 Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Mon, 11 May 2026 23:49:37 +0200 Subject: [PATCH 04/29] test(acceptance-test-l2cm-karst): make the test proper e2e --- .../tests/karst/upgrade_test.go | 214 ++++++++++++------ op-core/predeploys/addresses.go | 2 + op-devstack/sysgo/deployer.go | 13 ++ op-devstack/sysgo/l2_cl.go | 9 + op-devstack/sysgo/singlechain_build.go | 4 + op-node/rollup/derive/attributes.go | 6 + op-node/rollup/sequencing/sequencer.go | 5 + op-node/rollup/types.go | 48 ++++ .../test/L2/MinimalTestL2CM.sol | 25 ++ 9 files changed, 254 insertions(+), 72 deletions(-) create mode 100644 packages/contracts-bedrock/test/L2/MinimalTestL2CM.sol diff --git a/op-acceptance-tests/tests/karst/upgrade_test.go b/op-acceptance-tests/tests/karst/upgrade_test.go index d5f952ae7eb..ca612d5b4cb 100644 --- a/op-acceptance-tests/tests/karst/upgrade_test.go +++ b/op-acceptance-tests/tests/karst/upgrade_test.go @@ -4,43 +4,47 @@ import ( "bytes" "encoding/json" "fmt" + "math/big" "os" "path/filepath" "testing" "time" - "github.com/ethereum-optimism/optimism/op-chain-ops/devkeys" "github.com/ethereum-optimism/optimism/op-chain-ops/genesis" gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types" "github.com/ethereum-optimism/optimism/op-core/predeploys" "github.com/ethereum-optimism/optimism/op-devstack/devtest" - "github.com/ethereum-optimism/optimism/op-devstack/dsl" "github.com/ethereum-optimism/optimism/op-devstack/presets" "github.com/ethereum-optimism/optimism/op-devstack/sysgo" + "github.com/ethereum-optimism/optimism/op-node/rollup/derive" ps "github.com/ethereum-optimism/optimism/op-proposer/proposer" opservice "github.com/ethereum-optimism/optimism/op-service" "github.com/ethereum-optimism/optimism/op-service/apis" "github.com/ethereum-optimism/optimism/op-service/eth" - "github.com/ethereum-optimism/optimism/op-service/txplan" ethereum "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/rpc" ) -var conditionalDeployerAddr = common.HexToAddress("0x420000000000000000000000000000000000002C") - -func loadSchemaRegistryInitcode(t devtest.T) []byte { - wd, err := os.Getwd() - t.Require().NoError(err) - root, err := opservice.FindMonorepoRoot(wd) +// callVersionString calls version() on a contract and returns the decoded string. +func callVersionString(t devtest.T, client apis.EthClient, addr common.Address) string { + selector := crypto.Keccak256([]byte("version()"))[:4] + raw, err := client.Call(t.Ctx(), ethereum.CallMsg{To: &addr, Data: selector}, rpc.LatestBlockNumber) + t.Require().NoError(err, "version() call failed on %s", addr) + stringType, _ := abi.NewType("string", "", nil) + decoded, err := abi.Arguments{{Type: stringType}}.Unpack(raw) t.Require().NoError(err) + return decoded[0].(string) +} - artifactPath := filepath.Join(root, "packages", "contracts-bedrock", "forge-artifacts", "SchemaRegistry.sol", "SchemaRegistry.json") +// loadInitcodeFromArtifact loads deployment bytecode from a forge artifact JSON file. +func loadInitcodeFromArtifact(t devtest.T, artifactPath string) []byte { data, err := os.ReadFile(artifactPath) - t.Require().NoError(err, "failed to read SchemaRegistry artifact") - + t.Require().NoError(err, "failed to read artifact: %s", artifactPath) var artifact struct { Bytecode struct { Object string `json:"object"` @@ -51,17 +55,6 @@ func loadSchemaRegistryInitcode(t devtest.T) []byte { return common.FromHex(artifact.Bytecode.Object) } -// callVersionString calls version() on a contract and returns the decoded string. -func callVersionString(t devtest.T, client apis.EthClient, addr common.Address) string { - selector := crypto.Keccak256([]byte("version()"))[:4] - raw, err := client.Call(t.Ctx(), ethereum.CallMsg{To: &addr, Data: selector}, rpc.LatestBlockNumber) - t.Require().NoError(err, "version() call failed on %s", addr) - stringType, _ := abi.NewType("string", "", nil) - decoded, err := abi.Arguments{{Type: stringType}}.Unpack(raw) - t.Require().NoError(err) - return decoded[0].(string) -} - // patchVersionInInitcode patches the version string inside initcode. // // Solidity stores a string constant via two instructions: @@ -77,7 +70,6 @@ func patchVersionInInitcode(t devtest.T, initcode []byte, currentVersion, newVer currentVersionBytes := []byte(currentVersion) newVersionBytes := []byte(newVersion) - // Build the expected 32-byte PUSH32 argument for the current version. currentPush32Arg := make([]byte, 32) copy(currentPush32Arg, currentVersionBytes) @@ -88,12 +80,10 @@ func patchVersionInInitcode(t devtest.T, initcode []byte, currentVersion, newVer result := make([]byte, len(initcode)) copy(result, initcode) - // Overwrite the 32-byte PUSH32 argument with the new version (left-aligned, zero-padded). newPush32Arg := make([]byte, 32) copy(newPush32Arg, newVersionBytes) copy(result[idx+1:idx+33], newPush32Arg) - // Scan backwards from the PUSH32 to find the PUSH1 instruction. searchStart := idx - 30 if searchStart < 0 { searchStart = 0 @@ -111,12 +101,106 @@ func patchVersionInInitcode(t devtest.T, initcode []byte, currentVersion, newVer return result } -// TestL2CMUpgrade_Karst deploys a patched SchemaRegistry implementation via -// ConditionalDeployer, upgrades the proxy via L2ProxyAdmin, and verifies the -// change is observable both on L2 (version string, ERC-1967 slot) and on L1 -// (dispute game covering the post-upgrade block). +// buildNUTDepositTx creates a serialized deposit transaction suitable for use as a custom NUT. +func buildNUTDepositTx(t devtest.T, intent string, from common.Address, to *common.Address, data []byte, gasLimit uint64) hexutil.Bytes { + source := derive.UpgradeDepositSource{Intent: intent} + depTx := &types.DepositTx{ + SourceHash: source.SourceHash(), + From: from, + To: to, + Mint: big.NewInt(0), + Value: big.NewInt(0), + Gas: gasLimit, + IsSystemTransaction: false, + Data: data, + } + encoded, err := types.NewTx(depTx).MarshalBinary() + t.Require().NoError(err, "failed to marshal NUT deposit tx") + return encoded +} + +// buildCustomNUTs constructs the three deposit transactions for the post-Karst upgrade: +// 1. ConditionalDeployer.deploy(salt, patchedSchemaRegistryInitcode) +// 2. ConditionalDeployer.deploy(salt2, minimalTestL2CMInitcode) +// 3. L2ProxyAdmin.upgradePredeploys(testL2CMAddr) +func buildCustomNUTs( + t devtest.T, + implSalt [32]byte, patchedInitcode []byte, + l2CMSalt [32]byte, l2CMInitcode []byte, + testL2CMAddr common.Address, +) ([]hexutil.Bytes, uint64) { + depositor := common.HexToAddress("0xDeaDDEaDDeAdDeAdDEAdDEaddeAddEAdDEAd0001") + cdAddr := predeploys.ConditionalDeployerAddr + paAddr := predeploys.ProxyAdminAddr + + cdABI, err := abi.JSON(bytes.NewReader([]byte( + `[{"inputs":[{"name":"_salt","type":"bytes32"},{"name":"_code","type":"bytes"}],"name":"deploy","outputs":[{"name":"implementation_","type":"address"}],"stateMutability":"nonpayable","type":"function"}]`, + ))) + t.Require().NoError(err) + + paABI, err := abi.JSON(bytes.NewReader([]byte( + `[{"inputs":[{"name":"_l2ContractsManager","type":"address"}],"name":"upgradePredeploys","outputs":[],"stateMutability":"nonpayable","type":"function"}]`, + ))) + t.Require().NoError(err) + + deployImplData, err := cdABI.Pack("deploy", implSalt, patchedInitcode) + t.Require().NoError(err) + + deployL2CMData, err := cdABI.Pack("deploy", l2CMSalt, l2CMInitcode) + t.Require().NoError(err) + + upgradeData, err := paABI.Pack("upgradePredeploys", testL2CMAddr) + t.Require().NoError(err) + + const implGas = uint64(3_000_000) + const l2CMGas = uint64(500_000) + const upgradeGas = uint64(300_000) + + tx1 := buildNUTDepositTx(t, "Custom NUT 0: Deploy Patched SchemaRegistry", depositor, &cdAddr, deployImplData, implGas) + tx2 := buildNUTDepositTx(t, "Custom NUT 1: Deploy MinimalTestL2CM", depositor, &cdAddr, deployL2CMData, l2CMGas) + tx3 := buildNUTDepositTx(t, "Custom NUT 2: Upgrade Predeploys", depositor, &paAddr, upgradeData, upgradeGas) + + return []hexutil.Bytes{tx1, tx2, tx3}, implGas + l2CMGas + upgradeGas +} + +// TestL2CMUpgrade_Karst simulates a post-Karst upgrade on an already-Karst chain. It deploys a +// patched SchemaRegistry implementation via ConditionalDeployer, deploys a MinimalTestL2CM, and +// triggers L2ProxyAdmin.upgradePredeploys() via DEPOSITOR — exactly the production NUT flow — +// then verifies the upgrade is observable on L2 and covered by an L1 dispute game. func TestL2CMUpgrade_Karst(gt *testing.T) { t := devtest.ParallelT(gt) + + wd, err := os.Getwd() + t.Require().NoError(err) + root, err := opservice.FindMonorepoRoot(wd) + t.Require().NoError(err) + + // Load forge artifacts. + schemaRegistryInitcode := loadInitcodeFromArtifact(t, + filepath.Join(root, "packages", "contracts-bedrock", "forge-artifacts", "SchemaRegistry.sol", "SchemaRegistry.json")) + minimalTestL2CMInitcode := loadInitcodeFromArtifact(t, + filepath.Join(root, "packages", "contracts-bedrock", "forge-artifacts", "MinimalTestL2CM.sol", "MinimalTestL2CM.json")) + + // Pre-compute the patched SchemaRegistry implementation address. + // The current version is patched to a recognisably high value so the upgrade is easily verified. + // (patchVersionInInitcode will fail loudly if the version string is not found in the bytecode.) + const newVersion = "999.999.999" + + var implSalt [32]byte + copy(implSalt[:], []byte("karst-upgrade-test-impl-v1")) + deterministicProxy := predeploys.DeterministicDeploymentProxyAddr + + var l2CMSalt [32]byte + copy(l2CMSalt[:], []byte("karst-upgrade-test-l2cm-v1")) + + // Pre-compute L2CM address: it depends on the patched impl address (baked as an immutable), + // but we need the impl address first. We will fill in the initcode after reading version(). + // Both addresses are deterministic from salts + initcodes, so they can be computed offline + // once the version is known. We defer that to after system startup (see below). + + // Schedule the custom NUT activation 30 seconds after genesis. + activation, nutOpt := sysgo.WithCustomNUTAtOffset(30) + sys := presets.NewMinimal(t, presets.WithDeployerOptions(sysgo.WithKarstAtGenesis), presets.WithGameTypeAdded(gameTypes.CannonGameType), @@ -124,64 +208,50 @@ func TestL2CMUpgrade_Karst(gt *testing.T) { presets.WithProposerOption(func(_ sysgo.ComponentTarget, cfg *ps.CLIConfig) { cfg.DisputeGameType = uint32(gameTypes.CannonGameType) }), + presets.WithGlobalL2CLOption(nutOpt), ) - devKeys, err := devkeys.NewMnemonicDevKeys(devkeys.TestMnemonic) - t.Require().NoError(err) - paOwnerKey := devkeys.L2ProxyAdminOwnerRole.Key(sys.L2Chain.ChainID().ToBig()) - privKey, err := devKeys.Secret(paOwnerKey) - t.Require().NoError(err) - l2PAO := dsl.NewEOA(dsl.NewKey(t, privKey), sys.L2EL) - sys.FunderL2.Fund(l2PAO, eth.OneEther) - + // Read the current SchemaRegistry version from the running chain and build NUT transactions. currentVersion := callVersionString(t, sys.L2EL.EthClient(), predeploys.SchemaRegistryAddr) t.Logger().Info("Current SchemaRegistry version", "version", currentVersion) - const newVersion = "999.999.999" - initcode := loadSchemaRegistryInitcode(t) - initcode = patchVersionInInitcode(t, initcode, currentVersion, newVersion) - - var salt [32]byte - copy(salt[:], []byte("karst-upgrade-test-v1")) - - deterministicProxy := predeploys.DeterministicDeploymentProxyAddr - newImplAddr := crypto.CreateAddress2(deterministicProxy, salt, crypto.Keccak256(initcode)) + patchedInitcode := patchVersionInInitcode(t, schemaRegistryInitcode, currentVersion, newVersion) + newImplAddr := crypto.CreateAddress2(deterministicProxy, implSalt, crypto.Keccak256(patchedInitcode)) + t.Logger().Info("Pre-computed patched SchemaRegistry implementation", "addr", newImplAddr) - cdABI, err := abi.JSON(bytes.NewReader([]byte( - `[{"inputs":[{"name":"_salt","type":"bytes32"},{"name":"_code","type":"bytes"}],"name":"deploy","outputs":[{"name":"implementation_","type":"address"}],"stateMutability":"nonpayable","type":"function"}]`, - ))) - t.Require().NoError(err) - deployCalldata, err := cdABI.Pack("deploy", salt, initcode) + // Build MinimalTestL2CM initcode with constructor args (proxy, newImpl). + addressType, _ := abi.NewType("address", "", nil) + ctorArgs, err := abi.Arguments{{Type: addressType}, {Type: addressType}}.Pack(predeploys.SchemaRegistryAddr, newImplAddr) t.Require().NoError(err) + l2CMInitcodeWithArgs := append(minimalTestL2CMInitcode, ctorArgs...) + testL2CMAddr := crypto.CreateAddress2(deterministicProxy, l2CMSalt, crypto.Keccak256(l2CMInitcodeWithArgs)) + t.Logger().Info("Pre-computed MinimalTestL2CM", "addr", testL2CMAddr) - cdAddr := conditionalDeployerAddr - l2PAO.Transact(l2PAO.Plan(), txplan.WithTo(&cdAddr), txplan.WithData(deployCalldata)) - t.Logger().Info("Deployed patched SchemaRegistry implementation", "impl", newImplAddr) + // Register the NUT transactions before the activation block is sequenced. + nutTxs, totalGas := buildCustomNUTs(t, implSalt, patchedInitcode, l2CMSalt, l2CMInitcodeWithArgs, testL2CMAddr) + activation.SetTransactions(nutTxs, totalGas) + t.Logger().Info("Registered custom NUT transactions", "activationTime", activation.Time, "txCount", len(nutTxs)) - paABI, err := abi.JSON(bytes.NewReader([]byte( - `[{"inputs":[{"name":"_proxy","type":"address"},{"name":"_implementation","type":"address"}],"name":"upgrade","outputs":[],"stateMutability":"nonpayable","type":"function"}]`, - ))) - t.Require().NoError(err) - upgradeCalldata, err := paABI.Pack("upgrade", predeploys.SchemaRegistryAddr, newImplAddr) - t.Require().NoError(err) - - proxyAdminAddr := predeploys.ProxyAdminAddr - upgradeTx := l2PAO.Transact(l2PAO.Plan(), txplan.WithTo(&proxyAdminAddr), txplan.WithData(upgradeCalldata)) - upgradeReceipt, err := upgradeTx.Included.Eval(t.Ctx()) - t.Require().NoError(err) - upgradeBlockNumber := upgradeReceipt.BlockNumber.Uint64() - t.Logger().Info("Upgraded SchemaRegistry proxy", "block", upgradeBlockNumber, "newImpl", newImplAddr) + // Wait for the activation block (poll until version() returns the new version). + t.Require().Eventually(func() bool { + ver := callVersionString(t, sys.L2EL.EthClient(), predeploys.SchemaRegistryAddr) + return ver == newVersion + }, 2*time.Minute, 2*time.Second, fmt.Sprintf("SchemaRegistry version should update to %s after custom NUT activation", newVersion)) + t.Logger().Info("Verified version() on L2", "version", newVersion) - // Verify ERC-1967 slot and version() on L2. + // Verify ERC-1967 implementation slot. implSlot, err := sys.L2EL.EthClient().GetStorageAt( t.Ctx(), predeploys.SchemaRegistryAddr, genesis.ImplementationSlot, "latest", ) t.Require().NoError(err) t.Require().Equal(newImplAddr, common.BytesToAddress(implSlot[:])) - t.Require().Equal(newVersion, callVersionString(t, sys.L2EL.EthClient(), predeploys.SchemaRegistryAddr)) - t.Logger().Info("Verified version() on L2", "version", newVersion) - // Wait for a dispute game on L1 that covers the post-upgrade block. + // Retrieve the block number at which the upgrade landed. + latestBlock, err := sys.L2EL.EthClient().InfoByLabel(t.Ctx(), eth.Unsafe) + t.Require().NoError(err) + upgradeBlockNumber := latestBlock.NumberU64() + + // Wait for an L1 dispute game covering the post-upgrade block. dgf := sys.DisputeGameFactory() initialGameCount := dgf.GameCount() t.Require().Eventually(func() bool { diff --git a/op-core/predeploys/addresses.go b/op-core/predeploys/addresses.go index fc677c21c99..25f1de17753 100644 --- a/op-core/predeploys/addresses.go +++ b/op-core/predeploys/addresses.go @@ -32,6 +32,7 @@ const ( ETHLiquidity = "0x4200000000000000000000000000000000000025" NativeAssetLiquidity = "0x4200000000000000000000000000000000000029" LiquidityController = "0x420000000000000000000000000000000000002a" + ConditionalDeployer = "0x420000000000000000000000000000000000002C" Create2Deployer = "0x13b0D85CcB8bf860b6b79AF3029fCA081AE9beF2" MultiCall3 = "0xcA11bde05977b3631167028862bE2a173976CA11" Safe_v130 = "0x69f4D1788e39c87893C980c06EdF4b7f686e2938" @@ -74,6 +75,7 @@ var ( ETHLiquidityAddr = common.HexToAddress(ETHLiquidity) NativeAssetLiquidityAddr = common.HexToAddress(NativeAssetLiquidity) LiquidityControllerAddr = common.HexToAddress(LiquidityController) + ConditionalDeployerAddr = common.HexToAddress(ConditionalDeployer) Create2DeployerAddr = common.HexToAddress(Create2Deployer) MultiCall3Addr = common.HexToAddress(MultiCall3) Safe_v130Addr = common.HexToAddress(Safe_v130) diff --git a/op-devstack/sysgo/deployer.go b/op-devstack/sysgo/deployer.go index 10cd0561faf..334b01278f4 100644 --- a/op-devstack/sysgo/deployer.go +++ b/op-devstack/sysgo/deployer.go @@ -80,6 +80,19 @@ func WithJovianAtGenesis(p devtest.T, _ devkeys.Keys, builder intentbuilder.Buil } } +// WithCustomNUTAtOffset returns a *rollup.CustomNUTActivation handle and an L2CLOption that +// schedules custom NUT deposit transactions to be injected at genesis-time + offsetSeconds. +// The caller must call SetTransactions on the returned handle before the activation block is +// sequenced. Intended for tests only; never use in production. +func WithCustomNUTAtOffset(offsetSeconds uint64) (*rollup.CustomNUTActivation, L2CLOption) { + activation := &rollup.CustomNUTActivation{} + opt := L2CLOptionFn(func(_ devtest.T, _ ComponentTarget, cfg *L2CLConfig) { + cfg.customNUTActivationOffset = offsetSeconds + cfg.customNUTActivation = activation + }) + return activation, opt +} + func WithL2GasLimit(gasLimit uint64) DeployerOption { return func(_ devtest.T, _ devkeys.Keys, builder intentbuilder.Builder) { for _, l2Cfg := range builder.L2s() { diff --git a/op-devstack/sysgo/l2_cl.go b/op-devstack/sysgo/l2_cl.go index 8945523c9ed..d1d7024ae2d 100644 --- a/op-devstack/sysgo/l2_cl.go +++ b/op-devstack/sysgo/l2_cl.go @@ -5,6 +5,7 @@ import ( "github.com/ethereum-optimism/optimism/op-devstack/devtest" "github.com/ethereum-optimism/optimism/op-devstack/stack" + "github.com/ethereum-optimism/optimism/op-node/rollup" nodeSync "github.com/ethereum-optimism/optimism/op-node/rollup/sync" "github.com/ethereum-optimism/optimism/op-service/eth" ) @@ -40,6 +41,14 @@ type L2CLConfig struct { // OffsetELSafe retracts safe and finalized from the EL-sync tip by floor(OffsetELSafe / L2BlockTime) blocks. OffsetELSafe time.Duration + + // customNUTActivationOffset, if non-zero, triggers injection of custom NUT transactions at + // genesis-time + customNUTActivationOffset seconds. The *rollup.CustomNUTActivation handle + // returned by WithCustomNUTAtOffset must have SetTransactions called before that block. + customNUTActivationOffset uint64 + // customNUTActivation is the shared handle written by singlechain_build once the genesis time + // is known. The caller holds the same pointer to feed in transactions after startup. + customNUTActivation *rollup.CustomNUTActivation } func L2CLSequencer() L2CLOption { diff --git a/op-devstack/sysgo/singlechain_build.go b/op-devstack/sysgo/singlechain_build.go index c456e07ecfb..2ab3459592f 100644 --- a/op-devstack/sysgo/singlechain_build.go +++ b/op-devstack/sysgo/singlechain_build.go @@ -430,6 +430,10 @@ func startL2CLNode( IgnoreMissingPectraBlobSchedule: false, ExperimentalOPStackAPI: true, } + if cfg.customNUTActivation != nil { + cfg.customNUTActivation.Time = l2Net.rollupCfg.Genesis.L2Time + cfg.customNUTActivationOffset + nodeCfg.Rollup.CustomNUTActivation = cfg.customNUTActivation + } l2CL := &OpNode{ name: startCfg.Key, opNode: nil, diff --git a/op-node/rollup/derive/attributes.go b/op-node/rollup/derive/attributes.go index 3b24a36ae74..4960a82b932 100644 --- a/op-node/rollup/derive/attributes.go +++ b/op-node/rollup/derive/attributes.go @@ -167,6 +167,12 @@ func (ba *FetchingAttributesBuilder) PreparePayloadAttributes(ctx context.Contex upgradeGas += nutGas } + if ba.rollupCfg.IsCustomNUTActivationBlock(nextL2Time) { + customTxs, customGas := ba.rollupCfg.CustomNUTActivation.Transactions() + upgradeTxs = append(upgradeTxs, customTxs...) + upgradeGas += customGas + } + // TODO(#19239): migrate Interop to NUT bundle and add its gas to upgradeGas. if ba.rollupCfg.IsInteropActivationBlock(nextL2Time) { interop, err := InteropNetworkUpgradeTransactions() diff --git a/op-node/rollup/sequencing/sequencer.go b/op-node/rollup/sequencing/sequencer.go index 5e8d9765b96..cba4708c133 100644 --- a/op-node/rollup/sequencing/sequencer.go +++ b/op-node/rollup/sequencing/sequencer.go @@ -593,6 +593,11 @@ func (d *Sequencer) startBuildingBlock() { d.log.Info("Sequencing Karst upgrade block") } + if d.rollupCfg.IsCustomNUTActivationBlock(uint64(attrs.Timestamp)) { + attrs.NoTxPool = true + d.log.Info("Sequencing custom NUT upgrade block") + } + // For the Interop activation block we must not include any sequencer transactions. if d.rollupCfg.IsInteropActivationBlock(uint64(attrs.Timestamp)) { attrs.NoTxPool = true diff --git a/op-node/rollup/types.go b/op-node/rollup/types.go index c80b22aa5b3..c02dcad6761 100644 --- a/op-node/rollup/types.go +++ b/op-node/rollup/types.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "math/big" + "sync" "time" altda "github.com/ethereum-optimism/optimism/op-alt-da" @@ -14,6 +15,7 @@ import ( "github.com/ethereum-optimism/optimism/op-service/eth" "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/params" @@ -165,6 +167,39 @@ type Config struct { // This feature (de)activates by L1 origin timestamp, to keep a consistent L1 block info per L2 // epoch. PectraBlobScheduleTime *uint64 `json:"pectra_blob_schedule_time,omitempty"` + + // CustomNUTActivation is a test-only hook for injecting custom NUT deposit transactions at a + // specific L2 timestamp, simulating a future post-Karst upgrade. Not serialized; must not be + // used in production. + CustomNUTActivation *CustomNUTActivation `json:"-"` +} + +// CustomNUTActivation holds the activation timestamp and NUT deposit transactions for a test-only +// post-Karst upgrade simulation. Transactions may be set after construction (before the activation +// block is sequenced) via SetTransactions. +type CustomNUTActivation struct { + // Time is the L2 block timestamp at which to inject the NUT transactions. + Time uint64 + + mu sync.Mutex + txs []hexutil.Bytes + totalGas uint64 +} + +// SetTransactions sets the serialized deposit transactions and their combined gas limit. +// Must be called before the activation block is sequenced. +func (a *CustomNUTActivation) SetTransactions(txs []hexutil.Bytes, totalGas uint64) { + a.mu.Lock() + defer a.mu.Unlock() + a.txs = txs + a.totalGas = totalGas +} + +// Transactions returns the serialized deposit transactions and their combined gas limit. +func (a *CustomNUTActivation) Transactions() ([]hexutil.Bytes, uint64) { + a.mu.Lock() + defer a.mu.Unlock() + return a.txs, a.totalGas } // ValidateL1Config checks L1 config variables for errors. @@ -570,6 +605,19 @@ func (c *Config) IsKarstActivationBlock(l2BlockTime uint64) bool { !c.IsKarst(l2BlockTime-c.BlockTime) } +// IsCustomNUTActivationBlock returns true for the first L2 block at or after the custom NUT +// activation timestamp. Used to inject test-only NUT transactions; never true in production +// (CustomNUTActivation is nil when loaded from JSON). +func (c *Config) IsCustomNUTActivationBlock(l2BlockTime uint64) bool { + if c.CustomNUTActivation == nil { + return false + } + t := c.CustomNUTActivation.Time + return l2BlockTime >= t && + l2BlockTime >= c.BlockTime && + l2BlockTime-c.BlockTime < t +} + func (c *Config) IsInteropActivationBlock(l2BlockTime uint64) bool { return c.IsInterop(l2BlockTime) && l2BlockTime >= c.BlockTime && diff --git a/packages/contracts-bedrock/test/L2/MinimalTestL2CM.sol b/packages/contracts-bedrock/test/L2/MinimalTestL2CM.sol new file mode 100644 index 00000000000..4fff2f08163 --- /dev/null +++ b/packages/contracts-bedrock/test/L2/MinimalTestL2CM.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +interface IProxy { + function upgradeTo(address _implementation) external; +} + +/// @title MinimalTestL2CM +/// @notice Test-only minimal L2ContractsManager that upgrades a single predeploy proxy. +/// Designed to be called via DELEGATECALL from L2ProxyAdmin.upgradePredeploys(): +/// in that context address(this) == L2ProxyAdmin, which is the proxy admin, so +/// the upgradeTo call is authorized. +contract MinimalTestL2CM { + address public immutable proxy; + address public immutable newImpl; + + constructor(address _proxy, address _newImpl) { + proxy = _proxy; + newImpl = _newImpl; + } + + function upgrade() external { + IProxy(proxy).upgradeTo(newImpl); + } +} From 1971c09a512b4f4ed9ec663cdb1e46ad2b118b37 Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Wed, 13 May 2026 10:25:36 +0200 Subject: [PATCH 05/29] fix(acceptance-test-l2cm-karst): remove redundant flag check --- packages/contracts-bedrock/test/setup/Setup.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contracts-bedrock/test/setup/Setup.sol b/packages/contracts-bedrock/test/setup/Setup.sol index 2d8a2fc44ef..ac7f944ab78 100644 --- a/packages/contracts-bedrock/test/setup/Setup.sol +++ b/packages/contracts-bedrock/test/setup/Setup.sol @@ -189,7 +189,7 @@ abstract contract Setup is FeatureFlags { console.log("Setup: L1 setup start!"); // Handle L2 fork test (takes precedence over L1 fork) - if (isL2ForkTest() || isKarstBetanetL2ForkTest()) { + if (isL2ForkTest()) { uint256 l2ForkBlock = Config.l2ForkBlockNumber(); if (isKarstBetanetL2ForkTest()) { if (l2ForkBlock == 0) { From a12f6d049295ce60b001efb2e37b882e76963964 Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Wed, 13 May 2026 11:51:13 +0200 Subject: [PATCH 06/29] Revert "test(acceptance-test-l2cm-karst): make the test proper e2e" This reverts commit 329e54d64094ca190d0c7257074d3f18cdd357d9. --- .../tests/karst/upgrade_test.go | 214 ++++++------------ op-core/predeploys/addresses.go | 2 - op-devstack/sysgo/deployer.go | 13 -- op-devstack/sysgo/l2_cl.go | 9 - op-devstack/sysgo/singlechain_build.go | 4 - op-node/rollup/derive/attributes.go | 6 - op-node/rollup/sequencing/sequencer.go | 5 - op-node/rollup/types.go | 48 ---- .../test/L2/MinimalTestL2CM.sol | 25 -- 9 files changed, 72 insertions(+), 254 deletions(-) delete mode 100644 packages/contracts-bedrock/test/L2/MinimalTestL2CM.sol diff --git a/op-acceptance-tests/tests/karst/upgrade_test.go b/op-acceptance-tests/tests/karst/upgrade_test.go index ca612d5b4cb..d5f952ae7eb 100644 --- a/op-acceptance-tests/tests/karst/upgrade_test.go +++ b/op-acceptance-tests/tests/karst/upgrade_test.go @@ -4,47 +4,43 @@ import ( "bytes" "encoding/json" "fmt" - "math/big" "os" "path/filepath" "testing" "time" + "github.com/ethereum-optimism/optimism/op-chain-ops/devkeys" "github.com/ethereum-optimism/optimism/op-chain-ops/genesis" gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types" "github.com/ethereum-optimism/optimism/op-core/predeploys" "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" "github.com/ethereum-optimism/optimism/op-devstack/presets" "github.com/ethereum-optimism/optimism/op-devstack/sysgo" - "github.com/ethereum-optimism/optimism/op-node/rollup/derive" ps "github.com/ethereum-optimism/optimism/op-proposer/proposer" opservice "github.com/ethereum-optimism/optimism/op-service" "github.com/ethereum-optimism/optimism/op-service/apis" "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/txplan" ethereum "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/common/hexutil" - "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/rpc" ) -// callVersionString calls version() on a contract and returns the decoded string. -func callVersionString(t devtest.T, client apis.EthClient, addr common.Address) string { - selector := crypto.Keccak256([]byte("version()"))[:4] - raw, err := client.Call(t.Ctx(), ethereum.CallMsg{To: &addr, Data: selector}, rpc.LatestBlockNumber) - t.Require().NoError(err, "version() call failed on %s", addr) - stringType, _ := abi.NewType("string", "", nil) - decoded, err := abi.Arguments{{Type: stringType}}.Unpack(raw) +var conditionalDeployerAddr = common.HexToAddress("0x420000000000000000000000000000000000002C") + +func loadSchemaRegistryInitcode(t devtest.T) []byte { + wd, err := os.Getwd() + t.Require().NoError(err) + root, err := opservice.FindMonorepoRoot(wd) t.Require().NoError(err) - return decoded[0].(string) -} -// loadInitcodeFromArtifact loads deployment bytecode from a forge artifact JSON file. -func loadInitcodeFromArtifact(t devtest.T, artifactPath string) []byte { + artifactPath := filepath.Join(root, "packages", "contracts-bedrock", "forge-artifacts", "SchemaRegistry.sol", "SchemaRegistry.json") data, err := os.ReadFile(artifactPath) - t.Require().NoError(err, "failed to read artifact: %s", artifactPath) + t.Require().NoError(err, "failed to read SchemaRegistry artifact") + var artifact struct { Bytecode struct { Object string `json:"object"` @@ -55,6 +51,17 @@ func loadInitcodeFromArtifact(t devtest.T, artifactPath string) []byte { return common.FromHex(artifact.Bytecode.Object) } +// callVersionString calls version() on a contract and returns the decoded string. +func callVersionString(t devtest.T, client apis.EthClient, addr common.Address) string { + selector := crypto.Keccak256([]byte("version()"))[:4] + raw, err := client.Call(t.Ctx(), ethereum.CallMsg{To: &addr, Data: selector}, rpc.LatestBlockNumber) + t.Require().NoError(err, "version() call failed on %s", addr) + stringType, _ := abi.NewType("string", "", nil) + decoded, err := abi.Arguments{{Type: stringType}}.Unpack(raw) + t.Require().NoError(err) + return decoded[0].(string) +} + // patchVersionInInitcode patches the version string inside initcode. // // Solidity stores a string constant via two instructions: @@ -70,6 +77,7 @@ func patchVersionInInitcode(t devtest.T, initcode []byte, currentVersion, newVer currentVersionBytes := []byte(currentVersion) newVersionBytes := []byte(newVersion) + // Build the expected 32-byte PUSH32 argument for the current version. currentPush32Arg := make([]byte, 32) copy(currentPush32Arg, currentVersionBytes) @@ -80,10 +88,12 @@ func patchVersionInInitcode(t devtest.T, initcode []byte, currentVersion, newVer result := make([]byte, len(initcode)) copy(result, initcode) + // Overwrite the 32-byte PUSH32 argument with the new version (left-aligned, zero-padded). newPush32Arg := make([]byte, 32) copy(newPush32Arg, newVersionBytes) copy(result[idx+1:idx+33], newPush32Arg) + // Scan backwards from the PUSH32 to find the PUSH1 instruction. searchStart := idx - 30 if searchStart < 0 { searchStart = 0 @@ -101,106 +111,12 @@ func patchVersionInInitcode(t devtest.T, initcode []byte, currentVersion, newVer return result } -// buildNUTDepositTx creates a serialized deposit transaction suitable for use as a custom NUT. -func buildNUTDepositTx(t devtest.T, intent string, from common.Address, to *common.Address, data []byte, gasLimit uint64) hexutil.Bytes { - source := derive.UpgradeDepositSource{Intent: intent} - depTx := &types.DepositTx{ - SourceHash: source.SourceHash(), - From: from, - To: to, - Mint: big.NewInt(0), - Value: big.NewInt(0), - Gas: gasLimit, - IsSystemTransaction: false, - Data: data, - } - encoded, err := types.NewTx(depTx).MarshalBinary() - t.Require().NoError(err, "failed to marshal NUT deposit tx") - return encoded -} - -// buildCustomNUTs constructs the three deposit transactions for the post-Karst upgrade: -// 1. ConditionalDeployer.deploy(salt, patchedSchemaRegistryInitcode) -// 2. ConditionalDeployer.deploy(salt2, minimalTestL2CMInitcode) -// 3. L2ProxyAdmin.upgradePredeploys(testL2CMAddr) -func buildCustomNUTs( - t devtest.T, - implSalt [32]byte, patchedInitcode []byte, - l2CMSalt [32]byte, l2CMInitcode []byte, - testL2CMAddr common.Address, -) ([]hexutil.Bytes, uint64) { - depositor := common.HexToAddress("0xDeaDDEaDDeAdDeAdDEAdDEaddeAddEAdDEAd0001") - cdAddr := predeploys.ConditionalDeployerAddr - paAddr := predeploys.ProxyAdminAddr - - cdABI, err := abi.JSON(bytes.NewReader([]byte( - `[{"inputs":[{"name":"_salt","type":"bytes32"},{"name":"_code","type":"bytes"}],"name":"deploy","outputs":[{"name":"implementation_","type":"address"}],"stateMutability":"nonpayable","type":"function"}]`, - ))) - t.Require().NoError(err) - - paABI, err := abi.JSON(bytes.NewReader([]byte( - `[{"inputs":[{"name":"_l2ContractsManager","type":"address"}],"name":"upgradePredeploys","outputs":[],"stateMutability":"nonpayable","type":"function"}]`, - ))) - t.Require().NoError(err) - - deployImplData, err := cdABI.Pack("deploy", implSalt, patchedInitcode) - t.Require().NoError(err) - - deployL2CMData, err := cdABI.Pack("deploy", l2CMSalt, l2CMInitcode) - t.Require().NoError(err) - - upgradeData, err := paABI.Pack("upgradePredeploys", testL2CMAddr) - t.Require().NoError(err) - - const implGas = uint64(3_000_000) - const l2CMGas = uint64(500_000) - const upgradeGas = uint64(300_000) - - tx1 := buildNUTDepositTx(t, "Custom NUT 0: Deploy Patched SchemaRegistry", depositor, &cdAddr, deployImplData, implGas) - tx2 := buildNUTDepositTx(t, "Custom NUT 1: Deploy MinimalTestL2CM", depositor, &cdAddr, deployL2CMData, l2CMGas) - tx3 := buildNUTDepositTx(t, "Custom NUT 2: Upgrade Predeploys", depositor, &paAddr, upgradeData, upgradeGas) - - return []hexutil.Bytes{tx1, tx2, tx3}, implGas + l2CMGas + upgradeGas -} - -// TestL2CMUpgrade_Karst simulates a post-Karst upgrade on an already-Karst chain. It deploys a -// patched SchemaRegistry implementation via ConditionalDeployer, deploys a MinimalTestL2CM, and -// triggers L2ProxyAdmin.upgradePredeploys() via DEPOSITOR — exactly the production NUT flow — -// then verifies the upgrade is observable on L2 and covered by an L1 dispute game. +// TestL2CMUpgrade_Karst deploys a patched SchemaRegistry implementation via +// ConditionalDeployer, upgrades the proxy via L2ProxyAdmin, and verifies the +// change is observable both on L2 (version string, ERC-1967 slot) and on L1 +// (dispute game covering the post-upgrade block). func TestL2CMUpgrade_Karst(gt *testing.T) { t := devtest.ParallelT(gt) - - wd, err := os.Getwd() - t.Require().NoError(err) - root, err := opservice.FindMonorepoRoot(wd) - t.Require().NoError(err) - - // Load forge artifacts. - schemaRegistryInitcode := loadInitcodeFromArtifact(t, - filepath.Join(root, "packages", "contracts-bedrock", "forge-artifacts", "SchemaRegistry.sol", "SchemaRegistry.json")) - minimalTestL2CMInitcode := loadInitcodeFromArtifact(t, - filepath.Join(root, "packages", "contracts-bedrock", "forge-artifacts", "MinimalTestL2CM.sol", "MinimalTestL2CM.json")) - - // Pre-compute the patched SchemaRegistry implementation address. - // The current version is patched to a recognisably high value so the upgrade is easily verified. - // (patchVersionInInitcode will fail loudly if the version string is not found in the bytecode.) - const newVersion = "999.999.999" - - var implSalt [32]byte - copy(implSalt[:], []byte("karst-upgrade-test-impl-v1")) - deterministicProxy := predeploys.DeterministicDeploymentProxyAddr - - var l2CMSalt [32]byte - copy(l2CMSalt[:], []byte("karst-upgrade-test-l2cm-v1")) - - // Pre-compute L2CM address: it depends on the patched impl address (baked as an immutable), - // but we need the impl address first. We will fill in the initcode after reading version(). - // Both addresses are deterministic from salts + initcodes, so they can be computed offline - // once the version is known. We defer that to after system startup (see below). - - // Schedule the custom NUT activation 30 seconds after genesis. - activation, nutOpt := sysgo.WithCustomNUTAtOffset(30) - sys := presets.NewMinimal(t, presets.WithDeployerOptions(sysgo.WithKarstAtGenesis), presets.WithGameTypeAdded(gameTypes.CannonGameType), @@ -208,50 +124,64 @@ func TestL2CMUpgrade_Karst(gt *testing.T) { presets.WithProposerOption(func(_ sysgo.ComponentTarget, cfg *ps.CLIConfig) { cfg.DisputeGameType = uint32(gameTypes.CannonGameType) }), - presets.WithGlobalL2CLOption(nutOpt), ) - // Read the current SchemaRegistry version from the running chain and build NUT transactions. + devKeys, err := devkeys.NewMnemonicDevKeys(devkeys.TestMnemonic) + t.Require().NoError(err) + paOwnerKey := devkeys.L2ProxyAdminOwnerRole.Key(sys.L2Chain.ChainID().ToBig()) + privKey, err := devKeys.Secret(paOwnerKey) + t.Require().NoError(err) + l2PAO := dsl.NewEOA(dsl.NewKey(t, privKey), sys.L2EL) + sys.FunderL2.Fund(l2PAO, eth.OneEther) + currentVersion := callVersionString(t, sys.L2EL.EthClient(), predeploys.SchemaRegistryAddr) t.Logger().Info("Current SchemaRegistry version", "version", currentVersion) - patchedInitcode := patchVersionInInitcode(t, schemaRegistryInitcode, currentVersion, newVersion) - newImplAddr := crypto.CreateAddress2(deterministicProxy, implSalt, crypto.Keccak256(patchedInitcode)) - t.Logger().Info("Pre-computed patched SchemaRegistry implementation", "addr", newImplAddr) + const newVersion = "999.999.999" + initcode := loadSchemaRegistryInitcode(t) + initcode = patchVersionInInitcode(t, initcode, currentVersion, newVersion) + + var salt [32]byte + copy(salt[:], []byte("karst-upgrade-test-v1")) + + deterministicProxy := predeploys.DeterministicDeploymentProxyAddr + newImplAddr := crypto.CreateAddress2(deterministicProxy, salt, crypto.Keccak256(initcode)) - // Build MinimalTestL2CM initcode with constructor args (proxy, newImpl). - addressType, _ := abi.NewType("address", "", nil) - ctorArgs, err := abi.Arguments{{Type: addressType}, {Type: addressType}}.Pack(predeploys.SchemaRegistryAddr, newImplAddr) + cdABI, err := abi.JSON(bytes.NewReader([]byte( + `[{"inputs":[{"name":"_salt","type":"bytes32"},{"name":"_code","type":"bytes"}],"name":"deploy","outputs":[{"name":"implementation_","type":"address"}],"stateMutability":"nonpayable","type":"function"}]`, + ))) + t.Require().NoError(err) + deployCalldata, err := cdABI.Pack("deploy", salt, initcode) t.Require().NoError(err) - l2CMInitcodeWithArgs := append(minimalTestL2CMInitcode, ctorArgs...) - testL2CMAddr := crypto.CreateAddress2(deterministicProxy, l2CMSalt, crypto.Keccak256(l2CMInitcodeWithArgs)) - t.Logger().Info("Pre-computed MinimalTestL2CM", "addr", testL2CMAddr) - // Register the NUT transactions before the activation block is sequenced. - nutTxs, totalGas := buildCustomNUTs(t, implSalt, patchedInitcode, l2CMSalt, l2CMInitcodeWithArgs, testL2CMAddr) - activation.SetTransactions(nutTxs, totalGas) - t.Logger().Info("Registered custom NUT transactions", "activationTime", activation.Time, "txCount", len(nutTxs)) + cdAddr := conditionalDeployerAddr + l2PAO.Transact(l2PAO.Plan(), txplan.WithTo(&cdAddr), txplan.WithData(deployCalldata)) + t.Logger().Info("Deployed patched SchemaRegistry implementation", "impl", newImplAddr) - // Wait for the activation block (poll until version() returns the new version). - t.Require().Eventually(func() bool { - ver := callVersionString(t, sys.L2EL.EthClient(), predeploys.SchemaRegistryAddr) - return ver == newVersion - }, 2*time.Minute, 2*time.Second, fmt.Sprintf("SchemaRegistry version should update to %s after custom NUT activation", newVersion)) - t.Logger().Info("Verified version() on L2", "version", newVersion) + paABI, err := abi.JSON(bytes.NewReader([]byte( + `[{"inputs":[{"name":"_proxy","type":"address"},{"name":"_implementation","type":"address"}],"name":"upgrade","outputs":[],"stateMutability":"nonpayable","type":"function"}]`, + ))) + t.Require().NoError(err) + upgradeCalldata, err := paABI.Pack("upgrade", predeploys.SchemaRegistryAddr, newImplAddr) + t.Require().NoError(err) - // Verify ERC-1967 implementation slot. + proxyAdminAddr := predeploys.ProxyAdminAddr + upgradeTx := l2PAO.Transact(l2PAO.Plan(), txplan.WithTo(&proxyAdminAddr), txplan.WithData(upgradeCalldata)) + upgradeReceipt, err := upgradeTx.Included.Eval(t.Ctx()) + t.Require().NoError(err) + upgradeBlockNumber := upgradeReceipt.BlockNumber.Uint64() + t.Logger().Info("Upgraded SchemaRegistry proxy", "block", upgradeBlockNumber, "newImpl", newImplAddr) + + // Verify ERC-1967 slot and version() on L2. implSlot, err := sys.L2EL.EthClient().GetStorageAt( t.Ctx(), predeploys.SchemaRegistryAddr, genesis.ImplementationSlot, "latest", ) t.Require().NoError(err) t.Require().Equal(newImplAddr, common.BytesToAddress(implSlot[:])) + t.Require().Equal(newVersion, callVersionString(t, sys.L2EL.EthClient(), predeploys.SchemaRegistryAddr)) + t.Logger().Info("Verified version() on L2", "version", newVersion) - // Retrieve the block number at which the upgrade landed. - latestBlock, err := sys.L2EL.EthClient().InfoByLabel(t.Ctx(), eth.Unsafe) - t.Require().NoError(err) - upgradeBlockNumber := latestBlock.NumberU64() - - // Wait for an L1 dispute game covering the post-upgrade block. + // Wait for a dispute game on L1 that covers the post-upgrade block. dgf := sys.DisputeGameFactory() initialGameCount := dgf.GameCount() t.Require().Eventually(func() bool { diff --git a/op-core/predeploys/addresses.go b/op-core/predeploys/addresses.go index 25f1de17753..fc677c21c99 100644 --- a/op-core/predeploys/addresses.go +++ b/op-core/predeploys/addresses.go @@ -32,7 +32,6 @@ const ( ETHLiquidity = "0x4200000000000000000000000000000000000025" NativeAssetLiquidity = "0x4200000000000000000000000000000000000029" LiquidityController = "0x420000000000000000000000000000000000002a" - ConditionalDeployer = "0x420000000000000000000000000000000000002C" Create2Deployer = "0x13b0D85CcB8bf860b6b79AF3029fCA081AE9beF2" MultiCall3 = "0xcA11bde05977b3631167028862bE2a173976CA11" Safe_v130 = "0x69f4D1788e39c87893C980c06EdF4b7f686e2938" @@ -75,7 +74,6 @@ var ( ETHLiquidityAddr = common.HexToAddress(ETHLiquidity) NativeAssetLiquidityAddr = common.HexToAddress(NativeAssetLiquidity) LiquidityControllerAddr = common.HexToAddress(LiquidityController) - ConditionalDeployerAddr = common.HexToAddress(ConditionalDeployer) Create2DeployerAddr = common.HexToAddress(Create2Deployer) MultiCall3Addr = common.HexToAddress(MultiCall3) Safe_v130Addr = common.HexToAddress(Safe_v130) diff --git a/op-devstack/sysgo/deployer.go b/op-devstack/sysgo/deployer.go index 334b01278f4..10cd0561faf 100644 --- a/op-devstack/sysgo/deployer.go +++ b/op-devstack/sysgo/deployer.go @@ -80,19 +80,6 @@ func WithJovianAtGenesis(p devtest.T, _ devkeys.Keys, builder intentbuilder.Buil } } -// WithCustomNUTAtOffset returns a *rollup.CustomNUTActivation handle and an L2CLOption that -// schedules custom NUT deposit transactions to be injected at genesis-time + offsetSeconds. -// The caller must call SetTransactions on the returned handle before the activation block is -// sequenced. Intended for tests only; never use in production. -func WithCustomNUTAtOffset(offsetSeconds uint64) (*rollup.CustomNUTActivation, L2CLOption) { - activation := &rollup.CustomNUTActivation{} - opt := L2CLOptionFn(func(_ devtest.T, _ ComponentTarget, cfg *L2CLConfig) { - cfg.customNUTActivationOffset = offsetSeconds - cfg.customNUTActivation = activation - }) - return activation, opt -} - func WithL2GasLimit(gasLimit uint64) DeployerOption { return func(_ devtest.T, _ devkeys.Keys, builder intentbuilder.Builder) { for _, l2Cfg := range builder.L2s() { diff --git a/op-devstack/sysgo/l2_cl.go b/op-devstack/sysgo/l2_cl.go index d1d7024ae2d..8945523c9ed 100644 --- a/op-devstack/sysgo/l2_cl.go +++ b/op-devstack/sysgo/l2_cl.go @@ -5,7 +5,6 @@ import ( "github.com/ethereum-optimism/optimism/op-devstack/devtest" "github.com/ethereum-optimism/optimism/op-devstack/stack" - "github.com/ethereum-optimism/optimism/op-node/rollup" nodeSync "github.com/ethereum-optimism/optimism/op-node/rollup/sync" "github.com/ethereum-optimism/optimism/op-service/eth" ) @@ -41,14 +40,6 @@ type L2CLConfig struct { // OffsetELSafe retracts safe and finalized from the EL-sync tip by floor(OffsetELSafe / L2BlockTime) blocks. OffsetELSafe time.Duration - - // customNUTActivationOffset, if non-zero, triggers injection of custom NUT transactions at - // genesis-time + customNUTActivationOffset seconds. The *rollup.CustomNUTActivation handle - // returned by WithCustomNUTAtOffset must have SetTransactions called before that block. - customNUTActivationOffset uint64 - // customNUTActivation is the shared handle written by singlechain_build once the genesis time - // is known. The caller holds the same pointer to feed in transactions after startup. - customNUTActivation *rollup.CustomNUTActivation } func L2CLSequencer() L2CLOption { diff --git a/op-devstack/sysgo/singlechain_build.go b/op-devstack/sysgo/singlechain_build.go index 2ab3459592f..c456e07ecfb 100644 --- a/op-devstack/sysgo/singlechain_build.go +++ b/op-devstack/sysgo/singlechain_build.go @@ -430,10 +430,6 @@ func startL2CLNode( IgnoreMissingPectraBlobSchedule: false, ExperimentalOPStackAPI: true, } - if cfg.customNUTActivation != nil { - cfg.customNUTActivation.Time = l2Net.rollupCfg.Genesis.L2Time + cfg.customNUTActivationOffset - nodeCfg.Rollup.CustomNUTActivation = cfg.customNUTActivation - } l2CL := &OpNode{ name: startCfg.Key, opNode: nil, diff --git a/op-node/rollup/derive/attributes.go b/op-node/rollup/derive/attributes.go index 4960a82b932..3b24a36ae74 100644 --- a/op-node/rollup/derive/attributes.go +++ b/op-node/rollup/derive/attributes.go @@ -167,12 +167,6 @@ func (ba *FetchingAttributesBuilder) PreparePayloadAttributes(ctx context.Contex upgradeGas += nutGas } - if ba.rollupCfg.IsCustomNUTActivationBlock(nextL2Time) { - customTxs, customGas := ba.rollupCfg.CustomNUTActivation.Transactions() - upgradeTxs = append(upgradeTxs, customTxs...) - upgradeGas += customGas - } - // TODO(#19239): migrate Interop to NUT bundle and add its gas to upgradeGas. if ba.rollupCfg.IsInteropActivationBlock(nextL2Time) { interop, err := InteropNetworkUpgradeTransactions() diff --git a/op-node/rollup/sequencing/sequencer.go b/op-node/rollup/sequencing/sequencer.go index cba4708c133..5e8d9765b96 100644 --- a/op-node/rollup/sequencing/sequencer.go +++ b/op-node/rollup/sequencing/sequencer.go @@ -593,11 +593,6 @@ func (d *Sequencer) startBuildingBlock() { d.log.Info("Sequencing Karst upgrade block") } - if d.rollupCfg.IsCustomNUTActivationBlock(uint64(attrs.Timestamp)) { - attrs.NoTxPool = true - d.log.Info("Sequencing custom NUT upgrade block") - } - // For the Interop activation block we must not include any sequencer transactions. if d.rollupCfg.IsInteropActivationBlock(uint64(attrs.Timestamp)) { attrs.NoTxPool = true diff --git a/op-node/rollup/types.go b/op-node/rollup/types.go index c02dcad6761..c80b22aa5b3 100644 --- a/op-node/rollup/types.go +++ b/op-node/rollup/types.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "math/big" - "sync" "time" altda "github.com/ethereum-optimism/optimism/op-alt-da" @@ -15,7 +14,6 @@ import ( "github.com/ethereum-optimism/optimism/op-service/eth" "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/params" @@ -167,39 +165,6 @@ type Config struct { // This feature (de)activates by L1 origin timestamp, to keep a consistent L1 block info per L2 // epoch. PectraBlobScheduleTime *uint64 `json:"pectra_blob_schedule_time,omitempty"` - - // CustomNUTActivation is a test-only hook for injecting custom NUT deposit transactions at a - // specific L2 timestamp, simulating a future post-Karst upgrade. Not serialized; must not be - // used in production. - CustomNUTActivation *CustomNUTActivation `json:"-"` -} - -// CustomNUTActivation holds the activation timestamp and NUT deposit transactions for a test-only -// post-Karst upgrade simulation. Transactions may be set after construction (before the activation -// block is sequenced) via SetTransactions. -type CustomNUTActivation struct { - // Time is the L2 block timestamp at which to inject the NUT transactions. - Time uint64 - - mu sync.Mutex - txs []hexutil.Bytes - totalGas uint64 -} - -// SetTransactions sets the serialized deposit transactions and their combined gas limit. -// Must be called before the activation block is sequenced. -func (a *CustomNUTActivation) SetTransactions(txs []hexutil.Bytes, totalGas uint64) { - a.mu.Lock() - defer a.mu.Unlock() - a.txs = txs - a.totalGas = totalGas -} - -// Transactions returns the serialized deposit transactions and their combined gas limit. -func (a *CustomNUTActivation) Transactions() ([]hexutil.Bytes, uint64) { - a.mu.Lock() - defer a.mu.Unlock() - return a.txs, a.totalGas } // ValidateL1Config checks L1 config variables for errors. @@ -605,19 +570,6 @@ func (c *Config) IsKarstActivationBlock(l2BlockTime uint64) bool { !c.IsKarst(l2BlockTime-c.BlockTime) } -// IsCustomNUTActivationBlock returns true for the first L2 block at or after the custom NUT -// activation timestamp. Used to inject test-only NUT transactions; never true in production -// (CustomNUTActivation is nil when loaded from JSON). -func (c *Config) IsCustomNUTActivationBlock(l2BlockTime uint64) bool { - if c.CustomNUTActivation == nil { - return false - } - t := c.CustomNUTActivation.Time - return l2BlockTime >= t && - l2BlockTime >= c.BlockTime && - l2BlockTime-c.BlockTime < t -} - func (c *Config) IsInteropActivationBlock(l2BlockTime uint64) bool { return c.IsInterop(l2BlockTime) && l2BlockTime >= c.BlockTime && diff --git a/packages/contracts-bedrock/test/L2/MinimalTestL2CM.sol b/packages/contracts-bedrock/test/L2/MinimalTestL2CM.sol deleted file mode 100644 index 4fff2f08163..00000000000 --- a/packages/contracts-bedrock/test/L2/MinimalTestL2CM.sol +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.15; - -interface IProxy { - function upgradeTo(address _implementation) external; -} - -/// @title MinimalTestL2CM -/// @notice Test-only minimal L2ContractsManager that upgrades a single predeploy proxy. -/// Designed to be called via DELEGATECALL from L2ProxyAdmin.upgradePredeploys(): -/// in that context address(this) == L2ProxyAdmin, which is the proxy admin, so -/// the upgradeTo call is authorized. -contract MinimalTestL2CM { - address public immutable proxy; - address public immutable newImpl; - - constructor(address _proxy, address _newImpl) { - proxy = _proxy; - newImpl = _newImpl; - } - - function upgrade() external { - IProxy(proxy).upgradeTo(newImpl); - } -} From e2eb850828f39e5f3d99554aebaac01cbe1cd9f7 Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Wed, 13 May 2026 11:51:42 +0200 Subject: [PATCH 07/29] Revert "test(acceptance-test-l2cm-karst): add acceptance test for doing a minimal upgrade with l2cm" This reverts commit 9782ee95c2278e042bb2e3e12d4662fe06fe0feb. --- .../tests/karst/upgrade_test.go | 197 ------------------ 1 file changed, 197 deletions(-) delete mode 100644 op-acceptance-tests/tests/karst/upgrade_test.go diff --git a/op-acceptance-tests/tests/karst/upgrade_test.go b/op-acceptance-tests/tests/karst/upgrade_test.go deleted file mode 100644 index d5f952ae7eb..00000000000 --- a/op-acceptance-tests/tests/karst/upgrade_test.go +++ /dev/null @@ -1,197 +0,0 @@ -package karst - -import ( - "bytes" - "encoding/json" - "fmt" - "os" - "path/filepath" - "testing" - "time" - - "github.com/ethereum-optimism/optimism/op-chain-ops/devkeys" - "github.com/ethereum-optimism/optimism/op-chain-ops/genesis" - gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types" - "github.com/ethereum-optimism/optimism/op-core/predeploys" - "github.com/ethereum-optimism/optimism/op-devstack/devtest" - "github.com/ethereum-optimism/optimism/op-devstack/dsl" - "github.com/ethereum-optimism/optimism/op-devstack/presets" - "github.com/ethereum-optimism/optimism/op-devstack/sysgo" - ps "github.com/ethereum-optimism/optimism/op-proposer/proposer" - opservice "github.com/ethereum-optimism/optimism/op-service" - "github.com/ethereum-optimism/optimism/op-service/apis" - "github.com/ethereum-optimism/optimism/op-service/eth" - "github.com/ethereum-optimism/optimism/op-service/txplan" - ethereum "github.com/ethereum/go-ethereum" - "github.com/ethereum/go-ethereum/accounts/abi" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/crypto" - "github.com/ethereum/go-ethereum/rpc" -) - -var conditionalDeployerAddr = common.HexToAddress("0x420000000000000000000000000000000000002C") - -func loadSchemaRegistryInitcode(t devtest.T) []byte { - wd, err := os.Getwd() - t.Require().NoError(err) - root, err := opservice.FindMonorepoRoot(wd) - t.Require().NoError(err) - - artifactPath := filepath.Join(root, "packages", "contracts-bedrock", "forge-artifacts", "SchemaRegistry.sol", "SchemaRegistry.json") - data, err := os.ReadFile(artifactPath) - t.Require().NoError(err, "failed to read SchemaRegistry artifact") - - var artifact struct { - Bytecode struct { - Object string `json:"object"` - } `json:"bytecode"` - } - t.Require().NoError(json.Unmarshal(data, &artifact)) - t.Require().NotEmpty(artifact.Bytecode.Object) - return common.FromHex(artifact.Bytecode.Object) -} - -// callVersionString calls version() on a contract and returns the decoded string. -func callVersionString(t devtest.T, client apis.EthClient, addr common.Address) string { - selector := crypto.Keccak256([]byte("version()"))[:4] - raw, err := client.Call(t.Ctx(), ethereum.CallMsg{To: &addr, Data: selector}, rpc.LatestBlockNumber) - t.Require().NoError(err, "version() call failed on %s", addr) - stringType, _ := abi.NewType("string", "", nil) - decoded, err := abi.Arguments{{Type: stringType}}.Unpack(raw) - t.Require().NoError(err) - return decoded[0].(string) -} - -// patchVersionInInitcode patches the version string inside initcode. -// -// Solidity stores a string constant via two instructions: -// - PUSH1 — the string length, a few bytes before PUSH32 -// - PUSH32 — the string data -// -// We locate both by searching for the PUSH32 pattern (0x7f + currentVersion -// left-aligned in 32 zero-padded bytes) and scanning backwards for the -// length PUSH1. This makes the patch independent of the current version value. -func patchVersionInInitcode(t devtest.T, initcode []byte, currentVersion, newVersion string) []byte { - t.Require().LessOrEqual(len([]byte(newVersion)), 32, "new version must fit in a PUSH32 (max 32 bytes)") - - currentVersionBytes := []byte(currentVersion) - newVersionBytes := []byte(newVersion) - - // Build the expected 32-byte PUSH32 argument for the current version. - currentPush32Arg := make([]byte, 32) - copy(currentPush32Arg, currentVersionBytes) - - push32Pattern := append([]byte{0x7f}, currentPush32Arg...) - idx := bytes.Index(initcode, push32Pattern) - t.Require().GreaterOrEqual(idx, 0, "PUSH32 version pattern not found in initcode (current version: %q)", currentVersion) - - result := make([]byte, len(initcode)) - copy(result, initcode) - - // Overwrite the 32-byte PUSH32 argument with the new version (left-aligned, zero-padded). - newPush32Arg := make([]byte, 32) - copy(newPush32Arg, newVersionBytes) - copy(result[idx+1:idx+33], newPush32Arg) - - // Scan backwards from the PUSH32 to find the PUSH1 instruction. - searchStart := idx - 30 - if searchStart < 0 { - searchStart = 0 - } - found := false - for i := idx - 1; i >= searchStart; i-- { - if result[i] == 0x60 && int(result[i+1]) == len(currentVersionBytes) { - result[i+1] = byte(len(newVersionBytes)) - found = true - break - } - } - t.Require().True(found, "PUSH1 length byte not found near version PUSH32 (current version: %q)", currentVersion) - - return result -} - -// TestL2CMUpgrade_Karst deploys a patched SchemaRegistry implementation via -// ConditionalDeployer, upgrades the proxy via L2ProxyAdmin, and verifies the -// change is observable both on L2 (version string, ERC-1967 slot) and on L1 -// (dispute game covering the post-upgrade block). -func TestL2CMUpgrade_Karst(gt *testing.T) { - t := devtest.ParallelT(gt) - sys := presets.NewMinimal(t, - presets.WithDeployerOptions(sysgo.WithKarstAtGenesis), - presets.WithGameTypeAdded(gameTypes.CannonGameType), - presets.WithRespectedGameTypeOverride(gameTypes.CannonGameType), - presets.WithProposerOption(func(_ sysgo.ComponentTarget, cfg *ps.CLIConfig) { - cfg.DisputeGameType = uint32(gameTypes.CannonGameType) - }), - ) - - devKeys, err := devkeys.NewMnemonicDevKeys(devkeys.TestMnemonic) - t.Require().NoError(err) - paOwnerKey := devkeys.L2ProxyAdminOwnerRole.Key(sys.L2Chain.ChainID().ToBig()) - privKey, err := devKeys.Secret(paOwnerKey) - t.Require().NoError(err) - l2PAO := dsl.NewEOA(dsl.NewKey(t, privKey), sys.L2EL) - sys.FunderL2.Fund(l2PAO, eth.OneEther) - - currentVersion := callVersionString(t, sys.L2EL.EthClient(), predeploys.SchemaRegistryAddr) - t.Logger().Info("Current SchemaRegistry version", "version", currentVersion) - - const newVersion = "999.999.999" - initcode := loadSchemaRegistryInitcode(t) - initcode = patchVersionInInitcode(t, initcode, currentVersion, newVersion) - - var salt [32]byte - copy(salt[:], []byte("karst-upgrade-test-v1")) - - deterministicProxy := predeploys.DeterministicDeploymentProxyAddr - newImplAddr := crypto.CreateAddress2(deterministicProxy, salt, crypto.Keccak256(initcode)) - - cdABI, err := abi.JSON(bytes.NewReader([]byte( - `[{"inputs":[{"name":"_salt","type":"bytes32"},{"name":"_code","type":"bytes"}],"name":"deploy","outputs":[{"name":"implementation_","type":"address"}],"stateMutability":"nonpayable","type":"function"}]`, - ))) - t.Require().NoError(err) - deployCalldata, err := cdABI.Pack("deploy", salt, initcode) - t.Require().NoError(err) - - cdAddr := conditionalDeployerAddr - l2PAO.Transact(l2PAO.Plan(), txplan.WithTo(&cdAddr), txplan.WithData(deployCalldata)) - t.Logger().Info("Deployed patched SchemaRegistry implementation", "impl", newImplAddr) - - paABI, err := abi.JSON(bytes.NewReader([]byte( - `[{"inputs":[{"name":"_proxy","type":"address"},{"name":"_implementation","type":"address"}],"name":"upgrade","outputs":[],"stateMutability":"nonpayable","type":"function"}]`, - ))) - t.Require().NoError(err) - upgradeCalldata, err := paABI.Pack("upgrade", predeploys.SchemaRegistryAddr, newImplAddr) - t.Require().NoError(err) - - proxyAdminAddr := predeploys.ProxyAdminAddr - upgradeTx := l2PAO.Transact(l2PAO.Plan(), txplan.WithTo(&proxyAdminAddr), txplan.WithData(upgradeCalldata)) - upgradeReceipt, err := upgradeTx.Included.Eval(t.Ctx()) - t.Require().NoError(err) - upgradeBlockNumber := upgradeReceipt.BlockNumber.Uint64() - t.Logger().Info("Upgraded SchemaRegistry proxy", "block", upgradeBlockNumber, "newImpl", newImplAddr) - - // Verify ERC-1967 slot and version() on L2. - implSlot, err := sys.L2EL.EthClient().GetStorageAt( - t.Ctx(), predeploys.SchemaRegistryAddr, genesis.ImplementationSlot, "latest", - ) - t.Require().NoError(err) - t.Require().Equal(newImplAddr, common.BytesToAddress(implSlot[:])) - t.Require().Equal(newVersion, callVersionString(t, sys.L2EL.EthClient(), predeploys.SchemaRegistryAddr)) - t.Logger().Info("Verified version() on L2", "version", newVersion) - - // Wait for a dispute game on L1 that covers the post-upgrade block. - dgf := sys.DisputeGameFactory() - initialGameCount := dgf.GameCount() - t.Require().Eventually(func() bool { - count := dgf.GameCount() - for i := initialGameCount; i < count; i++ { - if dgf.GameAtIndex(i).L2SequenceNumber() >= upgradeBlockNumber { - return true - } - } - return false - }, 5*time.Minute, 5*time.Second, fmt.Sprintf("L1 must have a dispute game covering upgrade block %d", upgradeBlockNumber)) - t.Logger().Info("Verified L1 dispute game covers upgrade block", "block", upgradeBlockNumber) -} From 81bcb094078afff480e9dfd68679964d3e550c67 Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Wed, 13 May 2026 12:10:52 +0200 Subject: [PATCH 08/29] fix(acceptance-test-l2cm-karst): remove karst activation check in fork test due to PastUpgrades handling it already --- .../test/L2/fork/L2ForkUpgrade.t.sol | 13 ++----------- packages/contracts-bedrock/test/setup/Setup.sol | 6 ------ 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol b/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol index 11c17cd304c..1f5b960d83b 100644 --- a/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol +++ b/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol @@ -90,12 +90,9 @@ contract L2ForkUpgrade_TestInit is CommonTest { } /// @notice Executes the current generated NUT bundle with any fork-specific wrappers. - /// Skipped when the network already has the upgrade applied. function _executeCurrentBundle() internal virtual { - if (!isKarstForkActivated()) { - PastNUTBundles.ForkWrappers memory w = PastNUTBundles.wrappersForFork(currentFork); - PastNUTBundles.executeWithWrappers(executeScript, w.pre, _currentBundleTxns(), w.post); - } + PastNUTBundles.ForkWrappers memory w = PastNUTBundles.wrappersForFork(currentFork); + PastNUTBundles.executeWithWrappers(executeScript, w.pre, _currentBundleTxns(), w.post); } /// @notice Copies the cached current bundle transactions from storage to memory. @@ -691,12 +688,6 @@ contract L2ForkUpgrade_Events_Test is L2ForkUpgrade_TestInit { // Skip if running with an unoptimized Foundry profile skipIfUnoptimized(); - // Events were emitted in the past and cannot be replayed on an already-upgraded network - if (isKarstForkActivated()) { - vm.skip(true); - return; - } - // Get StorageSetter implementation to filter out intermediate upgrade events (address storageSetterImpl,,,) = generateScript.implementationConfigs("StorageSetter"); diff --git a/packages/contracts-bedrock/test/setup/Setup.sol b/packages/contracts-bedrock/test/setup/Setup.sol index ac7f944ab78..241f1c52798 100644 --- a/packages/contracts-bedrock/test/setup/Setup.sol +++ b/packages/contracts-bedrock/test/setup/Setup.sol @@ -366,12 +366,6 @@ abstract contract Setup is FeatureFlags { } } - /// @dev Returns true if the forked L2 network has the Karst upgrade applied, by checking if the ConditionalDeployer predeploy exists. - /// When true, fork upgrade tests skip bundle execution but still run verification. - function isKarstForkActivated() public view returns (bool) { - return Predeploys.CONDITIONAL_DEPLOYER.code.length > 0; - } - /// @dev Sets up the L1 contracts. function L1() public { console.log("Setup: creating L1 deployments"); From da9943e68d2447c1c060eb6df713ca47e221ddab Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Wed, 13 May 2026 12:41:36 +0200 Subject: [PATCH 09/29] test(acceptance-test-l2cm-karst): add test to check the presence of the deterministic deployer --- .../test/L2/fork/L2ForkUpgrade.t.sol | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol b/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol index 1f5b960d83b..3145d751292 100644 --- a/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol +++ b/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol @@ -15,6 +15,7 @@ import { UpgradeUtils } from "scripts/libraries/UpgradeUtils.sol"; // Libraries import { LibString } from "@solady/utils/LibString.sol"; import { Predeploys } from "src/libraries/Predeploys.sol"; +import { Preinstalls } from "src/libraries/Preinstalls.sol"; import { DevFeatures } from "src/libraries/DevFeatures.sol"; import { SemverComp } from "src/libraries/SemverComp.sol"; import { Types } from "src/libraries/Types.sol"; @@ -982,3 +983,21 @@ contract L2ForkUpgrade_GasProfile_Test is L2ForkUpgrade_TestInit { _logAdjustments(measurements); } } + +/// @title L2ForkUpgrade_DeterministicDeploymentProxy_Test +/// @notice Sanity check that the forked L2 has the deterministic deployment proxy preinstall. +contract L2ForkUpgrade_DeterministicDeploymentProxy_Test is CommonTest { + function setUp() public virtual override { + super.setUp(); + skipIfNotL2ForkTest("L2ForkUpgrade: deterministic deployer test requires L2 fork"); + } + + /// @notice Arachnid's proxy must be deployed at the canonical address on forked L2 state. + function test_fork_deterministicDeploymentProxy_exists_succeeds() external view { + address proxy = Preinstalls.DeterministicDeploymentProxy; + assertNotEq(proxy.code.length, 0, "DeterministicDeploymentProxy must have code"); + assertEq( + proxy.code, Preinstalls.DeterministicDeploymentProxyCode, "unexpected DeterministicDeploymentProxy bytecode" + ); + } +} From 331ce17b054f7c1c3ed39844d9c87d8ab4a743a3 Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Wed, 13 May 2026 15:39:17 +0200 Subject: [PATCH 10/29] fix(acceptance-test-l2cm-karst): fix testname lint error --- packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol b/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol index 3145d751292..c7307d4f0f9 100644 --- a/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol +++ b/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol @@ -993,7 +993,7 @@ contract L2ForkUpgrade_DeterministicDeploymentProxy_Test is CommonTest { } /// @notice Arachnid's proxy must be deployed at the canonical address on forked L2 state. - function test_fork_deterministicDeploymentProxy_exists_succeeds() external view { + function test_l2ForkUpgrade_deterministicDeploymentProxyExistence_succeeds() external view { address proxy = Preinstalls.DeterministicDeploymentProxy; assertNotEq(proxy.code.length, 0, "DeterministicDeploymentProxy must have code"); assertEq( From 96f15bc6ad5e392e49519ebd7d0a03f3f3781667 Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Wed, 13 May 2026 20:03:54 +0200 Subject: [PATCH 11/29] fix(acceptance-test-l2cm-karst): remove misleading comment --- packages/contracts-bedrock/justfile | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/contracts-bedrock/justfile b/packages/contracts-bedrock/justfile index 96dd58a6cac..28081ef8b4e 100644 --- a/packages/contracts-bedrock/justfile +++ b/packages/contracts-bedrock/justfile @@ -228,7 +228,6 @@ test-l2-fork-upgrade-rerun *ARGS: just test-l2-fork-upgrade {{ARGS}} --rerun -vvvv # Runs L2 fork upgrade tests against a Karst betanet L2 fork. -# Skips bundle execution and only runs verification steps. # Usage: KARST_BETANET_L2_FORK_RPC_URL= just test-karst-betanet-l2-fork-upgrade [ARGS] test-karst-betanet-l2-fork-upgrade *ARGS: KARST_BETANET_L2_FORK_TEST=true just prepare-l2-upgrade-env "just test {{ARGS}}" From 7cd0a82a3ba4f349a0203f4e8068eb33ed192179 Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Fri, 15 May 2026 14:51:57 +0200 Subject: [PATCH 12/29] fix(acceptance-test-l2cm-karst): skip execute and events test when current bundle is applied --- .../test/L2/fork/L2ForkUpgrade.t.sol | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol b/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol index c7307d4f0f9..ec27b85739d 100644 --- a/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol +++ b/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol @@ -20,6 +20,7 @@ import { DevFeatures } from "src/libraries/DevFeatures.sol"; import { SemverComp } from "src/libraries/SemverComp.sol"; import { Types } from "src/libraries/Types.sol"; import { NetworkUpgradeTxns } from "src/libraries/NetworkUpgradeTxns.sol"; +import { Constants } from "src/libraries/Constants.sol"; // Interfaces import { ICrossDomainMessenger } from "interfaces/universal/ICrossDomainMessenger.sol"; @@ -91,11 +92,22 @@ contract L2ForkUpgrade_TestInit is CommonTest { } /// @notice Executes the current generated NUT bundle with any fork-specific wrappers. + /// No-op when the bundle has already been applied to the forked chain. function _executeCurrentBundle() internal virtual { + if (_isCurrentBundleAlreadyApplied()) return; PastNUTBundles.ForkWrappers memory w = PastNUTBundles.wrappersForFork(currentFork); PastNUTBundles.executeWithWrappers(executeScript, w.pre, _currentBundleTxns(), w.post); } + /// @notice Returns true when the current bundle has already been applied to the forked chain. + /// Uses two checks: ConditionalDeployer exists (Karst ran) and this bundle's + /// L2ContractsManager was deployed at its expected address. + function _isCurrentBundleAlreadyApplied() internal view returns (bool) { + if (Predeploys.CONDITIONAL_DEPLOYER.code.length == 0) return false; + address l2cm = PastNUTBundles.extractL2CM(_currentBundleTxns(), Constants.CURRENT_BUNDLE_PATH); + return l2cm.code.length > 0; + } + /// @notice Copies the cached current bundle transactions from storage to memory. function _currentBundleTxns() internal view returns (NetworkUpgradeTxns.NetworkUpgradeTxn[] memory txns_) { uint256 len = currentBundleTxns.length; @@ -689,6 +701,13 @@ contract L2ForkUpgrade_Events_Test is L2ForkUpgrade_TestInit { // Skip if running with an unoptimized Foundry profile skipIfUnoptimized(); + // Skip when the bundle is already applied: Upgraded events are historical and cannot be + // replayed via vm.recordLogs() since _executeCurrentBundle() is a no-op in that case. + if (_isCurrentBundleAlreadyApplied()) { + vm.skip(true); + return; + } + // Get StorageSetter implementation to filter out intermediate upgrade events (address storageSetterImpl,,,) = generateScript.implementationConfigs("StorageSetter"); From 71506e60122f4e0d5c7e2bc2a54210ad6f35e50a Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Mon, 18 May 2026 23:28:06 +0200 Subject: [PATCH 13/29] fix(acceptance-test-l2cm-karst): rework betanet upgrade test to work with before/after blocknumbers and capture events from the activation block --- packages/contracts-bedrock/justfile | 12 ++-- .../scripts/libraries/Config.sol | 16 ++++-- .../test/L2/fork/L2ForkUpgrade.t.sol | 57 ++++++++++++++++--- .../contracts-bedrock/test/setup/Setup.sol | 18 ++---- 4 files changed, 71 insertions(+), 32 deletions(-) diff --git a/packages/contracts-bedrock/justfile b/packages/contracts-bedrock/justfile index 28081ef8b4e..c5c99d906c7 100644 --- a/packages/contracts-bedrock/justfile +++ b/packages/contracts-bedrock/justfile @@ -228,11 +228,15 @@ test-l2-fork-upgrade-rerun *ARGS: just test-l2-fork-upgrade {{ARGS}} --rerun -vvvv # Runs L2 fork upgrade tests against a Karst betanet L2 fork. -# Usage: KARST_BETANET_L2_FORK_RPC_URL= just test-karst-betanet-l2-fork-upgrade [ARGS] -test-karst-betanet-l2-fork-upgrade *ARGS: - KARST_BETANET_L2_FORK_TEST=true just prepare-l2-upgrade-env "just test {{ARGS}}" +# Env Vars: +# - L2_FORK_RPC_URL must be set to a post-Karst betanet L2 RPC URL. +# - L2_BLOCK_BEFORE_FORK must be set to the block right before the fork +# - L2_FORK_BLOCK_NUMBER can be set in the env to pin a block (defaults to latest). +# Usage: L2_FORK_RPC_URL= L2_BLOCK_AFTER_FORK= just test-post-karst-betanet-l2-fork-upgrade [ARGS] +test-post-karst-betanet-l2-fork-upgrade *ARGS: + L2CM_ACTIVATION_TEST=true just prepare-l2-upgrade-env "just test {{ARGS}}" -test-karst-betanet-l2-fork-upgrade-rerun *ARGS: +test-post-karst-betanet-l2-fork-upgrade-rerun *ARGS: just test-karst-betanet-l2-fork-upgrade {{ARGS}} --rerun -vvvv ######################################################## diff --git a/packages/contracts-bedrock/scripts/libraries/Config.sol b/packages/contracts-bedrock/scripts/libraries/Config.sol index 08483c3d3b0..2b99f1ceb64 100644 --- a/packages/contracts-bedrock/scripts/libraries/Config.sol +++ b/packages/contracts-bedrock/scripts/libraries/Config.sol @@ -289,9 +289,9 @@ library Config { return vm.envOr("L2_FORK_TEST", false); } - /// @notice Returns true if this is a Karst betanet L2 fork test. - function karstBetanetL2ForkTest() internal view returns (bool) { - return vm.envOr("KARST_BETANET_L2_FORK_TEST", false); + /// @notice Returns true if this is a L2CM activation test. + function l2CMActivationTest() internal view returns (bool) { + return vm.envOr("L2CM_ACTIVATION_TEST", false); } /// @notice Returns the L2 RPC URL for forking. @@ -299,13 +299,17 @@ library Config { return vm.envString("L2_FORK_RPC_URL"); } - /// @notice Returns the Karst betanet L2 RPC URL for forking. - function karstBetanetL2ForkRpcUrl() internal view returns (string memory) { - return vm.envString("KARST_BETANET_L2_FORK_RPC_URL"); + /// @notice Returns the L2 block after the fork. + function l2BlockAfterFork() internal view returns (uint256) { + return vm.envOr("L2_FORK_BLOCK_NUMBER", uint256(0)); } /// @notice Returns the L2 block number to fork at. Defaults to 0 (latest). + /// If L2CM activation test is enabled, returns the block before the fork. function l2ForkBlockNumber() internal view returns (uint256) { + if (l2CMActivationTest()) { + return vm.envUint("L2_BLOCK_BEFORE_FORK"); + } return vm.envOr("L2_FORK_BLOCK_NUMBER", uint256(0)); } diff --git a/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol b/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol index ec27b85739d..f273a9b0c7d 100644 --- a/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol +++ b/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol @@ -10,6 +10,7 @@ import { console } from "forge-std/console.sol"; // Scripts import { ExecuteNUTBundle } from "scripts/upgrade/ExecuteNUTBundle.s.sol"; import { GenerateNUTBundle } from "scripts/upgrade/GenerateNUTBundle.s.sol"; +import { Config } from "scripts/libraries/Config.sol"; import { UpgradeUtils } from "scripts/libraries/UpgradeUtils.sol"; // Libraries @@ -99,6 +100,17 @@ contract L2ForkUpgrade_TestInit is CommonTest { PastNUTBundles.executeWithWrappers(executeScript, w.pre, _currentBundleTxns(), w.post); } + /// @notice Executes the current generated NUT bundle with any fork-specific wrappers, or + /// switches to the fork after the fork if L2CM activation test is enabled. + function _executeCurrentBundleOrSwitchFork() internal { + if (isL2CMActivationTest()) { + vm.createSelectFork(Config.l2ForkRpcUrl(), Config.l2BlockAfterFork()); + console.log("Setup: L2 fork switched to after the fork!"); + return; + } + _executeCurrentBundle(); + } + /// @notice Returns true when the current bundle has already been applied to the forked chain. /// Uses two checks: ConditionalDeployer exists (Karst ran) and this bundle's /// L2ContractsManager was deployed at its expected address. @@ -190,7 +202,7 @@ contract L2ForkUpgrade_Versions_Test is L2ForkUpgrade_TestInit { PreUpgradeVersionState memory preState = _capturePreUpgradeVersionState(); // Execute bundle on forked L2 - _executeCurrentBundle(); + _executeCurrentBundleOrSwitchFork(); // Verify all versions were updated _verifyAllVersionsUpdated(preState); @@ -295,7 +307,7 @@ contract L2ForkUpgrade_Initialization_Test is L2ForkUpgrade_TestInit { PreUpgradeInitializationState memory preState = _capturePreUpgradeInitializationState(); // Execute bundle on forked L2 - _executeCurrentBundle(); + _executeCurrentBundleOrSwitchFork(); // Verify initialization state was preserved _verifyInitializationState(preState); @@ -650,7 +662,7 @@ contract L2ForkUpgrade_Implementations_Test is L2ForkUpgrade_TestInit { skipIfUnoptimized(); // Execute bundle on forked L2 - _executeCurrentBundle(); + _executeCurrentBundleOrSwitchFork(); // Get all upgradeable predeploys address[] memory predeploys = Predeploys.getUpgradeablePredeploys(); @@ -702,7 +714,7 @@ contract L2ForkUpgrade_Events_Test is L2ForkUpgrade_TestInit { skipIfUnoptimized(); // Skip when the bundle is already applied: Upgraded events are historical and cannot be - // replayed via vm.recordLogs() since _executeCurrentBundle() is a no-op in that case. + // replayed via vm.recordLogs() if (_isCurrentBundleAlreadyApplied()) { vm.skip(true); return; @@ -711,14 +723,29 @@ contract L2ForkUpgrade_Events_Test is L2ForkUpgrade_TestInit { // Get StorageSetter implementation to filter out intermediate upgrade events (address storageSetterImpl,,,) = generateScript.implementationConfigs("StorageSetter"); - // Start recording logs - vm.recordLogs(); - + Vm.Log[] memory logs; + if (!isL2CMActivationTest()) { + // Start recording logs + vm.recordLogs(); + } // Execute upgrade bundle - _executeCurrentBundle(); + _executeCurrentBundleOrSwitchFork(); // Get all recorded logs - Vm.Log[] memory logs = vm.getRecordedLogs(); + if (!isL2CMActivationTest()) { + logs = vm.getRecordedLogs(); + } else { + bytes32[] memory topics = new bytes32[](1); + uint256 activationBlockNumber = Config.l2ForkBlockNumber() + 1; + topics[0] = UPGRADED_EVENT_TOPIC; + Vm.EthGetLogs[] memory ethLogs = vm.eth_getLogs( + activationBlockNumber, + activationBlockNumber, + address(0), + topics + ); + logs = _ethGetLogsToLogs(ethLogs); + } // Get all upgradeable predeploys address[] memory predeploys = Predeploys.getUpgradeablePredeploys(); @@ -770,6 +797,18 @@ contract L2ForkUpgrade_Events_Test is L2ForkUpgrade_TestInit { assertTrue(foundEvent, string.concat("Upgraded event not found for ", name, ": ", vm.toString(predeploy))); } } + + /// @notice Converts RPC logs from `vm.eth_getLogs` into the shape returned by `vm.getRecordedLogs`. + /// @param _ethLogs The RPC logs from `vm.eth_getLogs`. + /// @return logs The logs in the shape returned by `vm.getRecordedLogs`. + function _ethGetLogsToLogs(Vm.EthGetLogs[] memory _ethLogs) internal pure returns (Vm.Log[] memory logs) { + logs = new Vm.Log[](_ethLogs.length); + for (uint256 i = 0; i < _ethLogs.length; i++) { + logs[i].topics = _ethLogs[i].topics; + logs[i].data = _ethLogs[i].data; + logs[i].emitter = _ethLogs[i].emitter; + } + } } /// @title L2ForkUpgrade_GasProfile_Test diff --git a/packages/contracts-bedrock/test/setup/Setup.sol b/packages/contracts-bedrock/test/setup/Setup.sol index 241f1c52798..338fb794aa8 100644 --- a/packages/contracts-bedrock/test/setup/Setup.sol +++ b/packages/contracts-bedrock/test/setup/Setup.sol @@ -174,8 +174,8 @@ abstract contract Setup is FeatureFlags { } /// @notice Indicates whether a test is running against a Karst betanet L2 fork test. - function isKarstBetanetL2ForkTest() public view returns (bool) { - return Config.karstBetanetL2ForkTest(); + function isL2CMActivationTest() public view returns (bool) { + return Config.l2CMActivationTest(); } /// @dev Deploys either the Deploy.s.sol or Fork.s.sol contract, by fetching the bytecode dynamically using @@ -191,18 +191,10 @@ abstract contract Setup is FeatureFlags { // Handle L2 fork test (takes precedence over L1 fork) if (isL2ForkTest()) { uint256 l2ForkBlock = Config.l2ForkBlockNumber(); - if (isKarstBetanetL2ForkTest()) { - if (l2ForkBlock == 0) { - vm.createSelectFork(Config.karstBetanetL2ForkRpcUrl()); - } else { - vm.createSelectFork(Config.karstBetanetL2ForkRpcUrl(), l2ForkBlock); - } + if (l2ForkBlock == 0) { + vm.createSelectFork(Config.l2ForkRpcUrl()); } else { - if (l2ForkBlock == 0) { - vm.createSelectFork(Config.l2ForkRpcUrl()); - } else { - vm.createSelectFork(Config.l2ForkRpcUrl(), l2ForkBlock); - } + vm.createSelectFork(Config.l2ForkRpcUrl(), l2ForkBlock); } console.log("Setup: L2 fork selected!"); } else if (isL1ForkTest()) { From fff70c2cc6faba0d8df5639bc7ce8b83fdbaf364 Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Tue, 19 May 2026 09:00:26 +0200 Subject: [PATCH 14/29] fix(acceptance-test-l2cm-karst): fix rerun command and make l2BlockAfterFork revert outside l2CMActivationTest --- packages/contracts-bedrock/justfile | 2 +- packages/contracts-bedrock/scripts/libraries/Config.sol | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/contracts-bedrock/justfile b/packages/contracts-bedrock/justfile index c5c99d906c7..c8876694cbf 100644 --- a/packages/contracts-bedrock/justfile +++ b/packages/contracts-bedrock/justfile @@ -237,7 +237,7 @@ test-post-karst-betanet-l2-fork-upgrade *ARGS: L2CM_ACTIVATION_TEST=true just prepare-l2-upgrade-env "just test {{ARGS}}" test-post-karst-betanet-l2-fork-upgrade-rerun *ARGS: - just test-karst-betanet-l2-fork-upgrade {{ARGS}} --rerun -vvvv + just test-post-karst-betanet-l2-fork-upgrade {{ARGS}} --rerun -vvvv ######################################################## # DEPLOY # diff --git a/packages/contracts-bedrock/scripts/libraries/Config.sol b/packages/contracts-bedrock/scripts/libraries/Config.sol index 2b99f1ceb64..b534451c93f 100644 --- a/packages/contracts-bedrock/scripts/libraries/Config.sol +++ b/packages/contracts-bedrock/scripts/libraries/Config.sol @@ -301,7 +301,10 @@ library Config { /// @notice Returns the L2 block after the fork. function l2BlockAfterFork() internal view returns (uint256) { - return vm.envOr("L2_FORK_BLOCK_NUMBER", uint256(0)); + if (l2CMActivationTest()) { + return vm.envOr("L2_FORK_BLOCK_NUMBER", uint256(0)); + } + revert("Config: l2BlockAfterFork called outside of L2CM activation test"); } /// @notice Returns the L2 block number to fork at. Defaults to 0 (latest). From d292e2875484c2eb98ed015155e026971cccd5b4 Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Tue, 19 May 2026 10:34:31 +0200 Subject: [PATCH 15/29] fix(acceptance-test-l2cm-karst): fix usage example --- packages/contracts-bedrock/justfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contracts-bedrock/justfile b/packages/contracts-bedrock/justfile index c8876694cbf..f13ced078a6 100644 --- a/packages/contracts-bedrock/justfile +++ b/packages/contracts-bedrock/justfile @@ -232,7 +232,7 @@ test-l2-fork-upgrade-rerun *ARGS: # - L2_FORK_RPC_URL must be set to a post-Karst betanet L2 RPC URL. # - L2_BLOCK_BEFORE_FORK must be set to the block right before the fork # - L2_FORK_BLOCK_NUMBER can be set in the env to pin a block (defaults to latest). -# Usage: L2_FORK_RPC_URL= L2_BLOCK_AFTER_FORK= just test-post-karst-betanet-l2-fork-upgrade [ARGS] +# Usage: L2_FORK_RPC_URL= L2_BLOCK_BEFORE_FORK= just test-post-karst-betanet-l2-fork-upgrade [ARGS] test-post-karst-betanet-l2-fork-upgrade *ARGS: L2CM_ACTIVATION_TEST=true just prepare-l2-upgrade-env "just test {{ARGS}}" From e05e520168193eaf30884f98e2010bb287227aac Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Tue, 19 May 2026 10:55:16 +0200 Subject: [PATCH 16/29] fix(acceptance-test-l2cm-karst): add missing latest fallback for the after activation block number --- .../contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol b/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol index f273a9b0c7d..416d615055c 100644 --- a/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol +++ b/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol @@ -104,7 +104,12 @@ contract L2ForkUpgrade_TestInit is CommonTest { /// switches to the fork after the fork if L2CM activation test is enabled. function _executeCurrentBundleOrSwitchFork() internal { if (isL2CMActivationTest()) { - vm.createSelectFork(Config.l2ForkRpcUrl(), Config.l2BlockAfterFork()); + uint256 l2BlockAfterFork = Config.l2BlockAfterFork(); + if (l2BlockAfterFork == 0) { + vm.createSelectFork(Config.l2ForkRpcUrl()); + } else { + vm.createSelectFork(Config.l2ForkRpcUrl(), l2BlockAfterFork); + } console.log("Setup: L2 fork switched to after the fork!"); return; } From 51859260df5a2442c14474c2d9f80b03e998cc8c Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Wed, 20 May 2026 13:50:27 +0200 Subject: [PATCH 17/29] fix(acceptance-test-l2cm-karst): fix semgrep errors --- .../test/L2/fork/L2ForkUpgrade.t.sol | 91 +++++++++++++++++-- 1 file changed, 85 insertions(+), 6 deletions(-) diff --git a/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol b/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol index 416d615055c..0315ef51bd2 100644 --- a/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol +++ b/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol @@ -805,17 +805,96 @@ contract L2ForkUpgrade_Events_Test is L2ForkUpgrade_TestInit { /// @notice Converts RPC logs from `vm.eth_getLogs` into the shape returned by `vm.getRecordedLogs`. /// @param _ethLogs The RPC logs from `vm.eth_getLogs`. - /// @return logs The logs in the shape returned by `vm.getRecordedLogs`. - function _ethGetLogsToLogs(Vm.EthGetLogs[] memory _ethLogs) internal pure returns (Vm.Log[] memory logs) { - logs = new Vm.Log[](_ethLogs.length); + /// @return logs_ The logs in the shape returned by `vm.getRecordedLogs`. + function _ethGetLogsToLogs(Vm.EthGetLogs[] memory _ethLogs) internal pure returns (Vm.Log[] memory logs_) { + logs_ = new Vm.Log[](_ethLogs.length); for (uint256 i = 0; i < _ethLogs.length; i++) { - logs[i].topics = _ethLogs[i].topics; - logs[i].data = _ethLogs[i].data; - logs[i].emitter = _ethLogs[i].emitter; + logs_[i].topics = _ethLogs[i].topics; + logs_[i].data = _ethLogs[i].data; + logs_[i].emitter = _ethLogs[i].emitter; } } } +/// @title L2ForkUpgrade_ActivationBlockTxns_Test +/// @notice Verifies the activation block contains the expected NUT bundle transactions. +contract L2ForkUpgrade_ActivationBlockTxns_Test is L2ForkUpgrade_TestInit { + /// @notice Fetches the activation block via RPC and asserts that the NUT bundle transactions + /// are present in the correct order with the correct from, to, and calldata. + function test_l2ForkUpgrade_activationBlockContainsNUTBundle_succeeds() public { + if (!isL2CMActivationTest()) { + vm.skip(true); + return; + } + skipIfUnoptimized(); + + // activationBlock is the first block after the pre-fork snapshot where the NUT bundle runs. + uint256 activationBlock = Config.l2ForkBlockNumber() + 1; + NetworkUpgradeTxns.NetworkUpgradeTxn[] memory bundleTxns = _currentBundleTxns(); + + // setUp already selected the L2 fork, so vm.rpc uses L2_FORK_RPC_URL. + string memory blockJson = string( + vm.rpc( + "eth_getBlockByNumber", + string.concat('["0x', LibString.toHexStringNoPrefix(activationBlock), '", true]') + ) + ); + + address[] memory froms = vm.parseJsonAddressArray(blockJson, ".transactions[*].from"); + address[] memory tos = vm.parseJsonAddressArray(blockJson, ".transactions[*].to"); + bytes[] memory inputs = vm.parseJsonBytesArray(blockJson, ".transactions[*].input"); + + // The activation block also contains the L1 attributes deposit (and potentially other + // system transactions) before the NUT bundle. Find the bundle start by matching the + // first bundle transaction's from+to. + uint256 bundleStart = _findBundleStart(froms, tos, bundleTxns[0]); + + assertGe( + froms.length - bundleStart, + bundleTxns.length, + "Activation block does not contain enough transactions for the full NUT bundle" + ); + + for (uint256 i = 0; i < bundleTxns.length; i++) { + uint256 blockIdx = bundleStart + i; + assertEq( + froms[blockIdx], + bundleTxns[i].from, + string.concat("from mismatch at bundle index ", vm.toString(i), ": ", bundleTxns[i].intent) + ); + assertEq( + tos[blockIdx], + bundleTxns[i].to, + string.concat("to mismatch at bundle index ", vm.toString(i), ": ", bundleTxns[i].intent) + ); + assertEq( + keccak256(inputs[blockIdx]), + keccak256(bundleTxns[i].data), + string.concat("data mismatch at bundle index ", vm.toString(i), ": ", bundleTxns[i].intent) + ); + } + } + + /// @notice Returns the block transaction index where the NUT bundle starts, identified by + /// matching the first bundle transaction's from+to pair. + function _findBundleStart( + address[] memory _froms, + address[] memory _tos, + NetworkUpgradeTxns.NetworkUpgradeTxn memory _firstBundleTxn + ) + internal + pure + returns (uint256) + { + for (uint256 i = 0; i < _froms.length; i++) { + if (_froms[i] == _firstBundleTxn.from && _tos[i] == _firstBundleTxn.to) { + return i; + } + } + revert("L2ForkUpgrade_ActivationBlockTxns_Test: NUT bundle start not found in activation block"); + } +} + /// @title L2ForkUpgrade_GasProfile_Test /// @notice Gas profiling tests for the NUT bundle upgrade transactions. contract L2ForkUpgrade_GasProfile_Test is L2ForkUpgrade_TestInit { From ba4153b97bdcf443672f111d9e6a1c4cf1d1413d Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Thu, 21 May 2026 11:31:39 +0200 Subject: [PATCH 18/29] fix(acceptance-test-l2cm-karst): all tests fixed --- .../test/L2/fork/L2ForkUpgrade.t.sol | 140 +++++++++++++----- 1 file changed, 106 insertions(+), 34 deletions(-) diff --git a/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol b/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol index 0315ef51bd2..f6bd29d8058 100644 --- a/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol +++ b/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol @@ -11,6 +11,7 @@ import { console } from "forge-std/console.sol"; import { ExecuteNUTBundle } from "scripts/upgrade/ExecuteNUTBundle.s.sol"; import { GenerateNUTBundle } from "scripts/upgrade/GenerateNUTBundle.s.sol"; import { Config } from "scripts/libraries/Config.sol"; +import { Process } from "scripts/libraries/Process.sol"; import { UpgradeUtils } from "scripts/libraries/UpgradeUtils.sol"; // Libraries @@ -104,18 +105,24 @@ contract L2ForkUpgrade_TestInit is CommonTest { /// switches to the fork after the fork if L2CM activation test is enabled. function _executeCurrentBundleOrSwitchFork() internal { if (isL2CMActivationTest()) { - uint256 l2BlockAfterFork = Config.l2BlockAfterFork(); - if (l2BlockAfterFork == 0) { - vm.createSelectFork(Config.l2ForkRpcUrl()); - } else { - vm.createSelectFork(Config.l2ForkRpcUrl(), l2BlockAfterFork); - } - console.log("Setup: L2 fork switched to after the fork!"); + _switchToForkAfterFork(); return; } _executeCurrentBundle(); } + /// @notice Switches to the fork after the fork if L2CM activation test is enabled. + function _switchToForkAfterFork() internal { + uint256 l2BlockAfterFork = Config.l2BlockAfterFork(); + if (l2BlockAfterFork == 0) { + vm.createSelectFork(Config.l2ForkRpcUrl()); + } else { + vm.createSelectFork(Config.l2ForkRpcUrl(), l2BlockAfterFork); + } + console.log("Setup: L2 fork switched to after the fork!"); + + } + /// @notice Returns true when the current bundle has already been applied to the forked chain. /// Uses two checks: ConditionalDeployer exists (Karst ran) and this bundle's /// L2ContractsManager was deployed at its expected address. @@ -666,12 +673,20 @@ contract L2ForkUpgrade_Implementations_Test is L2ForkUpgrade_TestInit { // Skip if running with an unoptimized Foundry profile skipIfUnoptimized(); + // Pre-capture expected implementations before any fork switch: + // in activation mode vm.createSelectFork creates a new fork where generateScript + // (deployed on fork 0) is not accessible. + address[] memory predeploys = Predeploys.getUpgradeablePredeploys(); + address[] memory expectedImpls = new address[](predeploys.length); + for (uint256 i = 0; i < predeploys.length; i++) { + if (!_isFeaturePredeployAndDisabled(predeploys[i])) { + expectedImpls[i] = _getExpectedImplementation(predeploys[i], Predeploys.getName(predeploys[i])); + } + } + // Execute bundle on forked L2 _executeCurrentBundleOrSwitchFork(); - // Get all upgradeable predeploys - address[] memory predeploys = Predeploys.getUpgradeablePredeploys(); - // Verify each predeploy's implementation for (uint256 i = 0; i < predeploys.length; i++) { address predeploy = predeploys[i]; @@ -683,8 +698,8 @@ contract L2ForkUpgrade_Implementations_Test is L2ForkUpgrade_TestInit { // Get predeploy name string memory name = Predeploys.getName(predeploy); - // Get expected implementation from config - address expectedImpl = _getExpectedImplementation(predeploy, name); + // Use pre-captured expected implementation (generateScript unavailable after fork switch) + address expectedImpl = expectedImpls[i]; // Get actual implementation from proxy address actualImpl = address(uint160(uint256(vm.load(predeploy, IMPLEMENTATION_SLOT)))); @@ -725,8 +740,17 @@ contract L2ForkUpgrade_Events_Test is L2ForkUpgrade_TestInit { return; } - // Get StorageSetter implementation to filter out intermediate upgrade events + // Pre-capture everything from generateScript before any fork switch: + // in activation mode vm.createSelectFork creates a new fork where generateScript + // (deployed on fork 0) is not accessible. (address storageSetterImpl,,,) = generateScript.implementationConfigs("StorageSetter"); + address[] memory predeploys = Predeploys.getUpgradeablePredeploys(); + address[] memory expectedImpls = new address[](predeploys.length); + for (uint256 i = 0; i < predeploys.length; i++) { + if (!_isFeaturePredeployAndDisabled(predeploys[i])) { + expectedImpls[i] = _getExpectedImplementation(predeploys[i], Predeploys.getName(predeploys[i])); + } + } Vm.Log[] memory logs; if (!isL2CMActivationTest()) { @@ -743,18 +767,25 @@ contract L2ForkUpgrade_Events_Test is L2ForkUpgrade_TestInit { bytes32[] memory topics = new bytes32[](1); uint256 activationBlockNumber = Config.l2ForkBlockNumber() + 1; topics[0] = UPGRADED_EVENT_TOPIC; - Vm.EthGetLogs[] memory ethLogs = vm.eth_getLogs( - activationBlockNumber, - activationBlockNumber, - address(0), - topics - ); + // vm.eth_getLogs serializes address(0) as the literal zero address rather than null, + // so passing address(0) returns no events. Query each predeploy address individually. + uint256 totalCount = 0; + Vm.EthGetLogs[][] memory perDeployLogs = new Vm.EthGetLogs[][](predeploys.length); + for (uint256 p = 0; p < predeploys.length; p++) { + perDeployLogs[p] = + vm.eth_getLogs(activationBlockNumber, activationBlockNumber, predeploys[p], topics); + totalCount += perDeployLogs[p].length; + } + Vm.EthGetLogs[] memory ethLogs = new Vm.EthGetLogs[](totalCount); + uint256 flatIdx = 0; + for (uint256 p = 0; p < predeploys.length; p++) { + for (uint256 q = 0; q < perDeployLogs[p].length; q++) { + ethLogs[flatIdx++] = perDeployLogs[p][q]; + } + } logs = _ethGetLogsToLogs(ethLogs); } - // Get all upgradeable predeploys - address[] memory predeploys = Predeploys.getUpgradeablePredeploys(); - // Verify each predeploy emitted the Upgraded event for (uint256 i = 0; i < predeploys.length; i++) { address predeploy = predeploys[i]; @@ -766,8 +797,8 @@ contract L2ForkUpgrade_Events_Test is L2ForkUpgrade_TestInit { // Get predeploy name string memory name = Predeploys.getName(predeploy); - // Get expected implementation from config - address expectedImpl = _getExpectedImplementation(predeploy, name); + // Use pre-captured expected implementation (generateScript unavailable after fork switch) + address expectedImpl = expectedImpls[i]; // Find the Upgraded event for this predeploy (skip StorageSetter events) bool foundEvent = false; @@ -825,6 +856,8 @@ contract L2ForkUpgrade_ActivationBlockTxns_Test is L2ForkUpgrade_TestInit { if (!isL2CMActivationTest()) { vm.skip(true); return; + } else { + _switchToForkAfterFork(); } skipIfUnoptimized(); @@ -832,17 +865,16 @@ contract L2ForkUpgrade_ActivationBlockTxns_Test is L2ForkUpgrade_TestInit { uint256 activationBlock = Config.l2ForkBlockNumber() + 1; NetworkUpgradeTxns.NetworkUpgradeTxn[] memory bundleTxns = _currentBundleTxns(); - // setUp already selected the L2 fork, so vm.rpc uses L2_FORK_RPC_URL. - string memory blockJson = string( - vm.rpc( - "eth_getBlockByNumber", - string.concat('["0x', LibString.toHexStringNoPrefix(activationBlock), '", true]') - ) - ); + string memory blockHex = string.concat("0x", LibString.toHexStringNoPrefix(activationBlock)); + string memory rpcUrl = Config.l2ForkRpcUrl(); + + // vm.rpc ABI-encodes block objects; use FFI (cast) to fetch JSON for parseJson. + string memory blockJsonHashes = _ffiGetBlockByNumber(blockHex, rpcUrl, false); + uint256 txCount = _activationBlockTransactionCount(blockJsonHashes); - address[] memory froms = vm.parseJsonAddressArray(blockJson, ".transactions[*].from"); - address[] memory tos = vm.parseJsonAddressArray(blockJson, ".transactions[*].to"); - bytes[] memory inputs = vm.parseJsonBytesArray(blockJson, ".transactions[*].input"); + string memory blockJson = _ffiGetBlockByNumber(blockHex, rpcUrl, true); + (address[] memory froms, address[] memory tos, bytes[] memory inputs) = + _parseActivationBlockTransactions(blockJson, txCount); // The activation block also contains the L1 attributes deposit (and potentially other // system transactions) before the NUT bundle. Find the bundle start by matching the @@ -875,6 +907,46 @@ contract L2ForkUpgrade_ActivationBlockTxns_Test is L2ForkUpgrade_TestInit { } } + /// @notice Fetches a block via `cast rpc` (FFI). Returns JSON suitable for `vm.parseJson`. + function _ffiGetBlockByNumber(string memory _blockHex, string memory _rpcUrl, bool _fullTxs) + internal + returns (string memory blockJson_) + { + string[] memory cmds = new string[](7); + cmds[0] = "cast"; + cmds[1] = "rpc"; + cmds[2] = "eth_getBlockByNumber"; + cmds[3] = _blockHex; + cmds[4] = _fullTxs ? "true" : "false"; + cmds[5] = "--rpc-url"; + cmds[6] = _rpcUrl; + blockJson_ = string(Process.run(cmds)); + } + + /// @notice Returns the number of transactions in an activation block JSON payload (hash-only). + function _activationBlockTransactionCount(string memory _blockJson) internal pure returns (uint256 count_) { + string[] memory hashes = vm.parseJsonStringArray(_blockJson, ".transactions"); + return hashes.length; + } + + /// @notice Parses from/to/input for each transaction in a full activation block JSON payload. + function _parseActivationBlockTransactions(string memory _blockJson, uint256 _txCount) + internal + pure + returns (address[] memory froms_, address[] memory tos_, bytes[] memory inputs_) + { + froms_ = new address[](_txCount); + tos_ = new address[](_txCount); + inputs_ = new bytes[](_txCount); + + for (uint256 i = 0; i < _txCount; i++) { + string memory base = string.concat(".transactions[", vm.toString(i), "]"); + froms_[i] = vm.parseJsonAddress(_blockJson, string.concat(base, ".from")); + tos_[i] = vm.parseJsonAddress(_blockJson, string.concat(base, ".to")); + inputs_[i] = vm.parseJsonBytes(_blockJson, string.concat(base, ".input")); + } + } + /// @notice Returns the block transaction index where the NUT bundle starts, identified by /// matching the first bundle transaction's from+to pair. function _findBundleStart( From 33b17004023478d3ef18f58453ea50b725d244b7 Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Thu, 21 May 2026 14:59:42 +0200 Subject: [PATCH 19/29] fix(acceptance-test-l2cm-karst): move tests to separate file --- packages/contracts-bedrock/justfile | 24 +- .../L2VerifyBetanetForkUpgrade.t.sol | 383 ++++++++++++++++++ .../test/L2/fork/L2ForkUpgrade.t.sol | 240 ++--------- 3 files changed, 426 insertions(+), 221 deletions(-) create mode 100644 packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol diff --git a/packages/contracts-bedrock/justfile b/packages/contracts-bedrock/justfile index f13ced078a6..50ac163cb80 100644 --- a/packages/contracts-bedrock/justfile +++ b/packages/contracts-bedrock/justfile @@ -227,17 +227,29 @@ test-l2-fork-upgrade *ARGS: test-l2-fork-upgrade-rerun *ARGS: just test-l2-fork-upgrade {{ARGS}} --rerun -vvvv -# Runs L2 fork upgrade tests against a Karst betanet L2 fork. +# Prepares the environment for L2CM activation (betanet fork) tests. # Env Vars: # - L2_FORK_RPC_URL must be set to a post-Karst betanet L2 RPC URL. # - L2_BLOCK_BEFORE_FORK must be set to the block right before the fork # - L2_FORK_BLOCK_NUMBER can be set in the env to pin a block (defaults to latest). -# Usage: L2_FORK_RPC_URL= L2_BLOCK_BEFORE_FORK= just test-post-karst-betanet-l2-fork-upgrade [ARGS] -test-post-karst-betanet-l2-fork-upgrade *ARGS: - L2CM_ACTIVATION_TEST=true just prepare-l2-upgrade-env "just test {{ARGS}}" +prepare-l2cm-activation-env *ARGS: + #!/bin/bash + set -euo pipefail + export L2_FORK_TEST=true + export L2CM_ACTIVATION_TEST=true + export DEV_FEATURE__L2CM=true + export FOUNDRY_FORK_RETRIES=10 + export FOUNDRY_FORK_RETRY_BACKOFF=1000 + {{ARGS}} \ + --match-path "test/L2/betanet-fork/**" + +# Runs L2CM activation (betanet fork) tests. +# Usage: L2_FORK_RPC_URL= L2_BLOCK_BEFORE_FORK= just test-l2cm-activation-test [ARGS] +test-l2cm-activation-test *ARGS: + just prepare-l2cm-activation-env "just test {{ARGS}}" -test-post-karst-betanet-l2-fork-upgrade-rerun *ARGS: - just test-post-karst-betanet-l2-fork-upgrade {{ARGS}} --rerun -vvvv +test-l2cm-activation-test-rerun *ARGS: + just test-l2cm-activation-test {{ARGS}} --rerun -vvvv ######################################################## # DEPLOY # diff --git a/packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol b/packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol new file mode 100644 index 00000000000..1d2abb6f0d6 --- /dev/null +++ b/packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol @@ -0,0 +1,383 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +// Testing +import { CommonTest } from "test/setup/CommonTest.sol"; +import { console2 as console } from "forge-std/console2.sol"; +import { Vm } from "forge-std/Vm.sol"; + +// Scripts +import { Config } from "scripts/libraries/Config.sol"; +import { ExecuteNUTBundle } from "scripts/upgrade/ExecuteNUTBundle.s.sol"; +import { GenerateNUTBundle } from "scripts/upgrade/GenerateNUTBundle.s.sol"; + +// Libraries +import { LibString } from "@solady/utils/LibString.sol"; +import { DevFeatures } from "src/libraries/DevFeatures.sol"; +import { NetworkUpgradeTxns } from "src/libraries/NetworkUpgradeTxns.sol"; +import { Predeploys } from "src/libraries/Predeploys.sol"; +import { Process } from "scripts/libraries/Process.sol"; + +// Reuse all test logic from L2ForkUpgrade — only setUp differs +import { + L2ForkUpgrade_TestInit, + L2ForkUpgrade_Versions_Test, + L2ForkUpgrade_Initialization_Test, + L2ForkUpgrade_Implementations_Test, + L2ForkUpgrade_Events_Test +} from "test/L2/fork/L2ForkUpgrade.t.sol"; + +/// @title L2VerifyBetanetForkUpgrade_TestInit +/// @notice Provides a setUp for the L2 verify betanet fork upgrade tests. +/// The tests are supposed to run on forks where the last L2CM activation block +/// used the current NUT bundle from the repository. +contract L2VerifyBetanetForkUpgrade_TestInit is L2ForkUpgrade_TestInit { + function setUp() public virtual override(L2ForkUpgrade_TestInit) { + vm.skip(!Config.l2CMActivationTest()); + super.setUp(); + } + + /// @notice Verify betanet fork upgrade tests switch to the block after the activation block. + function _executeCurrentBundle() internal virtual override { + uint256 l2BlockAfterFork = Config.l2BlockAfterFork(); + if (l2BlockAfterFork == 0) { + vm.createSelectFork(Config.l2ForkRpcUrl()); + } else { + vm.createSelectFork(Config.l2ForkRpcUrl(), l2BlockAfterFork); + } + console.log("Setup: L2 fork switched to after the fork!"); + } +} + +/// @title L2GenesisForkUpgrade_Versions_Test +/// @notice Tests that all predeploy versions were updated during the betanet activation. +contract L2VerifyBetanetForkUpgrade_Versions_Test is L2VerifyBetanetForkUpgrade_TestInit, L2ForkUpgrade_Versions_Test { + function setUp() public override(L2VerifyBetanetForkUpgrade_TestInit, L2ForkUpgrade_TestInit) { + L2VerifyBetanetForkUpgrade_TestInit.setUp(); + } + + function _executeCurrentBundle() internal override(L2VerifyBetanetForkUpgrade_TestInit, L2ForkUpgrade_TestInit) { + L2VerifyBetanetForkUpgrade_TestInit._executeCurrentBundle(); + } +} + +/// @title L2VerifyBetanetForkUpgrade_Initialization_Test +/// @notice Tests that all initialization configurations were preserved during the betanet activation. +contract L2VerifyBetanetForkUpgrade_Initialization_Test is + L2VerifyBetanetForkUpgrade_TestInit, + L2ForkUpgrade_Initialization_Test +{ + function setUp() public override(L2VerifyBetanetForkUpgrade_TestInit, L2ForkUpgrade_TestInit) { + L2VerifyBetanetForkUpgrade_TestInit.setUp(); + } + + function _executeCurrentBundle() internal override(L2VerifyBetanetForkUpgrade_TestInit, L2ForkUpgrade_TestInit) { + L2VerifyBetanetForkUpgrade_TestInit._executeCurrentBundle(); + } +} + +/// @title L2VerifyBetanetForkUpgrade_Implementations_Test +/// @notice Tests that all predeploy implementations were correctly upgraded during the betanet activation. +contract L2VerifyBetanetForkUpgrade_Implementations_Test is + L2VerifyBetanetForkUpgrade_TestInit, + L2ForkUpgrade_Implementations_Test { + function setUp() public override(L2VerifyBetanetForkUpgrade_TestInit, L2ForkUpgrade_TestInit) { + L2VerifyBetanetForkUpgrade_TestInit.setUp(); + } + + function _executeCurrentBundle() internal override(L2VerifyBetanetForkUpgrade_TestInit, L2ForkUpgrade_TestInit) { + L2VerifyBetanetForkUpgrade_TestInit._executeCurrentBundle(); + } + + /// @notice Tests that all predeploy implementations match expected addresses and have code. + function test_l2ForkUpgrade_implementationsMatch_succeeds() public override(L2ForkUpgrade_Implementations_Test) { + // Skip if running with an unoptimized Foundry profile + skipIfUnoptimized(); + + // Pre-capture expected implementations before any fork switch: + // in activation mode vm.createSelectFork creates a new fork where generateScript + // (deployed on fork 0) is not accessible. + address[] memory predeploys = Predeploys.getUpgradeablePredeploys(); + address[] memory expectedImpls = new address[](predeploys.length); + for (uint256 i = 0; i < predeploys.length; i++) { + if (!_isFeaturePredeployAndDisabled(predeploys[i])) { + expectedImpls[i] = _getExpectedImplementation(predeploys[i], Predeploys.getName(predeploys[i])); + } + } + + // Execute bundle on forked L2 + _executeCurrentBundle(); + + // Verify each predeploy's implementation + for (uint256 i = 0; i < predeploys.length; i++) { + address predeploy = predeploys[i]; + + if (_isFeaturePredeployAndDisabled(predeploy)) { + continue; + } + + // Get predeploy name + string memory name = Predeploys.getName(predeploy); + + // Use pre-captured expected implementation (generateScript unavailable after fork switch) + address expectedImpl = expectedImpls[i]; + + // Get actual implementation from proxy + address actualImpl = address(uint160(uint256(vm.load(predeploy, IMPLEMENTATION_SLOT)))); + + // Verify implementation matches + assertEq( + actualImpl, + expectedImpl, + string.concat("Implementation mismatch for ", name, ": ", vm.toString(predeploy)) + ); + + // Verify implementation has code + assertGt( + actualImpl.code.length, + 0, + string.concat("Implementation has no code for ", name, ": ", vm.toString(actualImpl)) + ); + } + } +} + +/// @title L2VerifyBetanetForkUpgrade_Events_Test +/// @notice Tests that all predeploy proxies emit the Upgraded event during the betanet activation. +contract L2VerifyBetanetForkUpgrade_Events_Test is L2VerifyBetanetForkUpgrade_TestInit, L2ForkUpgrade_Events_Test { + function setUp() public override(L2VerifyBetanetForkUpgrade_TestInit, L2ForkUpgrade_TestInit) { + L2VerifyBetanetForkUpgrade_TestInit.setUp(); + } + + function _executeCurrentBundle() internal override(L2VerifyBetanetForkUpgrade_TestInit, L2ForkUpgrade_TestInit) { + L2VerifyBetanetForkUpgrade_TestInit._executeCurrentBundle(); + } + + function test_l2ForkUpgrade_upgradeEventsEmitted_succeeds() public override(L2ForkUpgrade_Events_Test) { + // Skip if running with an unoptimized Foundry profile + skipIfUnoptimized(); + + // Pre-capture everything from generateScript before any fork switch: + // in activation mode vm.createSelectFork creates a new fork where generateScript + // (deployed on fork 0) is not accessible. + (address storageSetterImpl,,,) = generateScript.implementationConfigs("StorageSetter"); + address[] memory predeploys = Predeploys.getUpgradeablePredeploys(); + address[] memory expectedImpls = new address[](predeploys.length); + for (uint256 i = 0; i < predeploys.length; i++) { + if (!_isFeaturePredeployAndDisabled(predeploys[i])) { + expectedImpls[i] = _getExpectedImplementation(predeploys[i], Predeploys.getName(predeploys[i])); + } + } + + // Execute upgrade bundle + _executeCurrentBundle(); + + // Get all recorded logs + Vm.Log[] memory logs = _getLogs(predeploys); + + // Verify each predeploy emitted the Upgraded event + for (uint256 i = 0; i < predeploys.length; i++) { + address predeploy = predeploys[i]; + + if (_isFeaturePredeployAndDisabled(predeploy)) { + continue; + } + + // Get predeploy name + string memory name = Predeploys.getName(predeploy); + + // Use pre-captured expected implementation (generateScript unavailable after fork switch) + address expectedImpl = expectedImpls[i]; + + // Find the Upgraded event for this predeploy (skip StorageSetter events) + bool foundEvent = false; + for (uint256 j = 0; j < logs.length; j++) { + // Check if this log is an Upgraded event from the current predeploy + if ( + logs[j].emitter == predeploy && logs[j].topics.length > 0 + && logs[j].topics[0] == UPGRADED_EVENT_TOPIC + ) { + // Decode the implementation address from the event + address emittedImpl = address(uint160(uint256(logs[j].topics[1]))); + + // Skip StorageSetter upgrade events (intermediate step for initializable contracts) + if (emittedImpl == storageSetterImpl) { + continue; + } + + foundEvent = true; + + // Verify the implementation matches expected + assertEq( + emittedImpl, + expectedImpl, + string.concat("Upgraded event implementation mismatch for ", name, ": ", vm.toString(predeploy)) + ); + + break; + } + } + + // Verify the event was found + assertTrue(foundEvent, string.concat("Upgraded event not found for ", name, ": ", vm.toString(predeploy))); + } + } + + function _getLogs(address[] memory predeploys) internal returns (Vm.Log[] memory logs_) { + bytes32[] memory topics = new bytes32[](1); + uint256 activationBlockNumber = Config.l2ForkBlockNumber() + 1; + topics[0] = UPGRADED_EVENT_TOPIC; + // vm.eth_getLogs serializes address(0) as the literal zero address rather than null, + // so passing address(0) returns no events. Query each predeploy address individually. + uint256 totalCount = 0; + Vm.EthGetLogs[][] memory perDeployLogs = new Vm.EthGetLogs[][](predeploys.length); + for (uint256 p = 0; p < predeploys.length; p++) { + perDeployLogs[p] = + vm.eth_getLogs(activationBlockNumber, activationBlockNumber, predeploys[p], topics); + totalCount += perDeployLogs[p].length; + } + Vm.EthGetLogs[] memory ethLogs = new Vm.EthGetLogs[](totalCount); + uint256 flatIdx = 0; + for (uint256 p = 0; p < predeploys.length; p++) { + for (uint256 q = 0; q < perDeployLogs[p].length; q++) { + ethLogs[flatIdx++] = perDeployLogs[p][q]; + } + } + logs_ = _ethGetLogsToLogs(ethLogs); + } + + /// @notice Converts RPC logs from `vm.eth_getLogs` into the shape returned by `vm.getRecordedLogs`. + /// @param _ethLogs The RPC logs from `vm.eth_getLogs`. + /// @return logs_ The logs in the shape returned by `vm.getRecordedLogs`. + function _ethGetLogsToLogs(Vm.EthGetLogs[] memory _ethLogs) internal pure returns (Vm.Log[] memory logs_) { + logs_ = new Vm.Log[](_ethLogs.length); + for (uint256 i = 0; i < _ethLogs.length; i++) { + logs_[i].topics = _ethLogs[i].topics; + logs_[i].data = _ethLogs[i].data; + logs_[i].emitter = _ethLogs[i].emitter; + } + } +} + + +/// @title L2VerifyBetanetForkUpgrade_ActivationBlockTxns_Test +/// @notice Verifies the activation block contains the expected NUT bundle transactions. +contract L2VerifyBetanetForkUpgrade_ActivationBlockTxns_Test is L2VerifyBetanetForkUpgrade_TestInit { + function setUp() public override(L2VerifyBetanetForkUpgrade_TestInit) { + L2VerifyBetanetForkUpgrade_TestInit.setUp(); + } + + /// @notice Fetches the activation block via RPC and asserts that the NUT bundle transactions + /// are present in the correct order with the correct from, to, and calldata. + function test_l2VerifyBetanetForkUpgrade_activationBlockContainsNUTBundle_succeeds() public { + // Skip if running with an unoptimized Foundry profile + skipIfUnoptimized(); + + // Execute bundle on forked L2 + _executeCurrentBundle(); + + // activationBlock is the first block after the pre-fork snapshot where the NUT bundle runs. + uint256 activationBlock = Config.l2ForkBlockNumber() + 1; + NetworkUpgradeTxns.NetworkUpgradeTxn[] memory bundleTxns = _currentBundleTxns(); + + string memory blockHex = string.concat("0x", LibString.toHexStringNoPrefix(activationBlock)); + string memory rpcUrl = Config.l2ForkRpcUrl(); + + // vm.rpc ABI-encodes block objects; use FFI (cast) to fetch JSON for parseJson. + string memory blockJsonHashes = _ffiGetBlockByNumber(blockHex, rpcUrl, false); + uint256 txCount = _activationBlockTransactionCount(blockJsonHashes); + + string memory blockJson = _ffiGetBlockByNumber(blockHex, rpcUrl, true); + (address[] memory froms, address[] memory tos, bytes[] memory inputs) = + _parseActivationBlockTransactions(blockJson, txCount); + + // The activation block also contains the L1 attributes deposit (and potentially other + // system transactions) before the NUT bundle. Find the bundle start by matching the + // first bundle transaction's from+to. + uint256 bundleStart = _findBundleStart(froms, tos, bundleTxns[0]); + + assertGe( + froms.length - bundleStart, + bundleTxns.length, + "Activation block does not contain enough transactions for the full NUT bundle" + ); + + for (uint256 i = 0; i < bundleTxns.length; i++) { + uint256 blockIdx = bundleStart + i; + assertEq( + froms[blockIdx], + bundleTxns[i].from, + string.concat("from mismatch at bundle index ", vm.toString(i), ": ", bundleTxns[i].intent) + ); + assertEq( + tos[blockIdx], + bundleTxns[i].to, + string.concat("to mismatch at bundle index ", vm.toString(i), ": ", bundleTxns[i].intent) + ); + assertEq( + keccak256(inputs[blockIdx]), + keccak256(bundleTxns[i].data), + string.concat("data mismatch at bundle index ", vm.toString(i), ": ", bundleTxns[i].intent) + ); + } + } + + /// @notice Fetches a block via `cast rpc` (FFI). Returns JSON suitable for `vm.parseJson`. + function _ffiGetBlockByNumber(string memory _blockHex, string memory _rpcUrl, bool _fullTxs) + internal + returns (string memory blockJson_) + { + string[] memory cmds = new string[](7); + cmds[0] = "cast"; + cmds[1] = "rpc"; + cmds[2] = "eth_getBlockByNumber"; + cmds[3] = _blockHex; + cmds[4] = _fullTxs ? "true" : "false"; + cmds[5] = "--rpc-url"; + cmds[6] = _rpcUrl; + blockJson_ = string(Process.run(cmds)); + } + + /// @notice Returns the number of transactions in an activation block JSON payload (hash-only). + function _activationBlockTransactionCount(string memory _blockJson) internal pure returns (uint256 count_) { + string[] memory hashes = vm.parseJsonStringArray(_blockJson, ".transactions"); + return hashes.length; + } + + /// @notice Parses from/to/input for each transaction in a full activation block JSON payload. + function _parseActivationBlockTransactions(string memory _blockJson, uint256 _txCount) + internal + pure + returns (address[] memory froms_, address[] memory tos_, bytes[] memory inputs_) + { + froms_ = new address[](_txCount); + tos_ = new address[](_txCount); + inputs_ = new bytes[](_txCount); + + for (uint256 i = 0; i < _txCount; i++) { + string memory base = string.concat(".transactions[", vm.toString(i), "]"); + froms_[i] = vm.parseJsonAddress(_blockJson, string.concat(base, ".from")); + tos_[i] = vm.parseJsonAddress(_blockJson, string.concat(base, ".to")); + inputs_[i] = vm.parseJsonBytes(_blockJson, string.concat(base, ".input")); + } + } + + /// @notice Returns the block transaction index where the NUT bundle starts, identified by + /// matching the first bundle transaction's from+to pair. + function _findBundleStart( + address[] memory _froms, + address[] memory _tos, + NetworkUpgradeTxns.NetworkUpgradeTxn memory _firstBundleTxn + ) + internal + pure + returns (uint256) + { + for (uint256 i = 0; i < _froms.length; i++) { + if (_froms[i] == _firstBundleTxn.from && _tos[i] == _firstBundleTxn.to) { + return i; + } + } + revert("L2VerifyBetanetForkUpgrade_ActivationBlockTxns_Test: NUT bundle start not found in activation block"); + } +} \ No newline at end of file diff --git a/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol b/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol index f6bd29d8058..ab13d8ba36f 100644 --- a/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol +++ b/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol @@ -11,7 +11,6 @@ import { console } from "forge-std/console.sol"; import { ExecuteNUTBundle } from "scripts/upgrade/ExecuteNUTBundle.s.sol"; import { GenerateNUTBundle } from "scripts/upgrade/GenerateNUTBundle.s.sol"; import { Config } from "scripts/libraries/Config.sol"; -import { Process } from "scripts/libraries/Process.sol"; import { UpgradeUtils } from "scripts/libraries/UpgradeUtils.sol"; // Libraries @@ -101,28 +100,6 @@ contract L2ForkUpgrade_TestInit is CommonTest { PastNUTBundles.executeWithWrappers(executeScript, w.pre, _currentBundleTxns(), w.post); } - /// @notice Executes the current generated NUT bundle with any fork-specific wrappers, or - /// switches to the fork after the fork if L2CM activation test is enabled. - function _executeCurrentBundleOrSwitchFork() internal { - if (isL2CMActivationTest()) { - _switchToForkAfterFork(); - return; - } - _executeCurrentBundle(); - } - - /// @notice Switches to the fork after the fork if L2CM activation test is enabled. - function _switchToForkAfterFork() internal { - uint256 l2BlockAfterFork = Config.l2BlockAfterFork(); - if (l2BlockAfterFork == 0) { - vm.createSelectFork(Config.l2ForkRpcUrl()); - } else { - vm.createSelectFork(Config.l2ForkRpcUrl(), l2BlockAfterFork); - } - console.log("Setup: L2 fork switched to after the fork!"); - - } - /// @notice Returns true when the current bundle has already been applied to the forked chain. /// Uses two checks: ConditionalDeployer exists (Karst ran) and this bundle's /// L2ContractsManager was deployed at its expected address. @@ -214,7 +191,7 @@ contract L2ForkUpgrade_Versions_Test is L2ForkUpgrade_TestInit { PreUpgradeVersionState memory preState = _capturePreUpgradeVersionState(); // Execute bundle on forked L2 - _executeCurrentBundleOrSwitchFork(); + _executeCurrentBundle(); // Verify all versions were updated _verifyAllVersionsUpdated(preState); @@ -319,7 +296,7 @@ contract L2ForkUpgrade_Initialization_Test is L2ForkUpgrade_TestInit { PreUpgradeInitializationState memory preState = _capturePreUpgradeInitializationState(); // Execute bundle on forked L2 - _executeCurrentBundleOrSwitchFork(); + _executeCurrentBundle(); // Verify initialization state was preserved _verifyInitializationState(preState); @@ -669,23 +646,18 @@ contract L2ForkUpgrade_Implementations_Test is L2ForkUpgrade_TestInit { bytes32 internal constant IMPLEMENTATION_SLOT = bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1); /// @notice Tests that all predeploy implementations match expected addresses and have code. - function test_l2ForkUpgrade_implementationsMatch_succeeds() public { + function test_l2ForkUpgrade_implementationsMatch_succeeds() virtual public { // Skip if running with an unoptimized Foundry profile skipIfUnoptimized(); - // Pre-capture expected implementations before any fork switch: - // in activation mode vm.createSelectFork creates a new fork where generateScript - // (deployed on fork 0) is not accessible. - address[] memory predeploys = Predeploys.getUpgradeablePredeploys(); - address[] memory expectedImpls = new address[](predeploys.length); - for (uint256 i = 0; i < predeploys.length; i++) { - if (!_isFeaturePredeployAndDisabled(predeploys[i])) { - expectedImpls[i] = _getExpectedImplementation(predeploys[i], Predeploys.getName(predeploys[i])); - } - } + // Get StorageSetter implementation to filter out intermediate upgrade events + (address storageSetterImpl,,,) = generateScript.implementationConfigs("StorageSetter"); // Execute bundle on forked L2 - _executeCurrentBundleOrSwitchFork(); + _executeCurrentBundle(); + + // Get all upgradeable predeploys + address[] memory predeploys = Predeploys.getUpgradeablePredeploys(); // Verify each predeploy's implementation for (uint256 i = 0; i < predeploys.length; i++) { @@ -698,8 +670,8 @@ contract L2ForkUpgrade_Implementations_Test is L2ForkUpgrade_TestInit { // Get predeploy name string memory name = Predeploys.getName(predeploy); - // Use pre-captured expected implementation (generateScript unavailable after fork switch) - address expectedImpl = expectedImpls[i]; + // Get expected implementation from config + address expectedImpl = _getExpectedImplementation(predeploy, name); // Get actual implementation from proxy address actualImpl = address(uint160(uint256(vm.load(predeploy, IMPLEMENTATION_SLOT)))); @@ -729,10 +701,13 @@ contract L2ForkUpgrade_Events_Test is L2ForkUpgrade_TestInit { bytes32 internal constant UPGRADED_EVENT_TOPIC = 0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b; /// @notice Tests that all predeploy proxies emit the Upgraded event with correct implementation. - function test_l2ForkUpgrade_upgradeEventsEmitted_succeeds() public { + function test_l2ForkUpgrade_upgradeEventsEmitted_succeeds() virtual public { // Skip if running with an unoptimized Foundry profile skipIfUnoptimized(); + // Get StorageSetter implementation to filter out intermediate upgrade events + (address storageSetterImpl,,,) = generateScript.implementationConfigs("StorageSetter"); + // Skip when the bundle is already applied: Upgraded events are historical and cannot be // replayed via vm.recordLogs() if (_isCurrentBundleAlreadyApplied()) { @@ -740,51 +715,18 @@ contract L2ForkUpgrade_Events_Test is L2ForkUpgrade_TestInit { return; } - // Pre-capture everything from generateScript before any fork switch: - // in activation mode vm.createSelectFork creates a new fork where generateScript - // (deployed on fork 0) is not accessible. - (address storageSetterImpl,,,) = generateScript.implementationConfigs("StorageSetter"); - address[] memory predeploys = Predeploys.getUpgradeablePredeploys(); - address[] memory expectedImpls = new address[](predeploys.length); - for (uint256 i = 0; i < predeploys.length; i++) { - if (!_isFeaturePredeployAndDisabled(predeploys[i])) { - expectedImpls[i] = _getExpectedImplementation(predeploys[i], Predeploys.getName(predeploys[i])); - } - } + // Start recording logs + vm.recordLogs(); - Vm.Log[] memory logs; - if (!isL2CMActivationTest()) { - // Start recording logs - vm.recordLogs(); - } // Execute upgrade bundle - _executeCurrentBundleOrSwitchFork(); + _executeCurrentBundle(); // Get all recorded logs - if (!isL2CMActivationTest()) { - logs = vm.getRecordedLogs(); - } else { - bytes32[] memory topics = new bytes32[](1); - uint256 activationBlockNumber = Config.l2ForkBlockNumber() + 1; - topics[0] = UPGRADED_EVENT_TOPIC; - // vm.eth_getLogs serializes address(0) as the literal zero address rather than null, - // so passing address(0) returns no events. Query each predeploy address individually. - uint256 totalCount = 0; - Vm.EthGetLogs[][] memory perDeployLogs = new Vm.EthGetLogs[][](predeploys.length); - for (uint256 p = 0; p < predeploys.length; p++) { - perDeployLogs[p] = - vm.eth_getLogs(activationBlockNumber, activationBlockNumber, predeploys[p], topics); - totalCount += perDeployLogs[p].length; - } - Vm.EthGetLogs[] memory ethLogs = new Vm.EthGetLogs[](totalCount); - uint256 flatIdx = 0; - for (uint256 p = 0; p < predeploys.length; p++) { - for (uint256 q = 0; q < perDeployLogs[p].length; q++) { - ethLogs[flatIdx++] = perDeployLogs[p][q]; - } - } - logs = _ethGetLogsToLogs(ethLogs); - } + Vm.Log[] memory logs = vm.getRecordedLogs(); + + + // Get all upgradeable predeploys + address[] memory predeploys = Predeploys.getUpgradeablePredeploys(); // Verify each predeploy emitted the Upgraded event for (uint256 i = 0; i < predeploys.length; i++) { @@ -797,8 +739,8 @@ contract L2ForkUpgrade_Events_Test is L2ForkUpgrade_TestInit { // Get predeploy name string memory name = Predeploys.getName(predeploy); - // Use pre-captured expected implementation (generateScript unavailable after fork switch) - address expectedImpl = expectedImpls[i]; + // Get expected implementation from config + address expectedImpl = _getExpectedImplementation(predeploy, name); // Find the Upgraded event for this predeploy (skip StorageSetter events) bool foundEvent = false; @@ -833,138 +775,6 @@ contract L2ForkUpgrade_Events_Test is L2ForkUpgrade_TestInit { assertTrue(foundEvent, string.concat("Upgraded event not found for ", name, ": ", vm.toString(predeploy))); } } - - /// @notice Converts RPC logs from `vm.eth_getLogs` into the shape returned by `vm.getRecordedLogs`. - /// @param _ethLogs The RPC logs from `vm.eth_getLogs`. - /// @return logs_ The logs in the shape returned by `vm.getRecordedLogs`. - function _ethGetLogsToLogs(Vm.EthGetLogs[] memory _ethLogs) internal pure returns (Vm.Log[] memory logs_) { - logs_ = new Vm.Log[](_ethLogs.length); - for (uint256 i = 0; i < _ethLogs.length; i++) { - logs_[i].topics = _ethLogs[i].topics; - logs_[i].data = _ethLogs[i].data; - logs_[i].emitter = _ethLogs[i].emitter; - } - } -} - -/// @title L2ForkUpgrade_ActivationBlockTxns_Test -/// @notice Verifies the activation block contains the expected NUT bundle transactions. -contract L2ForkUpgrade_ActivationBlockTxns_Test is L2ForkUpgrade_TestInit { - /// @notice Fetches the activation block via RPC and asserts that the NUT bundle transactions - /// are present in the correct order with the correct from, to, and calldata. - function test_l2ForkUpgrade_activationBlockContainsNUTBundle_succeeds() public { - if (!isL2CMActivationTest()) { - vm.skip(true); - return; - } else { - _switchToForkAfterFork(); - } - skipIfUnoptimized(); - - // activationBlock is the first block after the pre-fork snapshot where the NUT bundle runs. - uint256 activationBlock = Config.l2ForkBlockNumber() + 1; - NetworkUpgradeTxns.NetworkUpgradeTxn[] memory bundleTxns = _currentBundleTxns(); - - string memory blockHex = string.concat("0x", LibString.toHexStringNoPrefix(activationBlock)); - string memory rpcUrl = Config.l2ForkRpcUrl(); - - // vm.rpc ABI-encodes block objects; use FFI (cast) to fetch JSON for parseJson. - string memory blockJsonHashes = _ffiGetBlockByNumber(blockHex, rpcUrl, false); - uint256 txCount = _activationBlockTransactionCount(blockJsonHashes); - - string memory blockJson = _ffiGetBlockByNumber(blockHex, rpcUrl, true); - (address[] memory froms, address[] memory tos, bytes[] memory inputs) = - _parseActivationBlockTransactions(blockJson, txCount); - - // The activation block also contains the L1 attributes deposit (and potentially other - // system transactions) before the NUT bundle. Find the bundle start by matching the - // first bundle transaction's from+to. - uint256 bundleStart = _findBundleStart(froms, tos, bundleTxns[0]); - - assertGe( - froms.length - bundleStart, - bundleTxns.length, - "Activation block does not contain enough transactions for the full NUT bundle" - ); - - for (uint256 i = 0; i < bundleTxns.length; i++) { - uint256 blockIdx = bundleStart + i; - assertEq( - froms[blockIdx], - bundleTxns[i].from, - string.concat("from mismatch at bundle index ", vm.toString(i), ": ", bundleTxns[i].intent) - ); - assertEq( - tos[blockIdx], - bundleTxns[i].to, - string.concat("to mismatch at bundle index ", vm.toString(i), ": ", bundleTxns[i].intent) - ); - assertEq( - keccak256(inputs[blockIdx]), - keccak256(bundleTxns[i].data), - string.concat("data mismatch at bundle index ", vm.toString(i), ": ", bundleTxns[i].intent) - ); - } - } - - /// @notice Fetches a block via `cast rpc` (FFI). Returns JSON suitable for `vm.parseJson`. - function _ffiGetBlockByNumber(string memory _blockHex, string memory _rpcUrl, bool _fullTxs) - internal - returns (string memory blockJson_) - { - string[] memory cmds = new string[](7); - cmds[0] = "cast"; - cmds[1] = "rpc"; - cmds[2] = "eth_getBlockByNumber"; - cmds[3] = _blockHex; - cmds[4] = _fullTxs ? "true" : "false"; - cmds[5] = "--rpc-url"; - cmds[6] = _rpcUrl; - blockJson_ = string(Process.run(cmds)); - } - - /// @notice Returns the number of transactions in an activation block JSON payload (hash-only). - function _activationBlockTransactionCount(string memory _blockJson) internal pure returns (uint256 count_) { - string[] memory hashes = vm.parseJsonStringArray(_blockJson, ".transactions"); - return hashes.length; - } - - /// @notice Parses from/to/input for each transaction in a full activation block JSON payload. - function _parseActivationBlockTransactions(string memory _blockJson, uint256 _txCount) - internal - pure - returns (address[] memory froms_, address[] memory tos_, bytes[] memory inputs_) - { - froms_ = new address[](_txCount); - tos_ = new address[](_txCount); - inputs_ = new bytes[](_txCount); - - for (uint256 i = 0; i < _txCount; i++) { - string memory base = string.concat(".transactions[", vm.toString(i), "]"); - froms_[i] = vm.parseJsonAddress(_blockJson, string.concat(base, ".from")); - tos_[i] = vm.parseJsonAddress(_blockJson, string.concat(base, ".to")); - inputs_[i] = vm.parseJsonBytes(_blockJson, string.concat(base, ".input")); - } - } - - /// @notice Returns the block transaction index where the NUT bundle starts, identified by - /// matching the first bundle transaction's from+to pair. - function _findBundleStart( - address[] memory _froms, - address[] memory _tos, - NetworkUpgradeTxns.NetworkUpgradeTxn memory _firstBundleTxn - ) - internal - pure - returns (uint256) - { - for (uint256 i = 0; i < _froms.length; i++) { - if (_froms[i] == _firstBundleTxn.from && _tos[i] == _firstBundleTxn.to) { - return i; - } - } - revert("L2ForkUpgrade_ActivationBlockTxns_Test: NUT bundle start not found in activation block"); - } } /// @title L2ForkUpgrade_GasProfile_Test From 373b1eeed1847075309625c2dc26e48335cba3c2 Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Thu, 21 May 2026 16:35:22 +0200 Subject: [PATCH 20/29] fix(acceptance-test-l2cm-karst): fix linter issues --- .../checks/test-validation/exclusions.toml | 35 +++++----- .../L2VerifyBetanetForkUpgrade.t.sol | 69 ++++++++++--------- .../test/L2/fork/L2ForkUpgrade.t.sol | 6 +- 3 files changed, 57 insertions(+), 53 deletions(-) diff --git a/packages/contracts-bedrock/scripts/checks/test-validation/exclusions.toml b/packages/contracts-bedrock/scripts/checks/test-validation/exclusions.toml index 62eda759f06..f61a1ffbb36 100644 --- a/packages/contracts-bedrock/scripts/checks/test-validation/exclusions.toml +++ b/packages/contracts-bedrock/scripts/checks/test-validation/exclusions.toml @@ -16,23 +16,24 @@ # documented here to avoid false validation failures while maintaining the validation rules # for standard contract tests. src_validation = [ - "test/invariants/", # Invariant testing framework - no direct src counterpart - "test/opcm/", # OP Chain Manager tests - may have different structure - "test/scripts/", # Script tests - test deployment/utility scripts, not contracts - "test/integration/", # Integration tests - test multiple contracts together - "test/cannon/MIPS64Memory.t.sol", # Tests external MIPS implementation - "test/dispute/lib/LibClock.t.sol", # Tests library utilities - "test/dispute/lib/LibGameId.t.sol", # Tests library utilities - "test/libraries/DeployUtils.t.sol", # Tests library utilities - no direct src counterpart - "test/setup/DeployVariations.t.sol", # Tests deployment variations - "test/setup/PastNUTBundles.t.sol", # Tests a test-only library (test/setup/PastNUTBundles.sol) - "test/universal/BenchmarkTest.t.sol", # Performance benchmarking tests - "test/universal/ExtendedPause.t.sol", # Tests extended functionality - "test/vendor/Initializable.t.sol", # Tests external vendor code - "test/vendor/InitializableOZv5.t.sol", # Tests external vendor code - "test/L2/fork/L2ForkUpgrade.t.sol", # Tests L2 fork upgrade workflow - "test/L2/L2GenesisForkUpgrade.t.sol", # Tests L2 genesis fork upgrade workflow - "test/dispute/zk/ZKDisputeGameIntegration.t.sol", # Integration tests for ZK dispute game lifecycle + "test/invariants/", # Invariant testing framework - no direct src counterpart + "test/opcm/", # OP Chain Manager tests - may have different structure + "test/scripts/", # Script tests - test deployment/utility scripts, not contracts + "test/integration/", # Integration tests - test multiple contracts together + "test/cannon/MIPS64Memory.t.sol", # Tests external MIPS implementation + "test/dispute/lib/LibClock.t.sol", # Tests library utilities + "test/dispute/lib/LibGameId.t.sol", # Tests library utilities + "test/libraries/DeployUtils.t.sol", # Tests library utilities - no direct src counterpart + "test/setup/DeployVariations.t.sol", # Tests deployment variations + "test/setup/PastNUTBundles.t.sol", # Tests a test-only library (test/setup/PastNUTBundles.sol) + "test/universal/BenchmarkTest.t.sol", # Performance benchmarking tests + "test/universal/ExtendedPause.t.sol", # Tests extended functionality + "test/vendor/Initializable.t.sol", # Tests external vendor code + "test/vendor/InitializableOZv5.t.sol", # Tests external vendor code + "test/L2/fork/L2ForkUpgrade.t.sol", # Tests L2 fork upgrade workflow + "test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol", # Tests L2 betanet fork upgrade workflow - no src counterpart + "test/L2/L2GenesisForkUpgrade.t.sol", # Tests L2 genesis fork upgrade workflow + "test/dispute/zk/ZKDisputeGameIntegration.t.sol", # Integration tests for ZK dispute game lifecycle ] # PATHS EXCLUDED FROM CONTRACT NAME FILE PATH VALIDATION: diff --git a/packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol b/packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol index 1d2abb6f0d6..dae472837f1 100644 --- a/packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol +++ b/packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol @@ -2,18 +2,14 @@ pragma solidity 0.8.15; // Testing -import { CommonTest } from "test/setup/CommonTest.sol"; import { console2 as console } from "forge-std/console2.sol"; import { Vm } from "forge-std/Vm.sol"; // Scripts import { Config } from "scripts/libraries/Config.sol"; -import { ExecuteNUTBundle } from "scripts/upgrade/ExecuteNUTBundle.s.sol"; -import { GenerateNUTBundle } from "scripts/upgrade/GenerateNUTBundle.s.sol"; // Libraries import { LibString } from "@solady/utils/LibString.sol"; -import { DevFeatures } from "src/libraries/DevFeatures.sol"; import { NetworkUpgradeTxns } from "src/libraries/NetworkUpgradeTxns.sol"; import { Predeploys } from "src/libraries/Predeploys.sol"; import { Process } from "scripts/libraries/Process.sol"; @@ -39,7 +35,7 @@ contract L2VerifyBetanetForkUpgrade_TestInit is L2ForkUpgrade_TestInit { /// @notice Verify betanet fork upgrade tests switch to the block after the activation block. function _executeCurrentBundle() internal virtual override { - uint256 l2BlockAfterFork = Config.l2BlockAfterFork(); + uint256 l2BlockAfterFork = Config.l2BlockAfterFork(); if (l2BlockAfterFork == 0) { vm.createSelectFork(Config.l2ForkRpcUrl()); } else { @@ -51,7 +47,10 @@ contract L2VerifyBetanetForkUpgrade_TestInit is L2ForkUpgrade_TestInit { /// @title L2GenesisForkUpgrade_Versions_Test /// @notice Tests that all predeploy versions were updated during the betanet activation. -contract L2VerifyBetanetForkUpgrade_Versions_Test is L2VerifyBetanetForkUpgrade_TestInit, L2ForkUpgrade_Versions_Test { +contract L2VerifyBetanetForkUpgrade_Versions_Test is + L2VerifyBetanetForkUpgrade_TestInit, + L2ForkUpgrade_Versions_Test +{ function setUp() public override(L2VerifyBetanetForkUpgrade_TestInit, L2ForkUpgrade_TestInit) { L2VerifyBetanetForkUpgrade_TestInit.setUp(); } @@ -80,7 +79,8 @@ contract L2VerifyBetanetForkUpgrade_Initialization_Test is /// @notice Tests that all predeploy implementations were correctly upgraded during the betanet activation. contract L2VerifyBetanetForkUpgrade_Implementations_Test is L2VerifyBetanetForkUpgrade_TestInit, - L2ForkUpgrade_Implementations_Test { + L2ForkUpgrade_Implementations_Test +{ function setUp() public override(L2VerifyBetanetForkUpgrade_TestInit, L2ForkUpgrade_TestInit) { L2VerifyBetanetForkUpgrade_TestInit.setUp(); } @@ -154,7 +154,7 @@ contract L2VerifyBetanetForkUpgrade_Events_Test is L2VerifyBetanetForkUpgrade_Te } function test_l2ForkUpgrade_upgradeEventsEmitted_succeeds() public override(L2ForkUpgrade_Events_Test) { - // Skip if running with an unoptimized Foundry profile + // Skip if running with an unoptimized Foundry profile skipIfUnoptimized(); // Pre-capture everything from generateScript before any fork switch: @@ -224,26 +224,25 @@ contract L2VerifyBetanetForkUpgrade_Events_Test is L2VerifyBetanetForkUpgrade_Te } function _getLogs(address[] memory predeploys) internal returns (Vm.Log[] memory logs_) { - bytes32[] memory topics = new bytes32[](1); - uint256 activationBlockNumber = Config.l2ForkBlockNumber() + 1; - topics[0] = UPGRADED_EVENT_TOPIC; - // vm.eth_getLogs serializes address(0) as the literal zero address rather than null, - // so passing address(0) returns no events. Query each predeploy address individually. - uint256 totalCount = 0; - Vm.EthGetLogs[][] memory perDeployLogs = new Vm.EthGetLogs[][](predeploys.length); - for (uint256 p = 0; p < predeploys.length; p++) { - perDeployLogs[p] = - vm.eth_getLogs(activationBlockNumber, activationBlockNumber, predeploys[p], topics); - totalCount += perDeployLogs[p].length; - } - Vm.EthGetLogs[] memory ethLogs = new Vm.EthGetLogs[](totalCount); - uint256 flatIdx = 0; - for (uint256 p = 0; p < predeploys.length; p++) { - for (uint256 q = 0; q < perDeployLogs[p].length; q++) { - ethLogs[flatIdx++] = perDeployLogs[p][q]; - } + bytes32[] memory topics = new bytes32[](1); + uint256 activationBlockNumber = Config.l2ForkBlockNumber() + 1; + topics[0] = UPGRADED_EVENT_TOPIC; + // vm.eth_getLogs serializes address(0) as the literal zero address rather than null, + // so passing address(0) returns no events. Query each predeploy address individually. + uint256 totalCount = 0; + Vm.EthGetLogs[][] memory perDeployLogs = new Vm.EthGetLogs[][](predeploys.length); + for (uint256 p = 0; p < predeploys.length; p++) { + perDeployLogs[p] = vm.eth_getLogs(activationBlockNumber, activationBlockNumber, predeploys[p], topics); + totalCount += perDeployLogs[p].length; + } + Vm.EthGetLogs[] memory ethLogs = new Vm.EthGetLogs[](totalCount); + uint256 flatIdx = 0; + for (uint256 p = 0; p < predeploys.length; p++) { + for (uint256 q = 0; q < perDeployLogs[p].length; q++) { + ethLogs[flatIdx++] = perDeployLogs[p][q]; } - logs_ = _ethGetLogsToLogs(ethLogs); + } + logs_ = _ethGetLogsToLogs(ethLogs); } /// @notice Converts RPC logs from `vm.eth_getLogs` into the shape returned by `vm.getRecordedLogs`. @@ -259,7 +258,6 @@ contract L2VerifyBetanetForkUpgrade_Events_Test is L2VerifyBetanetForkUpgrade_Te } } - /// @title L2VerifyBetanetForkUpgrade_ActivationBlockTxns_Test /// @notice Verifies the activation block contains the expected NUT bundle transactions. contract L2VerifyBetanetForkUpgrade_ActivationBlockTxns_Test is L2VerifyBetanetForkUpgrade_TestInit { @@ -270,7 +268,7 @@ contract L2VerifyBetanetForkUpgrade_ActivationBlockTxns_Test is L2VerifyBetanetF /// @notice Fetches the activation block via RPC and asserts that the NUT bundle transactions /// are present in the correct order with the correct from, to, and calldata. function test_l2VerifyBetanetForkUpgrade_activationBlockContainsNUTBundle_succeeds() public { - // Skip if running with an unoptimized Foundry profile + // Skip if running with an unoptimized Foundry profile skipIfUnoptimized(); // Execute bundle on forked L2 @@ -323,7 +321,11 @@ contract L2VerifyBetanetForkUpgrade_ActivationBlockTxns_Test is L2VerifyBetanetF } /// @notice Fetches a block via `cast rpc` (FFI). Returns JSON suitable for `vm.parseJson`. - function _ffiGetBlockByNumber(string memory _blockHex, string memory _rpcUrl, bool _fullTxs) + function _ffiGetBlockByNumber( + string memory _blockHex, + string memory _rpcUrl, + bool _fullTxs + ) internal returns (string memory blockJson_) { @@ -345,7 +347,10 @@ contract L2VerifyBetanetForkUpgrade_ActivationBlockTxns_Test is L2VerifyBetanetF } /// @notice Parses from/to/input for each transaction in a full activation block JSON payload. - function _parseActivationBlockTransactions(string memory _blockJson, uint256 _txCount) + function _parseActivationBlockTransactions( + string memory _blockJson, + uint256 _txCount + ) internal pure returns (address[] memory froms_, address[] memory tos_, bytes[] memory inputs_) @@ -380,4 +385,4 @@ contract L2VerifyBetanetForkUpgrade_ActivationBlockTxns_Test is L2VerifyBetanetF } revert("L2VerifyBetanetForkUpgrade_ActivationBlockTxns_Test: NUT bundle start not found in activation block"); } -} \ No newline at end of file +} diff --git a/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol b/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol index ab13d8ba36f..6845d4e8abf 100644 --- a/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol +++ b/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol @@ -10,7 +10,6 @@ import { console } from "forge-std/console.sol"; // Scripts import { ExecuteNUTBundle } from "scripts/upgrade/ExecuteNUTBundle.s.sol"; import { GenerateNUTBundle } from "scripts/upgrade/GenerateNUTBundle.s.sol"; -import { Config } from "scripts/libraries/Config.sol"; import { UpgradeUtils } from "scripts/libraries/UpgradeUtils.sol"; // Libraries @@ -646,7 +645,7 @@ contract L2ForkUpgrade_Implementations_Test is L2ForkUpgrade_TestInit { bytes32 internal constant IMPLEMENTATION_SLOT = bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1); /// @notice Tests that all predeploy implementations match expected addresses and have code. - function test_l2ForkUpgrade_implementationsMatch_succeeds() virtual public { + function test_l2ForkUpgrade_implementationsMatch_succeeds() public virtual { // Skip if running with an unoptimized Foundry profile skipIfUnoptimized(); @@ -701,7 +700,7 @@ contract L2ForkUpgrade_Events_Test is L2ForkUpgrade_TestInit { bytes32 internal constant UPGRADED_EVENT_TOPIC = 0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b; /// @notice Tests that all predeploy proxies emit the Upgraded event with correct implementation. - function test_l2ForkUpgrade_upgradeEventsEmitted_succeeds() virtual public { + function test_l2ForkUpgrade_upgradeEventsEmitted_succeeds() public virtual { // Skip if running with an unoptimized Foundry profile skipIfUnoptimized(); @@ -724,7 +723,6 @@ contract L2ForkUpgrade_Events_Test is L2ForkUpgrade_TestInit { // Get all recorded logs Vm.Log[] memory logs = vm.getRecordedLogs(); - // Get all upgradeable predeploys address[] memory predeploys = Predeploys.getUpgradeablePredeploys(); From 521f9917b2b7773c7c6aca82a013f76aaa32721d Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Thu, 21 May 2026 17:07:51 +0200 Subject: [PATCH 21/29] fix(acceptance-test-l2cm-karst): improve env var comment --- packages/contracts-bedrock/justfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contracts-bedrock/justfile b/packages/contracts-bedrock/justfile index 50ac163cb80..40fabfb8f61 100644 --- a/packages/contracts-bedrock/justfile +++ b/packages/contracts-bedrock/justfile @@ -231,7 +231,7 @@ test-l2-fork-upgrade-rerun *ARGS: # Env Vars: # - L2_FORK_RPC_URL must be set to a post-Karst betanet L2 RPC URL. # - L2_BLOCK_BEFORE_FORK must be set to the block right before the fork -# - L2_FORK_BLOCK_NUMBER can be set in the env to pin a block (defaults to latest). +# - L2_FORK_BLOCK_NUMBER optional: pin to a block after the activation block (defaults to latest). prepare-l2cm-activation-env *ARGS: #!/bin/bash set -euo pipefail From 201722290d625fe82fa4ad75a171800eedae6784 Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Thu, 21 May 2026 21:35:37 +0200 Subject: [PATCH 22/29] test(acceptance-test-l2cm-karst): make a withdrawal acceptance test on an upgraded network --- .../base/withdrawal/withdrawal_test_helper.go | 44 +++++++++++++++++++ .../tests/karst/withdrawal_test.go | 10 +++++ 2 files changed, 54 insertions(+) diff --git a/op-acceptance-tests/tests/base/withdrawal/withdrawal_test_helper.go b/op-acceptance-tests/tests/base/withdrawal/withdrawal_test_helper.go index 6a3f40a641b..b3c1a299dd1 100644 --- a/op-acceptance-tests/tests/base/withdrawal/withdrawal_test_helper.go +++ b/op-acceptance-tests/tests/base/withdrawal/withdrawal_test_helper.go @@ -4,6 +4,7 @@ import ( "testing" gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types" + opforks "github.com/ethereum-optimism/optimism/op-core/forks" "github.com/ethereum-optimism/optimism/op-devstack/devtest" "github.com/ethereum-optimism/optimism/op-devstack/presets" "github.com/ethereum-optimism/optimism/op-devstack/sysgo" @@ -77,3 +78,46 @@ func TestWithdrawal(gt *testing.T, gameType gameTypes.GameType, extra ...presets expectedL1UserBalance = expectedL1UserBalance.Sub(withdrawal.FinalizeGasCost()).Add(withdrawalAmount) l1User.VerifyBalanceExact(expectedL1UserBalance) } + +// TestWithdrawalAfterUpgrade is like TestWithdrawal but waits for the given fork to activate +// before initiating the withdrawal, exercising the upgrade path rather than genesis activation. +func TestWithdrawalAfterUpgrade(gt *testing.T, gameType gameTypes.GameType, fork opforks.Name, extra ...presets.Option) { + t := devtest.ParallelT(gt) + sys := newSystem(t, gameType, extra...) + + sys.L2Chain.AwaitActivation(t, fork) + + bridge := sys.StandardBridge() + bridge.VerifyRespectedGameType(gameType) + + initialL1Balance := eth.OneThirdEther + + l1User := sys.FunderL1.NewFundedEOA(initialL1Balance) + l2User := l1User.AsEL(sys.L2EL) + depositAmount := eth.OneTenthEther + withdrawalAmount := eth.OneHundredthEther + + deposit := bridge.Deposit(depositAmount, l1User) + expectedL1UserBalance := initialL1Balance.Sub(depositAmount).Sub(deposit.GasCost()) + l1User.VerifyBalanceExact(expectedL1UserBalance) + expectedL2UserBalance := depositAmount + l2User.VerifyBalanceExact(expectedL2UserBalance) + + withdrawal := bridge.InitiateWithdrawal(withdrawalAmount, l2User) + expectedL2UserBalance = expectedL2UserBalance.Sub(withdrawalAmount).Sub(withdrawal.InitiateGasCost()) + l2User.VerifyBalanceExact(expectedL2UserBalance) + + withdrawal.Prove(l1User) + expectedL1UserBalance = expectedL1UserBalance.Sub(withdrawal.ProveGasCost()) + l1User.VerifyBalanceExact(expectedL1UserBalance) + + sys.AdvanceTime(bridge.GameResolutionDelay()) + withdrawal.WaitForDisputeGameResolved() + + sys.AdvanceTime(max(bridge.WithdrawalDelay()-bridge.GameResolutionDelay(), bridge.DisputeGameFinalityDelay())) + + t.Logger().Info("Attempting to finalize", "proofMaturity", bridge.WithdrawalDelay(), "gameResolutionDelay", bridge.GameResolutionDelay(), "gameFinalityDelay", bridge.DisputeGameFinalityDelay()) + withdrawal.Finalize(l1User) + expectedL1UserBalance = expectedL1UserBalance.Sub(withdrawal.FinalizeGasCost()).Add(withdrawalAmount) + l1User.VerifyBalanceExact(expectedL1UserBalance) +} diff --git a/op-acceptance-tests/tests/karst/withdrawal_test.go b/op-acceptance-tests/tests/karst/withdrawal_test.go index 5eab1a1c7e3..480a037bdf8 100644 --- a/op-acceptance-tests/tests/karst/withdrawal_test.go +++ b/op-acceptance-tests/tests/karst/withdrawal_test.go @@ -5,6 +5,7 @@ import ( "github.com/ethereum-optimism/optimism/op-acceptance-tests/tests/base/withdrawal" gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types" + opforks "github.com/ethereum-optimism/optimism/op-core/forks" "github.com/ethereum-optimism/optimism/op-devstack/presets" "github.com/ethereum-optimism/optimism/op-devstack/sysgo" ) @@ -16,3 +17,12 @@ func TestWithdrawal_Karst(gt *testing.T) { presets.WithDeployerOptions(sysgo.WithKarstAtGenesis), ) } + +// TestWithdrawal_KarstUpgrade is the same withdrawal flow but on a network that +// started pre-Karst and activated Karst mid-chain via a scheduled upgrade. +func TestWithdrawal_KarstUpgrade(gt *testing.T) { + offset := uint64(10) + withdrawal.TestWithdrawalAfterUpgrade(gt, gameTypes.CannonGameType, opforks.Karst, + presets.WithDeployerOptions(sysgo.WithKarstAtOffset(&offset)), + ) +} From 9f54a9c7228d9315974979749d1c5f23273878cc Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Thu, 21 May 2026 23:15:20 +0200 Subject: [PATCH 23/29] fix(acceptance-test-l2cm-karst): fix natspec comment for _executeCurrentBundle override --- .../test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol b/packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol index dae472837f1..24be5a9a330 100644 --- a/packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol +++ b/packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol @@ -33,7 +33,8 @@ contract L2VerifyBetanetForkUpgrade_TestInit is L2ForkUpgrade_TestInit { super.setUp(); } - /// @notice Verify betanet fork upgrade tests switch to the block after the activation block. + /// @notice instead of executing the bundle on the fork, it overrides the execution + /// by going to a block after the activation block. function _executeCurrentBundle() internal virtual override { uint256 l2BlockAfterFork = Config.l2BlockAfterFork(); if (l2BlockAfterFork == 0) { From c1181f36771b693435c71727de40ed954e9ee9b6 Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Thu, 21 May 2026 23:49:29 +0200 Subject: [PATCH 24/29] fix(acceptance-test-l2cm-karst): reuse assertion logic in events tests --- .../L2VerifyBetanetForkUpgrade.t.sol | 53 +------------------ .../test/L2/fork/L2ForkUpgrade.t.sol | 36 ++++++++++--- 2 files changed, 31 insertions(+), 58 deletions(-) diff --git a/packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol b/packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol index 24be5a9a330..de53360071d 100644 --- a/packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol +++ b/packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol @@ -163,12 +163,7 @@ contract L2VerifyBetanetForkUpgrade_Events_Test is L2VerifyBetanetForkUpgrade_Te // (deployed on fork 0) is not accessible. (address storageSetterImpl,,,) = generateScript.implementationConfigs("StorageSetter"); address[] memory predeploys = Predeploys.getUpgradeablePredeploys(); - address[] memory expectedImpls = new address[](predeploys.length); - for (uint256 i = 0; i < predeploys.length; i++) { - if (!_isFeaturePredeployAndDisabled(predeploys[i])) { - expectedImpls[i] = _getExpectedImplementation(predeploys[i], Predeploys.getName(predeploys[i])); - } - } + address[] memory expectedImpls = _getExpectedImpls(predeploys); // Execute upgrade bundle _executeCurrentBundle(); @@ -177,51 +172,7 @@ contract L2VerifyBetanetForkUpgrade_Events_Test is L2VerifyBetanetForkUpgrade_Te Vm.Log[] memory logs = _getLogs(predeploys); // Verify each predeploy emitted the Upgraded event - for (uint256 i = 0; i < predeploys.length; i++) { - address predeploy = predeploys[i]; - - if (_isFeaturePredeployAndDisabled(predeploy)) { - continue; - } - - // Get predeploy name - string memory name = Predeploys.getName(predeploy); - - // Use pre-captured expected implementation (generateScript unavailable after fork switch) - address expectedImpl = expectedImpls[i]; - - // Find the Upgraded event for this predeploy (skip StorageSetter events) - bool foundEvent = false; - for (uint256 j = 0; j < logs.length; j++) { - // Check if this log is an Upgraded event from the current predeploy - if ( - logs[j].emitter == predeploy && logs[j].topics.length > 0 - && logs[j].topics[0] == UPGRADED_EVENT_TOPIC - ) { - // Decode the implementation address from the event - address emittedImpl = address(uint160(uint256(logs[j].topics[1]))); - - // Skip StorageSetter upgrade events (intermediate step for initializable contracts) - if (emittedImpl == storageSetterImpl) { - continue; - } - - foundEvent = true; - - // Verify the implementation matches expected - assertEq( - emittedImpl, - expectedImpl, - string.concat("Upgraded event implementation mismatch for ", name, ": ", vm.toString(predeploy)) - ); - - break; - } - } - - // Verify the event was found - assertTrue(foundEvent, string.concat("Upgraded event not found for ", name, ": ", vm.toString(predeploy))); - } + _verifyEvents(predeploys, logs, expectedImpls, storageSetterImpl); } function _getLogs(address[] memory predeploys) internal returns (Vm.Log[] memory logs_) { diff --git a/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol b/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol index 6845d4e8abf..2cafd5205d3 100644 --- a/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol +++ b/packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol @@ -725,10 +725,32 @@ contract L2ForkUpgrade_Events_Test is L2ForkUpgrade_TestInit { // Get all upgradeable predeploys address[] memory predeploys = Predeploys.getUpgradeablePredeploys(); + address[] memory expectedImpls = _getExpectedImpls(predeploys); // Verify each predeploy emitted the Upgraded event - for (uint256 i = 0; i < predeploys.length; i++) { - address predeploy = predeploys[i]; + _verifyEvents(predeploys, logs, expectedImpls, storageSetterImpl); + } + + function _getExpectedImpls(address[] memory _predeploys) internal view returns (address[] memory expectedImpls_) { + expectedImpls_ = new address[](_predeploys.length); + for (uint256 i = 0; i < _predeploys.length; i++) { + if (!_isFeaturePredeployAndDisabled(_predeploys[i])) { + expectedImpls_[i] = _getExpectedImplementation(_predeploys[i], Predeploys.getName(_predeploys[i])); + } + } + } + + function _verifyEvents( + address[] memory _predeploys, + Vm.Log[] memory _logs, + address[] memory _expectedImpls, + address _storageSetterImpl + ) + internal + view + { + for (uint256 i = 0; i < _predeploys.length; i++) { + address predeploy = _predeploys[i]; if (_isFeaturePredeployAndDisabled(predeploy)) { continue; @@ -742,17 +764,17 @@ contract L2ForkUpgrade_Events_Test is L2ForkUpgrade_TestInit { // Find the Upgraded event for this predeploy (skip StorageSetter events) bool foundEvent = false; - for (uint256 j = 0; j < logs.length; j++) { + for (uint256 j = 0; j < _logs.length; j++) { // Check if this log is an Upgraded event from the current predeploy if ( - logs[j].emitter == predeploy && logs[j].topics.length > 0 - && logs[j].topics[0] == UPGRADED_EVENT_TOPIC + _logs[j].emitter == predeploy && _logs[j].topics.length > 0 + && _logs[j].topics[0] == UPGRADED_EVENT_TOPIC ) { // Decode the implementation address from the event - address emittedImpl = address(uint160(uint256(logs[j].topics[1]))); + address emittedImpl = address(uint160(uint256(_logs[j].topics[1]))); // Skip StorageSetter upgrade events (intermediate step for initializable contracts) - if (emittedImpl == storageSetterImpl) { + if (emittedImpl == _storageSetterImpl) { continue; } From 599e4a9f57deaf173298a040854a40713c104810 Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Mon, 25 May 2026 18:43:53 +0200 Subject: [PATCH 25/29] fix(acceptance-test-l2cm-karst): add comment for fork offset --- op-acceptance-tests/tests/karst/withdrawal_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/op-acceptance-tests/tests/karst/withdrawal_test.go b/op-acceptance-tests/tests/karst/withdrawal_test.go index 480a037bdf8..1638c2abade 100644 --- a/op-acceptance-tests/tests/karst/withdrawal_test.go +++ b/op-acceptance-tests/tests/karst/withdrawal_test.go @@ -21,7 +21,7 @@ func TestWithdrawal_Karst(gt *testing.T) { // TestWithdrawal_KarstUpgrade is the same withdrawal flow but on a network that // started pre-Karst and activated Karst mid-chain via a scheduled upgrade. func TestWithdrawal_KarstUpgrade(gt *testing.T) { - offset := uint64(10) + offset := uint64(10) // arbitrary offset to have a few blocks before Karst withdrawal.TestWithdrawalAfterUpgrade(gt, gameTypes.CannonGameType, opforks.Karst, presets.WithDeployerOptions(sysgo.WithKarstAtOffset(&offset)), ) From bdd84c83af4e7a20133bc162653def3a30555068 Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Mon, 25 May 2026 18:49:08 +0200 Subject: [PATCH 26/29] fix(acceptance-test-l2cm-karst): inline log transformation --- .../L2VerifyBetanetForkUpgrade.t.sol | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol b/packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol index 6a45dcd3466..f080bd95fa1 100644 --- a/packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol +++ b/packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol @@ -153,26 +153,17 @@ contract L2VerifyBetanetForkUpgrade_Events_Test is L2VerifyBetanetForkUpgrade_Te vm.eth_getLogs(activationBlockNumber, activationBlockNumber, predeploys[p].predeploy, topics); totalCount += perDeployLogs[p].length; } - Vm.EthGetLogs[] memory ethLogs = new Vm.EthGetLogs[](totalCount); + logs_ = new Vm.Log[](totalCount); uint256 flatIdx = 0; for (uint256 p = 0; p < predeploys.length; p++) { for (uint256 q = 0; q < perDeployLogs[p].length; q++) { - ethLogs[flatIdx++] = perDeployLogs[p][q]; + Vm.EthGetLogs memory ethLog = perDeployLogs[p][q]; + logs_[flatIdx].topics = ethLog.topics; + logs_[flatIdx].data = ethLog.data; + logs_[flatIdx].emitter = ethLog.emitter; + flatIdx++; } } - logs_ = _ethGetLogsToLogs(ethLogs); - } - - /// @notice Converts RPC logs from `vm.eth_getLogs` into the shape returned by `vm.getRecordedLogs`. - /// @param _ethLogs The RPC logs from `vm.eth_getLogs`. - /// @return logs_ The logs in the shape returned by `vm.getRecordedLogs`. - function _ethGetLogsToLogs(Vm.EthGetLogs[] memory _ethLogs) internal pure returns (Vm.Log[] memory logs_) { - logs_ = new Vm.Log[](_ethLogs.length); - for (uint256 i = 0; i < _ethLogs.length; i++) { - logs_[i].topics = _ethLogs[i].topics; - logs_[i].data = _ethLogs[i].data; - logs_[i].emitter = _ethLogs[i].emitter; - } } } From c9ea936bebda3f4a15be45d250b8f619c6f300c8 Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Mon, 25 May 2026 18:59:39 +0200 Subject: [PATCH 27/29] fix(acceptance-test-l2cm-karst): cleanup --- .../test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol | 2 +- packages/contracts-bedrock/test/setup/Setup.sol | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol b/packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol index f080bd95fa1..b82bfaaab33 100644 --- a/packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol +++ b/packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol @@ -45,7 +45,7 @@ contract L2VerifyBetanetForkUpgrade_TestInit is L2ForkUpgrade_TestInit { } } -/// @title L2GenesisForkUpgrade_Versions_Test +/// @title L2VerifyBetanetForkUpgrade_Versions_Test /// @notice Tests that all predeploy versions were updated during the betanet activation. contract L2VerifyBetanetForkUpgrade_Versions_Test is L2VerifyBetanetForkUpgrade_TestInit, diff --git a/packages/contracts-bedrock/test/setup/Setup.sol b/packages/contracts-bedrock/test/setup/Setup.sol index 60c4b2588df..12b3badf7c3 100644 --- a/packages/contracts-bedrock/test/setup/Setup.sol +++ b/packages/contracts-bedrock/test/setup/Setup.sol @@ -173,11 +173,6 @@ abstract contract Setup is FeatureFlags { return Config.l2ForkTest(); } - /// @notice Indicates whether a test is running against a Karst betanet L2 fork test. - function isL2CMActivationTest() public view returns (bool) { - return Config.l2CMActivationTest(); - } - /// @dev Deploys either the Deploy.s.sol or Fork.s.sol contract, by fetching the bytecode dynamically using /// `vm.getDeployedCode()` and etching it into the state. /// This enables us to avoid including the bytecode of those contracts in the bytecode of this contract. From d4711ae35a600f954ee2911ed5c61dd1a2aefe20 Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Mon, 25 May 2026 19:14:28 +0200 Subject: [PATCH 28/29] fix(acceptance-test-l2cm-karst): include data in _findBundleStart --- .../test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol b/packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol index b82bfaaab33..4497855a662 100644 --- a/packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol +++ b/packages/contracts-bedrock/test/L2/betanet-fork/L2VerifyBetanetForkUpgrade.t.sol @@ -201,7 +201,7 @@ contract L2VerifyBetanetForkUpgrade_ActivationBlockTxns_Test is L2VerifyBetanetF // The activation block also contains the L1 attributes deposit (and potentially other // system transactions) before the NUT bundle. Find the bundle start by matching the // first bundle transaction's from+to. - uint256 bundleStart = _findBundleStart(froms, tos, bundleTxns[0]); + uint256 bundleStart = _findBundleStart(froms, tos, inputs, bundleTxns[0]); assertGe( froms.length - bundleStart, @@ -281,6 +281,7 @@ contract L2VerifyBetanetForkUpgrade_ActivationBlockTxns_Test is L2VerifyBetanetF function _findBundleStart( address[] memory _froms, address[] memory _tos, + bytes[] memory _inputs, NetworkUpgradeTxns.NetworkUpgradeTxn memory _firstBundleTxn ) internal @@ -288,7 +289,10 @@ contract L2VerifyBetanetForkUpgrade_ActivationBlockTxns_Test is L2VerifyBetanetF returns (uint256) { for (uint256 i = 0; i < _froms.length; i++) { - if (_froms[i] == _firstBundleTxn.from && _tos[i] == _firstBundleTxn.to) { + if ( + _froms[i] == _firstBundleTxn.from && _tos[i] == _firstBundleTxn.to + && keccak256(_inputs[i]) == keccak256(_firstBundleTxn.data) + ) { return i; } } From ff2746232e94a6ea727fe7fc9fa0da59ee9da0d4 Mon Sep 17 00:00:00 2001 From: 0xCoati <258754646+0xCoati@users.noreply.github.com> Date: Wed, 27 May 2026 23:06:32 +0200 Subject: [PATCH 29/29] fix(acceptance-test-l2cm-karst): bump github