From b829134905c0a9ea1e8a1c73e334840603d19fc2 Mon Sep 17 00:00:00 2001 From: Wazabie <48911235+Wazabie@users.noreply.github.com> Date: Fri, 26 Sep 2025 20:42:15 +0200 Subject: [PATCH 1/5] Adding initial files --- src/template/TransferL2PAOFromL1ToEOA.sol | 128 ++++++++++++++++++ test/tasks/Regression.t.sol | 28 ++++ .../sep/027-transfer-l2pao-to-eoa/.env | 2 + .../sep/027-transfer-l2pao-to-eoa/config.toml | 7 + 4 files changed, 165 insertions(+) create mode 100644 src/template/TransferL2PAOFromL1ToEOA.sol create mode 100644 test/tasks/example/sep/027-transfer-l2pao-to-eoa/.env create mode 100644 test/tasks/example/sep/027-transfer-l2pao-to-eoa/config.toml diff --git a/src/template/TransferL2PAOFromL1ToEOA.sol b/src/template/TransferL2PAOFromL1ToEOA.sol new file mode 100644 index 000000000..ad386c710 --- /dev/null +++ b/src/template/TransferL2PAOFromL1ToEOA.sol @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +import {VmSafe} from "forge-std/Vm.sol"; +import {stdToml} from "forge-std/StdToml.sol"; +import {Predeploys} from "@eth-optimism-bedrock/src/libraries/Predeploys.sol"; + +import {L2TaskBase} from "src/tasks/types/L2TaskBase.sol"; +import {SuperchainAddressRegistry} from "src/SuperchainAddressRegistry.sol"; +import {Action} from "src/libraries/MultisigTypes.sol"; + +/// @notice Template contract to transfer ownership of the L2 ProxyAdmin to an EOA address. +/// This template is used when the L2 ProxyAdmin is currently held by a multisig (e.g., 2/2 safe) +/// and needs to be transferred to an EOA. The transfer is executed via L1 using the OptimismPortal +/// deposit transaction mechanism. +/// See: https://docs.optimism.io/stack/transactions/deposit-flow +/// +/// ATTENTION: Use caution when using this template — transferring ownership is high risk. +/// To gain additional assurance that the corresponding L2 deposit transaction works as expected, +/// you must follow the steps outlined in the documentation: ../doc/simulate-l2-ownership-transfer.md +/// Add the results of the simulation to the VALIDATION.md file for the task. +/// +/// Manual Post-Execution checks to follow when executing this task: +/// 1. Find the L2 deposit transaction sent from the L1 caller. +/// 2. The transaction should be interacting with the L2 ProxyAdmin at 0x4200000000000000000000000000000000000018. +/// 3. Verify that the OwnershipTransferred event was emitted with the correct new EOA owner. +contract TransferL2PAOFromL1ToEOA is L2TaskBase { + using stdToml for string; + + /// @notice The new owner EOA address. + address public newOwnerEOA; + + /// @notice Returns the safe address string identifier + function safeAddressString() public pure override returns (string memory) { + return "ProxyAdminOwner"; + } + + /// @notice Returns the storage write permissions required for this task. + function _taskStorageWrites() internal pure virtual override returns (string[] memory) { + string[] memory storageWrites = new string[](1); + storageWrites[0] = "OptimismPortalProxy"; + return storageWrites; + } + + /// @notice Sets up the template with the new EOA owner from a TOML file. + function _templateSetup(string memory taskConfigFilePath, address rootSafe) internal override { + super._templateSetup(taskConfigFilePath, rootSafe); + string memory toml = vm.readFile(taskConfigFilePath); + + // New owner EOA address. + newOwnerEOA = abi.decode(vm.parseToml(toml, ".newOwnerEOA"), (address)); + + // Only allow one chain to be modified at a time with this template. + SuperchainAddressRegistry.ChainInfo[] memory _chains = superchainAddrRegistry.getChains(); + require(_chains.length == 1, "Must specify exactly one chain id to transfer ownership for"); + } + + /// @notice Builds the actions for transferring ownership of the proxy admin on the L2 to an EOA. + /// It does this by calling the L1 OptimismPortal's depositTransaction function. + function _build(address) internal override { + SuperchainAddressRegistry.ChainInfo[] memory chains = superchainAddrRegistry.getChains(); + + // See this Tenderly simulation for an example of this gas limit working: https://www.tdly.co/shared/simulation/d5028138-469c-4bb2-97fd-50f5f4bb8515 + uint64 gasLimit = 200000; + OptimismPortal optimismPortal = + OptimismPortal(superchainAddrRegistry.getAddress("OptimismPortalProxy", chains[0].chainId)); + optimismPortal.depositTransaction( + address(Predeploys.PROXY_ADMIN), + 0, + gasLimit, + false, + abi.encodeCall(ProxyAdmin.transferOwnership, (newOwnerEOA)) + ); + } + + /// @notice Validates that the owner was transferred correctly. + function _validate(VmSafe.AccountAccess[] memory, Action[] memory actions, address) internal view override { + // Validate that the depositTransaction action was created correctly + SuperchainAddressRegistry.ChainInfo[] memory chains = superchainAddrRegistry.getChains(); + address expectedPortal = superchainAddrRegistry.getAddress("OptimismPortalProxy", chains[0].chainId); + + // Expected calldata for depositTransaction + uint64 gasLimit = 200000; + bytes memory expectedCalldata = abi.encodeCall( + OptimismPortal.depositTransaction, + ( + address(Predeploys.PROXY_ADMIN), // _to + 0, // _value + gasLimit, // _gasLimit + false, // _isCreation + abi.encodeCall(ProxyAdmin.transferOwnership, (newOwnerEOA)) // _data + ) + ); + + // Check that we have exactly one action to the OptimismPortal with the expected calldata + bool found = false; + uint256 matches = 0; + for (uint256 i = 0; i < actions.length; i++) { + if (actions[i].target == expectedPortal) { + if (keccak256(actions[i].arguments) == keccak256(expectedCalldata)) { + found = true; + matches++; + } + assertEq(actions[i].value, 0, "Should not send ETH with depositTransaction"); + } + } + + assertTrue(found, "depositTransaction action not found"); + assertEq(matches, 1, "Should have exactly one depositTransaction action"); + + // Note: We can't validate the L2 state change since it only happens after L1 execution + // Manual verification steps are documented in the contract comments above + } + + /// @notice No code exceptions for this template. + function _getCodeExceptions() internal view virtual override returns (address[] memory) {} +} + +interface OptimismPortal { + function depositTransaction(address _to, uint256 _value, uint64 _gasLimit, bool _isCreation, bytes memory _data) + external + payable; +} + +interface ProxyAdmin { + function owner() external view returns (address); + function transferOwnership(address newOwner) external; +} \ No newline at end of file diff --git a/test/tasks/Regression.t.sol b/test/tasks/Regression.t.sol index ce196a6a5..8a969e01b 100644 --- a/test/tasks/Regression.t.sol +++ b/test/tasks/Regression.t.sol @@ -20,6 +20,7 @@ import {DelayedWETHOwnershipTemplate} from "src/template/DelayedWETHOwnershipTem import {TransferOwners} from "src/template/TransferOwners.sol"; import {TransferL2PAOFromL1} from "src/template/TransferL2PAOFromL1.sol"; import {DisableModule} from "src/template/DisableModule.sol"; +import {TransferL2PAOFromL1ToEOA} from "src/template/TransferL2PAOFromL1ToEOA.sol"; import {Action} from "src/libraries/MultisigTypes.sol"; import {GnosisSafeApproveHash} from "src/template/GnosisSafeApproveHash.sol"; import {SetDisputeGameImpl} from "src/template/SetDisputeGameImpl.sol"; @@ -560,6 +561,33 @@ contract RegressionTest is Test { _assertDataToSignNestedMultisig(multisigTask, actions, expectedDataToSign, MULTICALL3_ADDRESS, rootSafe); } + /// @notice Expected call data and data to sign generated by manually running the TransferL2PAOFromL1ToEOA template + /// Simulate from task directory (test/tasks/example/sep/026-transfer-l2pao-to-eoa) with: + /// SIMULATE_WITHOUT_LEDGER=1 just --dotenv-path "$(pwd)/.env" --justfile ../../../../../src/justfile simulate + function testRegressionCallDataMatches_TransferL2PAOFromL1ToEOA() public { + string memory taskConfigFilePath = "test/tasks/example/sep/027-transfer-l2pao-to-eoa/config.toml"; + // Call data generated by manually running the TransferL2PAOFromL1ToEOA template on sepolia + string memory expectedCallData = + "0x174dea71000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000ff6eba109271fe6d4237eeed4bab1dd9a77dd1a40000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000104e9e05c42000000000000000000000000420000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000030d40000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000024f2fde38b000000000000000000000000e78a0a96c5d6ae6c606418ed4a9ced378cb030a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; + MultisigTask multisigTask = new TransferL2PAOFromL1ToEOA(); + // Using the Worldchain sepolia challenger safe from the actual simulation + address rootSafe = address(0x945185C01fb641bA3E63a9bdF66575e35a407837); + address[] memory allSafes = new address[](1); + allSafes[0] = rootSafe; + (Action[] memory actions, uint256[] memory allOriginalNonces) = + _setupAndSimulate(taskConfigFilePath, 9000000, "sepolia", multisigTask, allSafes); + bytes memory rootSafeCalldata = + _assertCallDataMatches(multisigTask, actions, allSafes, allOriginalNonces, expectedCallData); + + // Data to sign generated by manually running the TransferL2PAOFromL1ToEOA template on sepolia + uint256 rootSafeNonce = allOriginalNonces[allOriginalNonces.length - 1]; + string memory expectedDataToSign = + "0x19016faec9c52949ba8274340008df12c69faedd5c44e77f77c956d2ca8e4bcd877ece8dfc106d22fd21fc65bfa3fe41c4943eb0f02cfb831d03cd4a15934b5fe163"; + _assertDataToSignSingleMultisig( + rootSafe, rootSafeCalldata, expectedDataToSign, rootSafeNonce, MULTICALL3_ADDRESS + ); + } + /// @notice Expected call data and data to sign generated by manually running the SetDisputeGameImpl template at block 7798424 on sepolia. /// Simulate from task directory (test/tasks/example/sep/014-set-dispute-game-impl/config.toml) with: /// SIMULATE_WITHOUT_LEDGER=1 just --dotenv-path $(pwd)/.env --justfile ../../../../../src/nested.just simulate diff --git a/test/tasks/example/sep/027-transfer-l2pao-to-eoa/.env b/test/tasks/example/sep/027-transfer-l2pao-to-eoa/.env new file mode 100644 index 000000000..97e801c51 --- /dev/null +++ b/test/tasks/example/sep/027-transfer-l2pao-to-eoa/.env @@ -0,0 +1,2 @@ +TENDERLY_GAS=25000000 +FORK_BLOCK_NUMBER=9000000 diff --git a/test/tasks/example/sep/027-transfer-l2pao-to-eoa/config.toml b/test/tasks/example/sep/027-transfer-l2pao-to-eoa/config.toml new file mode 100644 index 000000000..9a835bb50 --- /dev/null +++ b/test/tasks/example/sep/027-transfer-l2pao-to-eoa/config.toml @@ -0,0 +1,7 @@ +# Test configuration for TransferL2PAOFromL1ToEOA template +l2chains = [{name = "Worldchain Sepolia", chainId = 4801}] + +templateName = "TransferL2PAOFromL1ToEOA" + +# The new EOA owner address (from the actual simulation) +newOwnerEOA = "0xe78a0a96c5d6ae6c606418ed4a9ced378cb030a0" From 7067957aac0ee13b978ae94568d484b01cb9ea0c Mon Sep 17 00:00:00 2001 From: Wazabie <48911235+Wazabie@users.noreply.github.com> Date: Fri, 26 Sep 2025 20:45:46 +0200 Subject: [PATCH 2/5] Update TransferL2PAOFromL1ToEOA.sol --- src/template/TransferL2PAOFromL1ToEOA.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/template/TransferL2PAOFromL1ToEOA.sol b/src/template/TransferL2PAOFromL1ToEOA.sol index ad386c710..e1ec42ad5 100644 --- a/src/template/TransferL2PAOFromL1ToEOA.sol +++ b/src/template/TransferL2PAOFromL1ToEOA.sol @@ -125,4 +125,4 @@ interface OptimismPortal { interface ProxyAdmin { function owner() external view returns (address); function transferOwnership(address newOwner) external; -} \ No newline at end of file +} From fd166a8ea0116a1dfcaa898463fb66ea7b0939eb Mon Sep 17 00:00:00 2001 From: Wazabie <48911235+Wazabie@users.noreply.github.com> Date: Fri, 26 Sep 2025 21:14:47 +0200 Subject: [PATCH 3/5] Update Regression.t.sol Co-authored-by: blaine --- test/tasks/Regression.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/tasks/Regression.t.sol b/test/tasks/Regression.t.sol index 8a969e01b..70455e273 100644 --- a/test/tasks/Regression.t.sol +++ b/test/tasks/Regression.t.sol @@ -562,7 +562,7 @@ contract RegressionTest is Test { } /// @notice Expected call data and data to sign generated by manually running the TransferL2PAOFromL1ToEOA template - /// Simulate from task directory (test/tasks/example/sep/026-transfer-l2pao-to-eoa) with: + /// Simulate from task directory (test/tasks/example/sep/027-transfer-l2pao-to-eoa) with: /// SIMULATE_WITHOUT_LEDGER=1 just --dotenv-path "$(pwd)/.env" --justfile ../../../../../src/justfile simulate function testRegressionCallDataMatches_TransferL2PAOFromL1ToEOA() public { string memory taskConfigFilePath = "test/tasks/example/sep/027-transfer-l2pao-to-eoa/config.toml"; From 3e8402ae8487d83fc26cd9ae68f6d6a9f4381847 Mon Sep 17 00:00:00 2001 From: Wazabie <48911235+Wazabie@users.noreply.github.com> Date: Fri, 26 Sep 2025 17:40:01 -0400 Subject: [PATCH 4/5] Update src/template/TransferL2PAOFromL1ToEOA.sol Co-authored-by: almanax-ai[bot] <174396398+almanax-ai[bot]@users.noreply.github.com> --- src/template/TransferL2PAOFromL1ToEOA.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/template/TransferL2PAOFromL1ToEOA.sol b/src/template/TransferL2PAOFromL1ToEOA.sol index e1ec42ad5..d3d6d9153 100644 --- a/src/template/TransferL2PAOFromL1ToEOA.sol +++ b/src/template/TransferL2PAOFromL1ToEOA.sol @@ -47,10 +47,10 @@ contract TransferL2PAOFromL1ToEOA is L2TaskBase { super._templateSetup(taskConfigFilePath, rootSafe); string memory toml = vm.readFile(taskConfigFilePath); + // New owner EOA address. // New owner EOA address. newOwnerEOA = abi.decode(vm.parseToml(toml, ".newOwnerEOA"), (address)); - - // Only allow one chain to be modified at a time with this template. + require(newOwnerEOA != address(0), "newOwnerEOA must be non-zero address"); SuperchainAddressRegistry.ChainInfo[] memory _chains = superchainAddrRegistry.getChains(); require(_chains.length == 1, "Must specify exactly one chain id to transfer ownership for"); } From 8b327a68dac9502fef7310d1f6196a93cc494fcc Mon Sep 17 00:00:00 2001 From: Wazabie <48911235+Wazabie@users.noreply.github.com> Date: Fri, 26 Sep 2025 17:45:52 -0400 Subject: [PATCH 5/5] Update src/template/TransferL2PAOFromL1ToEOA.sol Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> --- src/template/TransferL2PAOFromL1ToEOA.sol | 1 - 1 file changed, 1 deletion(-) diff --git a/src/template/TransferL2PAOFromL1ToEOA.sol b/src/template/TransferL2PAOFromL1ToEOA.sol index d3d6d9153..bc889aa74 100644 --- a/src/template/TransferL2PAOFromL1ToEOA.sol +++ b/src/template/TransferL2PAOFromL1ToEOA.sol @@ -47,7 +47,6 @@ contract TransferL2PAOFromL1ToEOA is L2TaskBase { super._templateSetup(taskConfigFilePath, rootSafe); string memory toml = vm.readFile(taskConfigFilePath); - // New owner EOA address. // New owner EOA address. newOwnerEOA = abi.decode(vm.parseToml(toml, ".newOwnerEOA"), (address)); require(newOwnerEOA != address(0), "newOwnerEOA must be non-zero address");