Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0179e8a
test(withdraw-post-karst): acceptance test for withdrawing on karst
0xCoati May 7, 2026
9782ee9
test(acceptance-test-l2cm-karst): add acceptance test for doing a min…
0xCoati May 11, 2026
a118a9c
test(karst-fork-tests): skip upgrade executing when already on karst,…
0xCoati May 11, 2026
329e54d
test(acceptance-test-l2cm-karst): make the test proper e2e
0xCoati May 11, 2026
0a8fe52
Merge branch 'develop' into test/l2cm-upgrade-accaptance-test
0xCoati May 12, 2026
1971c09
fix(acceptance-test-l2cm-karst): remove redundant flag check
0xCoati May 13, 2026
a12f6d0
Revert "test(acceptance-test-l2cm-karst): make the test proper e2e"
0xCoati May 13, 2026
e2eb850
Revert "test(acceptance-test-l2cm-karst): add acceptance test for doi…
0xCoati May 13, 2026
81bcb09
fix(acceptance-test-l2cm-karst): remove karst activation check in for…
0xCoati May 13, 2026
da9943e
test(acceptance-test-l2cm-karst): add test to check the presence of t…
0xCoati May 13, 2026
331ce17
fix(acceptance-test-l2cm-karst): fix testname lint error
0xCoati May 13, 2026
96f15bc
fix(acceptance-test-l2cm-karst): remove misleading comment
0xCoati May 13, 2026
7cd0a82
fix(acceptance-test-l2cm-karst): skip execute and events test when cu…
0xCoati May 15, 2026
71506e6
fix(acceptance-test-l2cm-karst): rework betanet upgrade test to work …
0xCoati May 18, 2026
fff70c2
fix(acceptance-test-l2cm-karst): fix rerun command and make l2BlockAf…
0xCoati May 19, 2026
d292e28
fix(acceptance-test-l2cm-karst): fix usage example
0xCoati May 19, 2026
e05e520
fix(acceptance-test-l2cm-karst): add missing latest fallback for the …
0xCoati May 19, 2026
5185926
fix(acceptance-test-l2cm-karst): fix semgrep errors
0xCoati May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions op-acceptance-tests/tests/karst/withdrawal_test.go
Original file line number Diff line number Diff line change
@@ -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),
)
}
12 changes: 12 additions & 0 deletions packages/contracts-bedrock/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,18 @@ 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.
# 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=<url> L2_BLOCK_BEFORE_FORK=<block> 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-post-karst-betanet-l2-fork-upgrade-rerun *ARGS:
just test-post-karst-betanet-l2-fork-upgrade {{ARGS}} --rerun -vvvv

########################################################
# DEPLOY #
########################################################
Expand Down
17 changes: 17 additions & 0 deletions packages/contracts-bedrock/scripts/libraries/Config.sol
Original file line number Diff line number Diff line change
Expand Up @@ -289,13 +289,30 @@ library Config {
return vm.envOr("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.
function l2ForkRpcUrl() internal view returns (string memory) {
return vm.envString("L2_FORK_RPC_URL");
}

/// @notice Returns the L2 block after the fork.
function l2BlockAfterFork() internal view returns (uint256) {
if (l2CMActivationTest()) {
return vm.envOr("L2_FORK_BLOCK_NUMBER", uint256(0));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this needs to be fixed before merge.

The justfile documents L2_BLOCK_AFTER_FORK, but this reads L2_FORK_BLOCK_NUMBER and defaults to 0. I assume 0 is meant to preserve the existing "use latest" behaviour from the normal L2 fork setup. But activation mode passes this value directly to createSelectFork(url, block), so following the documented command makes the post upgrade fork select explicit block 0, not latest or the intended after fork block.

Could we do one of the following:

  • Read a mandatory L2_BLOCK_AFTER_FORK
  • Keep 0 as latest and have _executeCurrentBundleOrSwitchFork() call createSelectFork(url) overload when this returns 0?

The pre/post activation flow can become:

  • Fork at L2_BLOCK_BEFORE_FORK
  • Capture pre upgrade state
  • Fork at block 0
  • Verify post upgrade state

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in d292e28 and e05e520

}
revert("Config: l2BlockAfterFork called outside of L2CM activation test");
}

/// @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));
}

Expand Down
179 changes: 170 additions & 9 deletions packages/contracts-bedrock/test/L2/fork/L2ForkUpgrade.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,18 @@ 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
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";
import { NetworkUpgradeTxns } from "src/libraries/NetworkUpgradeTxns.sol";
import { Constants } from "src/libraries/Constants.sol";

// Interfaces
import { ICrossDomainMessenger } from "interfaces/universal/ICrossDomainMessenger.sol";
Expand Down Expand Up @@ -90,11 +93,38 @@ 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 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()) {
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;
}
_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.
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;
Expand Down Expand Up @@ -177,7 +207,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);
Expand Down Expand Up @@ -282,7 +312,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);
Expand Down Expand Up @@ -636,8 +666,8 @@ contract L2ForkUpgrade_Implementations_Test is L2ForkUpgrade_TestInit {
// Skip if running with an unoptimized Foundry profile
skipIfUnoptimized();

// Execute upgrade
_executeCurrentBundle();
// Execute bundle on forked L2
_executeCurrentBundleOrSwitchFork();

// Get all upgradeable predeploys
address[] memory predeploys = Predeploys.getUpgradeablePredeploys();
Expand Down Expand Up @@ -688,17 +718,39 @@ 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()
if (_isCurrentBundleAlreadyApplied()) {
vm.skip(true);
return;
}

// 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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently the L2CM activation path only checks the historical Upgraded(address) logs. That doesn't prove the activation block contained the expected NUT bundle txns.

Can we also assert that the activation block contains the expected activation bundle txns? Otherwise missing, reordered, or corrupted upgrade txns could still be missed if the expected upgrade logs are present.

}

// Get all upgradeable predeploys
address[] memory predeploys = Predeploys.getUpgradeablePredeploys();
Expand Down Expand Up @@ -750,6 +802,97 @@ 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;
}
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
Expand Down Expand Up @@ -982,3 +1125,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_l2ForkUpgrade_deterministicDeploymentProxyExistence_succeeds() external view {
address proxy = Preinstalls.DeterministicDeploymentProxy;
assertNotEq(proxy.code.length, 0, "DeterministicDeploymentProxy must have code");
assertEq(
proxy.code, Preinstalls.DeterministicDeploymentProxyCode, "unexpected DeterministicDeploymentProxy bytecode"
);
}
}
5 changes: 5 additions & 0 deletions packages/contracts-bedrock/test/setup/Setup.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.
Expand Down