diff --git a/foundry.toml b/foundry.toml index 85f04e027c..ef66745531 100644 --- a/foundry.toml +++ b/foundry.toml @@ -20,7 +20,7 @@ remappings = [ '@solady/=lib/optimism/packages/contracts-bedrock/lib/solady/src/', '@lib-keccak/=lib/optimism/packages/contracts-bedrock/lib/lib-keccak/contracts/lib/', 'ds-test/=lib/optimism/packages/contracts-bedrock/lib/forge-std/lib/ds-test/src', - 'forge-std/=lib/forge-std/src/', + 'forge-std/=lib/forge-std/src/' ] [profile.ci] diff --git a/src/improvements/template/L1PortalExecuteL2Call.sol b/src/improvements/template/L1PortalExecuteL2Call.sol new file mode 100644 index 0000000000..a2968cd49d --- /dev/null +++ b/src/improvements/template/L1PortalExecuteL2Call.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +import {VmSafe} from "forge-std/Vm.sol"; +import {stdToml} from "forge-std/StdToml.sol"; + +import {MultisigTaskPrinter} from "../../libraries/MultisigTaskPrinter.sol"; +import {Action} from "../../libraries/MultisigTypes.sol"; +import {SimpleTaskBase} from "../tasks/types/SimpleTaskBase.sol"; + +/// @notice Interface for the OptimismPortal2 contract on L1. +interface IOptimismPortal2 { + function depositTransaction( + address _to, + uint256 _value, + uint64 _gasLimit, + bool _isCreation, + bytes memory _data + ) + external + payable; +} + +/// @notice Template to execute an L2 call via the L1 Optimism Portal from a nested L1 Safe. +/// Sends an L2 transaction using OptimismPortal.depositTransaction with config-driven params. +contract L1PortalExecuteL2Call is SimpleTaskBase { + using stdToml for string; + + // -------- Config inputs -------- + /// @notice The address of the OptimismPortal2 contract on L1. + address payable public portal; + /// @notice The address of the L2 target contract. + address public l2Target; + /// @notice The calldata to be executed on l2Target. + bytes public l2Data; + /// @notice The ETH value to forward to L2. + uint256 public valueWei; + /// @notice The L2 gas limit. + uint64 public gasLimit; + /// @notice Whether to create a contract on L2. + bool public isCreation; + + /// @notice Default Safe name. Can be overridden via `safeAddressString` in config.toml. + function safeAddressString() public pure override returns (string memory) { + return "ProxyAdminOwner"; + } + + /// @notice The contracts expected to have storage writes during execution. + /// Allowlist the OptimismPortal since it will mutate state (queue/event) on deposit. + function _taskStorageWrites() internal pure override returns (string[] memory) { + string[] memory _storageWrites = new string[](1); + _storageWrites[0] = "OptimismPortal"; + return _storageWrites; + } + + /// @notice The contracts expected to have balance changes during execution. + /// Allowlist the OptimismPortal to receive ETH (value) in the deposit call. + function _taskBalanceChanges() internal pure override returns (string[] memory) { + string[] memory _balanceChanges = new string[](1); + _balanceChanges[0] = "OptimismPortal"; + return _balanceChanges; + } + + /// @notice Parse config and initialize template variables. + /// Expected TOML keys: + /// - portal: address (L1 OptimismPortal) OR addresses.OptimismPortal in [addresses] + /// - l2Target: address (L2 target address) + /// - l2Data: hex string (e.g. 0x1234...) + /// - gasLimit: uint (will be cast to uint64) + /// - value: uint (optional, default 0) + /// - isCreation: bool (optional, default false) + function _templateSetup(string memory _taskConfigFilePath, address) internal override { + string memory _toml = vm.readFile(_taskConfigFilePath); + + // Resolve portal from registry first if available, else read explicit field. + try simpleAddrRegistry.get("OptimismPortal") returns (address p) { + portal = payable(p); + } catch { + portal = payable(_toml.readAddress(".portal")); + } + require(portal != address(0), "portal must be set (addresses.OptimismPortal or .portal)"); + + l2Target = _toml.readAddress(".l2Target"); + require(l2Target != address(0), "l2Target must be set"); + + // Read hex string and parse to bytes. + l2Data = _toml.readBytes(".l2Data"); + require(l2Data.length > 0, "l2Data must be set"); + + uint256 _gasLimitTmp = _toml.readUint(".gasLimit"); + require(_gasLimitTmp > 0 && _gasLimitTmp <= type(uint64).max, "invalid gasLimit"); + gasLimit = uint64(_gasLimitTmp); + + // Optional fields + valueWei = 0; + try vm.parseTomlUint(_toml, ".value") returns (uint256 _v) { + valueWei = _v; + } catch {} + + isCreation = false; + try vm.parseTomlBool(_toml, ".isCreation") returns (bool _b) { + isCreation = _b; + } catch {} + + // early revert in case of attempted contract creation with a non-zero target + require(isCreation && l2Target == address(0) || !isCreation, "contract creation requires zero target address"); + } + + /// @notice Build the portal deposit action. WARNING: State changes here are reverted after capture. + function _build(address) internal override { + // Record the L1 portal call with value for action extraction. + IOptimismPortal2(portal).depositTransaction{value: valueWei}(l2Target, valueWei, gasLimit, isCreation, l2Data); + } + + /// @notice Validate that exactly one action to the portal with the expected calldata and value was captured. + function _validate(VmSafe.AccountAccess[] memory, Action[] memory _actions, address) internal view override { + bytes memory _expected = abi.encodeCall( + IOptimismPortal2.depositTransaction, (l2Target, valueWei, gasLimit, isCreation, l2Data) + ); + + bool _found; + uint256 _matches; + for (uint256 _i = 0; _i < _actions.length; _i++) { + if (_actions[_i].target == portal && _actions[_i].value == valueWei) { + if (keccak256(_actions[_i].arguments) == keccak256(_expected)) { + _found = true; + _matches++; + } + } + } + require(_found && _matches == 1, "expected one portal deposit action"); + MultisigTaskPrinter.printTitle("Validated portal deposit action"); + } + + /// @notice No code exceptions required for this template. + function _getCodeExceptions() internal view override returns (address[] memory) {} +} diff --git a/test/tasks/Regression.t.sol b/test/tasks/Regression.t.sol index 1cfce30545..48278ece88 100644 --- a/test/tasks/Regression.t.sol +++ b/test/tasks/Regression.t.sol @@ -29,6 +29,7 @@ import {WelcomeToSuperchainOps} from "src/improvements/template/WelcomeToSuperch import {GnosisSafeRemoveOwner} from "src/improvements/template/GnosisSafeRemoveOwner.sol"; import {SetEIP1967Implementation} from "src/improvements/template/SetEIP1967Implementation.sol"; import {UnpauseSuperchainConfigV400} from "src/improvements/template/UnpauseSuperchainConfigV400.sol"; +import {L1PortalExecuteL2Call} from "src/improvements/template/L1PortalExecuteL2Call.sol"; import {UniFix} from "src/improvements/template/UniFix.sol"; import {DeputyPauseKeyRotationTemplate} from "src/improvements/template/DeputyPauseKeyRotationTemplate.sol"; import {BlacklistGamesV140} from "src/improvements/template/BlacklistGamesV140.sol"; @@ -678,6 +679,34 @@ contract RegressionTest is Test { _assertDataToSignNestedMultisig(multisigTask, actions, expectedDataToSign, MULTICALL3_ADDRESS, rootSafe); } + /// @notice Expected call data and data to sign generated by manually running the L1PortalExecuteL2CallUpgradeGovernor template at block 23197819 on mainnet. + /// Simulate from task directory (test/tasks/example/eth/014-noop-call-optimismportal/config.toml) with: + /// just --dotenv-path $(pwd)/.env --justfile ../../../../../src/improvements/justfile simulate (foundation|council) + function testRegressionCallDataMatches_L1PortalExecuteL2CallUpgradeGovernor() public { + string memory taskConfigFilePath = "test/tasks/example/eth/014-noop-call-optimismportal/config.toml"; + string memory expectedCallData = + "0x174dea71000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000beb5fc579115071764c7423a4f12edde41f106ed0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000104e9e05c42000000000000000000000000cdf27f107725988f2261ce2256bdfcde8b382b100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007a120000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000243659cfe6000000000000000000000000ecbf4ed9f47302f00f0f039a691e7db83bdd26240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; + MultisigTask multisigTask = new L1PortalExecuteL2Call(); + address rootSafe = address(0x5a0Aae59D09fccBdDb6C6CcEB07B7279367C3d2A); // L1PAO + address securityCouncilChildMultisig = address(0xc2819DC788505Aac350142A7A707BF9D03E3Bd03); + address[] memory allSafes = MultisigTaskTestHelper.getAllSafes(rootSafe, securityCouncilChildMultisig); + + (Action[] memory actions, uint256[] memory allOriginalNonces) = + _setupAndSimulate(taskConfigFilePath, 23197819, "mainnet", multisigTask, allSafes); + + _assertCallDataMatches(multisigTask, actions, allSafes, allOriginalNonces, expectedCallData); + + string[] memory expectedDataToSign = new string[](2); + // Foundation + expectedDataToSign[0] = + "0x1901a4a9c312badf3fcaa05eafe5dc9bee8bd9316c78ee8b0bebe3115bb21b73267229ea72d29d343d55ff76a6ce84cc8514d45683b4339b10bef5e956955bfe65c9"; + // Security Council + expectedDataToSign[1] = + "0x1901df53d510b56e539b90b369ef08fce3631020fbf921e3136ea5f8747c20bce9672b811a78d33f39e928848432a404247a2ab7c4a596b8586797a2e86b284b3b8b"; + + _assertDataToSignNestedMultisig(multisigTask, actions, expectedDataToSign, MULTICALL3_ADDRESS, rootSafe); + } + /// @notice Expected call data and data to sign generated by manually running the UniFix template at block 8029861 on sepolia. /// Simulate from task directory (test/tasks/example/sep/004-replace-superchain-config/config.toml) with: /// SIMULATE_WITHOUT_LEDGER=1 just --dotenv-path "$(pwd)"/.env --justfile ../../../../../src/improvements/justfile simulate diff --git a/test/tasks/example/eth/014-noop-call-optimismportal/.env b/test/tasks/example/eth/014-noop-call-optimismportal/.env new file mode 100644 index 0000000000..d4a7b76058 --- /dev/null +++ b/test/tasks/example/eth/014-noop-call-optimismportal/.env @@ -0,0 +1,3 @@ +TENDERLY_GAS=10000000 +NESTED_SAFE_NAME_DEPTH_1=council +FORK_BLOCK_NUMBER=23197819 \ No newline at end of file diff --git a/test/tasks/example/eth/014-noop-call-optimismportal/config.toml b/test/tasks/example/eth/014-noop-call-optimismportal/config.toml new file mode 100644 index 0000000000..3c3c71ca2f --- /dev/null +++ b/test/tasks/example/eth/014-noop-call-optimismportal/config.toml @@ -0,0 +1,13 @@ +templateName = "L1PortalExecuteL2Call" + +# Portal + L2 call params +portal = "0xbEb5Fc579115071764c7423A4f12eDde41f106Ed" # L1 OptimismPortal +l2Target = "0xcDF27F107725988f2261Ce2256bDfCdE8B382B10" # OptimismGovernor Proxy +l2Data = "0x3659cfe6000000000000000000000000ecbf4ed9f47302f00f0f039a691e7db83bdd2624" # upgradeTo(currentImpl) -> 0xecbf4ed9f47302f00f0f039a691e7db83bdd2624 +gasLimit = 500000 +value = 0 +isCreation = false + +[addresses] +ProxyAdminOwner = "0x5a0Aae59D09fccBdDb6C6CcEB07B7279367C3d2A" # 2-of-2 between council and foundation +OptimismPortal = "0xbEb5Fc579115071764c7423A4f12eDde41f106Ed" \ No newline at end of file