Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
128 changes: 128 additions & 0 deletions src/template/TransferL2PAOFromL1ToEOA.sol
Original file line number Diff line number Diff line change
@@ -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;
}
28 changes: 28 additions & 0 deletions test/tasks/Regression.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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/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";
// 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 <foundation|council>
Expand Down
2 changes: 2 additions & 0 deletions test/tasks/example/sep/027-transfer-l2pao-to-eoa/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
TENDERLY_GAS=25000000
FORK_BLOCK_NUMBER=9000000
Original file line number Diff line number Diff line change
@@ -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"