From b4c50aa984176b0f5022a5c81da43a2398396233 Mon Sep 17 00:00:00 2001 From: Leopold Joy Date: Mon, 5 Jan 2026 14:50:52 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20add=20MultisigScriptDeposit=20for?= =?UTF-8?q?=20L1=E2=86=92L2=20deposit=20transaction=20wrapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- script/universal/MultisigScriptDeposit.sol | 153 ++++++++++ test/universal/MultisigScriptDeposit.t.sol | 307 +++++++++++++++++++++ 2 files changed, 460 insertions(+) create mode 100644 script/universal/MultisigScriptDeposit.sol create mode 100644 test/universal/MultisigScriptDeposit.t.sol diff --git a/script/universal/MultisigScriptDeposit.sol b/script/universal/MultisigScriptDeposit.sol new file mode 100644 index 0000000..5030ee7 --- /dev/null +++ b/script/universal/MultisigScriptDeposit.sol @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {IMulticall3} from "lib/forge-std/src/interfaces/IMulticall3.sol"; + +import {MultisigScript} from "./MultisigScript.sol"; + +/// @notice Interface for OptimismPortal2's depositTransaction function +interface IOptimismPortal2 { + /// @notice Creates a deposit transaction on L2 + /// @param _to Target address on L2 + /// @param _value ETH value to send with the transaction + /// @param _gasLimit Minimum gas limit for L2 execution + /// @param _isCreation Whether the transaction creates a contract + /// @param _data Calldata for the L2 transaction + function depositTransaction(address _to, uint256 _value, uint64 _gasLimit, bool _isCreation, bytes memory _data) + external + payable; +} + +/// @title MultisigScriptDeposit +/// @notice Extension of MultisigScript for L1 → L2 deposit transactions. +/// +/// @dev This contract simplifies the creation of L1 multisig transactions that trigger actions on L2 +/// via the OptimismPortal's depositTransaction mechanism. Task writers only need to define the +/// L2 calls they want to execute; this contract handles wrapping them in the appropriate +/// depositTransaction call automatically. +/// +/// Example usage: +/// ```solidity +/// contract MyL2Task is MultisigScriptDeposit { +/// function _optimismPortal() internal view override returns (address) { +/// return vm.envAddress("L1_PORTAL"); +/// } +/// +/// function _l2GasLimit() internal view override returns (uint64) { +/// return 200_000; // Estimated gas for L2 execution +/// } +/// +/// function _ownerSafe() internal view override returns (address) { +/// return vm.envAddress("OWNER_SAFE"); +/// } +/// +/// function _buildL2Calls() internal view override returns (IMulticall3.Call3Value[] memory) { +/// IMulticall3.Call3Value[] memory calls = new IMulticall3.Call3Value[](1); +/// calls[0] = IMulticall3.Call3Value({ +/// target: L2_CONTRACT, +/// allowFailure: false, +/// callData: abi.encodeCall(IL2Contract.someFunction, (arg1, arg2)), +/// value: 0 +/// }); +/// return calls; +/// } +/// } +/// ``` +/// +/// @dev Future Enhancements: +/// 1. L2 Post-Check Hook: Currently, `_postCheck` runs on L1 and cannot verify L2 state changes. +/// A future enhancement could add an `_postCheckL2` hook that forks L2 state and simulates +/// the deposit transaction's effect. This is non-trivial because deposit transactions are +/// not immediately reflected on L2. +/// +/// 2. Per-Call Gas Estimation: Currently, `_l2GasLimit` is a single value covering the entire +/// L2 multicall. A future enhancement could allow each L2 call to specify its own gas +/// requirement, with the total being calculated automatically. +abstract contract MultisigScriptDeposit is MultisigScript { + ////////////////////////////////////////////////////////////////////////////////////// + /// Virtual Functions /// + ////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Returns the OptimismPortal address on L1 + /// @dev This is the portal contract that will be called to initiate the L2 deposit + function _optimismPortal() internal view virtual returns (address); + + /// @notice Returns the minimum gas limit for L2 execution + /// @dev Task writers must estimate the gas required for their L2 calls to execute. + /// This value should account for: + /// - Gas for the CBMulticall.aggregate3Value call overhead + /// - Gas for each individual L2 call + /// - A safety margin for any unexpected gas consumption + /// + /// If the gas limit is too low, the L2 transaction will fail but the deposit + /// will still be recorded (ETH will be stuck until manually recovered). + /// + /// Common starting points: + /// - Single simple call: 100,000 - 200,000 + /// - Multiple calls or complex operations: 500,000+ + /// + /// Future enhancement: This could be expanded to calculate gas automatically + /// from per-call gas estimates provided in an extended Call3Value struct. + function _l2GasLimit() internal view virtual returns (uint64); + + /// @notice Build the calls that will be executed on L2 + /// @dev Task writers implement this to define what actions should occur on L2. + /// These calls will be batched into a single CBMulticall.aggregate3Value call + /// and wrapped in a depositTransaction to the OptimismPortal. + /// + /// The `value` field in each Call3Value struct specifies ETH to send with that + /// specific L2 call. The total ETH will be bridged via the deposit transaction. + /// @return calls Array of calls to execute on L2 via CBMulticall + function _buildL2Calls() internal view virtual returns (IMulticall3.Call3Value[] memory); + + ////////////////////////////////////////////////////////////////////////////////////// + /// Overridden Functions /// + ////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Wraps L2 calls in a depositTransaction to the OptimismPortal + /// @dev Task writers should NOT override this function. Instead, implement `_buildL2Calls` + /// to define the L2 operations. This function handles the L1 deposit wrapping automatically. + /// + /// The L2 calls are encoded as a CBMulticall.aggregate3Value call, which is then + /// passed as the data payload to OptimismPortal.depositTransaction. This allows + /// multiple L2 operations to be batched into a single deposit transaction. + /// + /// ETH bridging: If any L2 calls include a non-zero `value`, the total ETH is + /// summed and sent with the deposit transaction. The CBMulticall.aggregate3Value + /// function on L2 automatically distributes the ETH to each call according to its + /// specified `value` field - no additional developer action is required. + function _buildCalls() internal view virtual override returns (IMulticall3.Call3Value[] memory) { + IMulticall3.Call3Value[] memory l2Calls = _buildL2Calls(); + + // Sum ETH values from L2 calls for bridging + uint256 totalValue = 0; + for (uint256 i; i < l2Calls.length; i++) { + totalValue += l2Calls[i].value; + } + + // Encode L2 calls as a multicall + // Note: We use aggregate3Value to support per-call ETH distribution on L2 + bytes memory l2Data = abi.encodeCall(IMulticall3.aggregate3Value, (l2Calls)); + + // Wrap in depositTransaction call to OptimismPortal + IMulticall3.Call3Value[] memory l1Calls = new IMulticall3.Call3Value[](1); + l1Calls[0] = IMulticall3.Call3Value({ + target: _optimismPortal(), + allowFailure: false, + callData: abi.encodeCall( + IOptimismPortal2.depositTransaction, + ( + CB_MULTICALL, // L2 target: CBMulticall at same address on L2 + totalValue, // ETH to bridge + _l2GasLimit(), // Gas limit for L2 execution + false, // Not a contract creation + l2Data // Encoded multicall + ) + ), + value: totalValue + }); + + return l1Calls; + } +} + diff --git a/test/universal/MultisigScriptDeposit.t.sol b/test/universal/MultisigScriptDeposit.t.sol new file mode 100644 index 0000000..94eb39d --- /dev/null +++ b/test/universal/MultisigScriptDeposit.t.sol @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +import {IMulticall3} from "forge-std/interfaces/IMulticall3.sol"; +import {Test} from "forge-std/Test.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {Preinstalls} from "lib/optimism/packages/contracts-bedrock/src/libraries/Preinstalls.sol"; + +import {MultisigScriptDeposit, IOptimismPortal2} from "script/universal/MultisigScriptDeposit.sol"; +import {Simulation} from "script/universal/Simulation.sol"; +import {IGnosisSafe} from "script/universal/IGnosisSafe.sol"; + +import {Counter} from "test/universal/Counter.sol"; + +/// @notice Mock OptimismPortal for testing deposit transactions +contract MockOptimismPortal { + event TransactionDeposited(address indexed from, address indexed to, uint256 value, bytes data); + + /// @notice Records the deposit transaction parameters for verification + address public lastTo; + uint256 public lastValue; + uint64 public lastGasLimit; + bool public lastIsCreation; + bytes public lastData; + uint256 public depositCount; + + function depositTransaction(address _to, uint256 _value, uint64 _gasLimit, bool _isCreation, bytes memory _data) + external + payable + { + require(msg.value == _value, "MockPortal: value mismatch"); + + lastTo = _to; + lastValue = _value; + lastGasLimit = _gasLimit; + lastIsCreation = _isCreation; + lastData = _data; + depositCount++; + + emit TransactionDeposited(msg.sender, _to, _value, _data); + } +} + +contract MultisigScriptDepositTest is Test, MultisigScriptDeposit { + Vm.Wallet internal wallet1 = vm.createWallet("1"); + Vm.Wallet internal wallet2 = vm.createWallet("2"); + + address internal safe = address(1001); + MockOptimismPortal internal portal; + Counter internal l2Counter; + + // Test configuration + address internal testL2Target; + uint64 internal testGasLimit = 200_000; + + function() internal view returns (IMulticall3.Call3Value[] memory) buildL2CallsInternal; + + function setUp() public { + // Deploy mock portal + portal = new MockOptimismPortal(); + + // Deploy a counter to use as L2 target (for calldata encoding) + l2Counter = new Counter(address(this)); + testL2Target = address(l2Counter); + + // Setup Safe + vm.etch(safe, Preinstalls.getDeployedCode(Preinstalls.Safe_v130, block.chainid)); + vm.etch(Preinstalls.MultiCall3, Preinstalls.getDeployedCode(Preinstalls.MultiCall3, block.chainid)); + vm.deal(safe, 100 ether); + + address[] memory owners = new address[](2); + owners[0] = wallet1.addr; + owners[1] = wallet2.addr; + IGnosisSafe(safe).setup(owners, 2, address(0), "", address(0), address(0), 0, address(0)); + } + + ////////////////////////////////////////////////////////////////////////////////////// + /// MultisigScriptDeposit Overrides /// + ////////////////////////////////////////////////////////////////////////////////////// + + function _optimismPortal() internal view override returns (address) { + return address(portal); + } + + function _l2GasLimit() internal view override returns (uint64) { + return testGasLimit; + } + + function _ownerSafe() internal view override returns (address) { + return safe; + } + + function _buildL2Calls() internal view override returns (IMulticall3.Call3Value[] memory) { + return buildL2CallsInternal(); + } + + function _postCheck(Vm.AccountAccess[] memory, Simulation.Payload memory) internal view override { + // Verify deposit was made to portal + require(portal.depositCount() > 0, "No deposit made"); + } + + ////////////////////////////////////////////////////////////////////////////////////// + /// Tests /// + ////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Test that a single L2 call is correctly wrapped in depositTransaction + function test_buildCalls_singleL2Call_noValue() external { + buildL2CallsInternal = _buildSingleL2CallNoValue; + + IMulticall3.Call3Value[] memory calls = _buildCalls(); + + // Should produce exactly one L1 call to the portal + assertEq(calls.length, 1, "Should have one L1 call"); + assertEq(calls[0].target, address(portal), "Target should be portal"); + assertEq(calls[0].value, 0, "Value should be 0"); + assertFalse(calls[0].allowFailure, "Should not allow failure"); + + // Decode the depositTransaction call + (address to, uint256 value, uint64 gasLimit, bool isCreation, bytes memory data) = + _decodeDepositTransaction(calls[0].callData); + + assertEq(to, CB_MULTICALL, "L2 target should be CB_MULTICALL"); + assertEq(value, 0, "Bridged value should be 0"); + assertEq(gasLimit, testGasLimit, "Gas limit should match"); + assertFalse(isCreation, "Should not be creation"); + assertTrue(data.length > 0, "Data should not be empty"); + + // Verify the L2 data is an aggregate3Value call + bytes4 selector = bytes4(data); + assertEq(selector, IMulticall3.aggregate3Value.selector, "Should be aggregate3Value call"); + } + + /// @notice Test that multiple L2 calls are batched correctly + function test_buildCalls_multipleL2Calls_noValue() external { + buildL2CallsInternal = _buildMultipleL2CallsNoValue; + + IMulticall3.Call3Value[] memory calls = _buildCalls(); + + // Should still produce exactly one L1 call + assertEq(calls.length, 1, "Should have one L1 call"); + + // Decode and verify + (address to, uint256 value, uint64 gasLimit, bool isCreation, bytes memory data) = + _decodeDepositTransaction(calls[0].callData); + + assertEq(to, CB_MULTICALL, "L2 target should be CB_MULTICALL"); + assertEq(value, 0, "Bridged value should be 0"); + assertEq(gasLimit, testGasLimit, "Gas limit should match"); + assertFalse(isCreation, "Should not be creation"); + + // Decode the aggregate3Value call to verify multiple L2 calls are included + IMulticall3.Call3Value[] memory l2Calls = abi.decode(_stripSelector(data), (IMulticall3.Call3Value[])); + assertEq(l2Calls.length, 3, "Should have 3 L2 calls"); + + // Verify call parameters are preserved through the wrapping + assertEq(l2Calls[0].target, testL2Target, "First call target should be preserved"); + assertEq(l2Calls[1].target, testL2Target, "Second call target should be preserved"); + assertEq(l2Calls[2].target, testL2Target, "Third call target should be preserved"); + + // Verify allowFailure flags are preserved (first two are false, third is true) + assertFalse(l2Calls[0].allowFailure, "First call allowFailure should be false"); + assertFalse(l2Calls[1].allowFailure, "Second call allowFailure should be false"); + assertTrue(l2Calls[2].allowFailure, "Third call allowFailure should be true (preserved)"); + } + + /// @notice Test that ETH values are correctly summed and bridged + function test_buildCalls_withValue() external { + buildL2CallsInternal = _buildL2CallsWithValue; + + IMulticall3.Call3Value[] memory calls = _buildCalls(); + + // Value should be sum of all L2 call values (1 + 2 + 0.5 = 3.5 ether) + assertEq(calls[0].value, 3.5 ether, "L1 call value should be sum of L2 values"); + + // Decode and verify bridged value + (, uint256 value,,,) = _decodeDepositTransaction(calls[0].callData); + assertEq(value, 3.5 ether, "Bridged value should be 3.5 ether"); + } + + /// @notice Test that single L2 call still goes through multicall (consistent behavior) + function test_buildCalls_singleCallStillUsesMulticall() external { + buildL2CallsInternal = _buildSingleL2CallNoValue; + + IMulticall3.Call3Value[] memory calls = _buildCalls(); + (,,,, bytes memory data) = _decodeDepositTransaction(calls[0].callData); + + // Even single calls should be wrapped in aggregate3Value for consistency + bytes4 selector = bytes4(data); + assertEq(selector, IMulticall3.aggregate3Value.selector, "Single call should still use aggregate3Value"); + } + + /// @notice Test the full sign flow with deposit transaction + function test_sign_depositTransaction() external { + buildL2CallsInternal = _buildSingleL2CallNoValue; + + vm.recordLogs(); + bytes memory txData = abi.encodeWithSelector(this.sign.selector, new address[](0)); + vm.prank(wallet1.addr); + (bool success,) = address(this).call(txData); + assertTrue(success, "Sign should succeed"); + + // Verify DataToSign event was emitted + Vm.Log[] memory logs = vm.getRecordedLogs(); + bool foundDataToSign = false; + for (uint256 i; i < logs.length; i++) { + if (logs[i].topics[0] == keccak256("DataToSign(bytes)")) { + foundDataToSign = true; + break; + } + } + assertTrue(foundDataToSign, "DataToSign event should be emitted"); + } + + /// @notice Test sign flow with ETH value + function test_sign_depositTransaction_withValue() external { + buildL2CallsInternal = _buildL2CallsWithValue; + + vm.recordLogs(); + bytes memory txData = abi.encodeWithSelector(this.sign.selector, new address[](0)); + vm.prank(wallet1.addr); + (bool success,) = address(this).call(txData); + assertTrue(success, "Sign with value should succeed"); + } + + ////////////////////////////////////////////////////////////////////////////////////// + /// Helper Functions /// + ////////////////////////////////////////////////////////////////////////////////////// + + function _buildSingleL2CallNoValue() internal view returns (IMulticall3.Call3Value[] memory) { + IMulticall3.Call3Value[] memory calls = new IMulticall3.Call3Value[](1); + calls[0] = IMulticall3.Call3Value({ + target: testL2Target, + allowFailure: false, + callData: abi.encodeCall(Counter.increment, ()), + value: 0 + }); + return calls; + } + + function _buildMultipleL2CallsNoValue() internal view returns (IMulticall3.Call3Value[] memory) { + IMulticall3.Call3Value[] memory calls = new IMulticall3.Call3Value[](3); + calls[0] = IMulticall3.Call3Value({ + target: testL2Target, + allowFailure: false, + callData: abi.encodeCall(Counter.increment, ()), + value: 0 + }); + calls[1] = IMulticall3.Call3Value({ + target: testL2Target, + allowFailure: false, + callData: abi.encodeCall(Counter.increment, ()), + value: 0 + }); + calls[2] = IMulticall3.Call3Value({ + target: testL2Target, + allowFailure: true, // Test allowFailure flag preservation + callData: abi.encodeCall(Counter.increment, ()), + value: 0 + }); + return calls; + } + + function _buildL2CallsWithValue() internal view returns (IMulticall3.Call3Value[] memory) { + IMulticall3.Call3Value[] memory calls = new IMulticall3.Call3Value[](3); + calls[0] = IMulticall3.Call3Value({ + target: testL2Target, + allowFailure: false, + callData: abi.encodeCall(Counter.incrementPayable, ()), + value: 1 ether + }); + calls[1] = IMulticall3.Call3Value({ + target: testL2Target, + allowFailure: false, + callData: abi.encodeCall(Counter.incrementPayable, ()), + value: 2 ether + }); + calls[2] = IMulticall3.Call3Value({ + target: testL2Target, + allowFailure: false, + callData: abi.encodeCall(Counter.incrementPayable, ()), + value: 0.5 ether + }); + return calls; + } + + /// @notice Decode depositTransaction calldata + function _decodeDepositTransaction(bytes memory callData) + internal + pure + returns (address to, uint256 value, uint64 gasLimit, bool isCreation, bytes memory data) + { + // Skip the 4-byte selector + bytes memory params = _stripSelector(callData); + (to, value, gasLimit, isCreation, data) = abi.decode(params, (address, uint256, uint64, bool, bytes)); + } + + /// @notice Strip the 4-byte function selector from calldata + function _stripSelector(bytes memory data) internal pure returns (bytes memory) { + require(data.length >= 4, "Data too short"); + bytes memory result = new bytes(data.length - 4); + for (uint256 i = 4; i < data.length; i++) { + result[i - 4] = data[i]; + } + return result; + } +} + From 7e3633caebdac9dd4431fadaa799a3a69ab22a03 Mon Sep 17 00:00:00 2001 From: Leopold Joy Date: Mon, 5 Jan 2026 14:57:49 +0000 Subject: [PATCH 2/6] chore: run forge fmt --- test/universal/MultisigScriptDeposit.t.sol | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/test/universal/MultisigScriptDeposit.t.sol b/test/universal/MultisigScriptDeposit.t.sol index 94eb39d..8ce482e 100644 --- a/test/universal/MultisigScriptDeposit.t.sol +++ b/test/universal/MultisigScriptDeposit.t.sol @@ -229,10 +229,7 @@ contract MultisigScriptDepositTest is Test, MultisigScriptDeposit { function _buildSingleL2CallNoValue() internal view returns (IMulticall3.Call3Value[] memory) { IMulticall3.Call3Value[] memory calls = new IMulticall3.Call3Value[](1); calls[0] = IMulticall3.Call3Value({ - target: testL2Target, - allowFailure: false, - callData: abi.encodeCall(Counter.increment, ()), - value: 0 + target: testL2Target, allowFailure: false, callData: abi.encodeCall(Counter.increment, ()), value: 0 }); return calls; } @@ -240,16 +237,10 @@ contract MultisigScriptDepositTest is Test, MultisigScriptDeposit { function _buildMultipleL2CallsNoValue() internal view returns (IMulticall3.Call3Value[] memory) { IMulticall3.Call3Value[] memory calls = new IMulticall3.Call3Value[](3); calls[0] = IMulticall3.Call3Value({ - target: testL2Target, - allowFailure: false, - callData: abi.encodeCall(Counter.increment, ()), - value: 0 + target: testL2Target, allowFailure: false, callData: abi.encodeCall(Counter.increment, ()), value: 0 }); calls[1] = IMulticall3.Call3Value({ - target: testL2Target, - allowFailure: false, - callData: abi.encodeCall(Counter.increment, ()), - value: 0 + target: testL2Target, allowFailure: false, callData: abi.encodeCall(Counter.increment, ()), value: 0 }); calls[2] = IMulticall3.Call3Value({ target: testL2Target, From dcf22105807a64ab0b62fbc518b4dba5cfa9e8af Mon Sep 17 00:00:00 2001 From: Leopold Joy Date: Tue, 13 Jan 2026 18:18:54 +0000 Subject: [PATCH 3/6] feat: adjust MultisigScriptDeposit to support automatic gas estimation --- script/universal/MultisigScript.sol | 8 +- script/universal/MultisigScriptDeposit.sol | 208 +++++++++++++++--- script/universal/README.md | 20 ++ test/universal/MultisigScriptDeposit.t.sol | 48 ++++ .../MultisigScriptDepositIntegration.t.sol | 70 ++++++ 5 files changed, 319 insertions(+), 35 deletions(-) create mode 100644 test/universal/MultisigScriptDepositIntegration.t.sol diff --git a/script/universal/MultisigScript.sol b/script/universal/MultisigScript.sol index 966e63b..899039a 100644 --- a/script/universal/MultisigScript.sol +++ b/script/universal/MultisigScript.sol @@ -219,7 +219,7 @@ abstract contract MultisigScript is Script { /// multisig (see step 2). /// /// @param safes A list of nested safes (excluding the executing safe returned by `_ownerSafe`). - function sign(address[] memory safes) public { + function sign(address[] memory safes) public virtual { safes = _appendOwnerSafe({safes: safes}); // Snapshot and restore Safe nonce after simulation, otherwise the data logged to sign @@ -278,7 +278,7 @@ abstract contract MultisigScript is Script { /// /// @param safes A list of nested safes (excluding the executing safe returned by `_ownerSafe`). /// @param signatures The signatures from step 1 (concatenated, 65-bytes per sig) - function approve(address[] memory safes, bytes memory signatures) public { + function approve(address[] memory safes, bytes memory signatures) public virtual { safes = _appendOwnerSafe({safes: safes}); (bytes[] memory datas, uint256 value) = _transactionDatas({safes: safes}); (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = @@ -295,7 +295,7 @@ abstract contract MultisigScript is Script { /// Differs from `run` in that you can override the safe nonce for simulation purposes. /// /// @param signatures The signatures from step 1 (concatenated, 65-bytes per sig) - function simulate(bytes memory signatures) public { + function simulate(bytes memory signatures) public virtual { address ownerSafe = _ownerSafe(); (bytes[] memory datas, uint256 value) = _transactionDatas({safes: _toArray(ownerSafe)}); @@ -316,7 +316,7 @@ abstract contract MultisigScript is Script { /// submitted onchain (nested case, see step 2, in which case `signatures` can be empty). /// /// @param signatures The signatures from step 1 (concatenated, 65-bytes per sig) - function run(bytes memory signatures) public { + function run(bytes memory signatures) public virtual { address ownerSafe = _ownerSafe(); (bytes[] memory datas, uint256 value) = _transactionDatas({safes: _toArray(ownerSafe)}); diff --git a/script/universal/MultisigScriptDeposit.sol b/script/universal/MultisigScriptDeposit.sol index 5030ee7..fb8d801 100644 --- a/script/universal/MultisigScriptDeposit.sol +++ b/script/universal/MultisigScriptDeposit.sol @@ -29,14 +29,6 @@ interface IOptimismPortal2 { /// Example usage: /// ```solidity /// contract MyL2Task is MultisigScriptDeposit { -/// function _optimismPortal() internal view override returns (address) { -/// return vm.envAddress("L1_PORTAL"); -/// } -/// -/// function _l2GasLimit() internal view override returns (uint64) { -/// return 200_000; // Estimated gas for L2 execution -/// } -/// /// function _ownerSafe() internal view override returns (address) { /// return vm.envAddress("OWNER_SAFE"); /// } @@ -54,41 +46,88 @@ interface IOptimismPortal2 { /// } /// ``` /// +/// The example above uses default implementations for `_optimismPortal()` (chain-based) and +/// `_l2GasLimit()` (automatic estimation via L2 fork). Task writers can override these if needed. +/// /// @dev Future Enhancements: /// 1. L2 Post-Check Hook: Currently, `_postCheck` runs on L1 and cannot verify L2 state changes. /// A future enhancement could add an `_postCheckL2` hook that forks L2 state and simulates /// the deposit transaction's effect. This is non-trivial because deposit transactions are /// not immediately reflected on L2. /// -/// 2. Per-Call Gas Estimation: Currently, `_l2GasLimit` is a single value covering the entire -/// L2 multicall. A future enhancement could allow each L2 call to specify its own gas -/// requirement, with the total being calculated automatically. +/// 2. Per-Call Gas Hints: With automatic gas estimation via L2 fork, per-call gas hints are +/// less critical. However, they could still be useful when L2 RPC is unavailable or when +/// task writers want manual control without overriding `_l2GasLimit()` entirely. +/// +/// @dev Testing Note: +/// Unit tests override `_l2GasLimit()` directly to avoid external dependencies. The L2 gas +/// estimation mechanism is tested via an integration test that requires `L2_RPC_URL`: +/// `L2_RPC_URL= forge test --match-test test_integration_gasEstimation -vvv` +/// This integration test is automatically skipped in CI when `L2_RPC_URL` is not set. abstract contract MultisigScriptDeposit is MultisigScript { + ////////////////////////////////////////////////////////////////////////////////////// + /// Constants /// + ////////////////////////////////////////////////////////////////////////////////////// + + /// @notice OptimismPortalProxy address on L1 Mainnet (for Base Mainnet) + address internal constant OPTIMISM_PORTAL_MAINNET = 0x49048044D57e1C92A77f79988d21Fa8fAF74E97e; + + /// @notice OptimismPortalProxy address on L1 Sepolia (for Base Sepolia) + address internal constant OPTIMISM_PORTAL_SEPOLIA = 0x49f53e41452C74589E85cA1677426Ba426459e85; + + /// @notice Gas estimation safety buffer (50% overhead) + /// @dev Applied to the estimated gas to account for variations in execution + uint256 internal constant GAS_ESTIMATION_BUFFER_PERCENT = 150; + + ////////////////////////////////////////////////////////////////////////////////////// + /// State Variables /// + ////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Cached L2 gas limit from estimation + uint64 private _cachedL2GasLimit; + + /// @notice Whether the L2 gas limit has been cached + bool private _l2GasLimitCached; + ////////////////////////////////////////////////////////////////////////////////////// /// Virtual Functions /// ////////////////////////////////////////////////////////////////////////////////////// /// @notice Returns the OptimismPortal address on L1 - /// @dev This is the portal contract that will be called to initiate the L2 deposit - function _optimismPortal() internal view virtual returns (address); + /// @dev Default implementation returns the correct address based on chain ID. + /// Supports L1 Mainnet (chain 1) and L1 Sepolia (chain 11155111). + /// Override this function for other chains or custom portal addresses. + function _optimismPortal() internal view virtual returns (address) { + if (block.chainid == 1) { + return OPTIMISM_PORTAL_MAINNET; + } else if (block.chainid == 11155111) { + return OPTIMISM_PORTAL_SEPOLIA; + } + revert("MultisigScriptDeposit: unsupported chain, override _optimismPortal()"); + } /// @notice Returns the minimum gas limit for L2 execution - /// @dev Task writers must estimate the gas required for their L2 calls to execute. - /// This value should account for: - /// - Gas for the CBMulticall.aggregate3Value call overhead - /// - Gas for each individual L2 call - /// - A safety margin for any unexpected gas consumption + /// @dev Default implementation estimates gas by forking L2 and simulating the call. + /// Requires the `L2_RPC_URL` environment variable to be set. /// - /// If the gas limit is too low, the L2 transaction will fail but the deposit - /// will still be recorded (ETH will be stuck until manually recovered). + /// To manually specify a gas limit instead of using automatic estimation, + /// override this function in your task contract: + /// ```solidity + /// function _l2GasLimit() internal view override returns (uint64) { + /// return 200_000; // Your estimated gas limit + /// } + /// ``` /// - /// Common starting points: + /// Common gas limit starting points: /// - Single simple call: 100,000 - 200,000 /// - Multiple calls or complex operations: 500,000+ /// - /// Future enhancement: This could be expanded to calculate gas automatically - /// from per-call gas estimates provided in an extended Call3Value struct. - function _l2GasLimit() internal view virtual returns (uint64); + /// If the gas limit is too low, the L2 transaction will fail but the deposit + /// will still be recorded (ETH may be stuck until manually recovered). + function _l2GasLimit() internal view virtual returns (uint64) { + require(_l2GasLimitCached, "MultisigScriptDeposit: L2 gas limit not estimated, ensure L2_RPC_URL is set"); + return _cachedL2GasLimit; + } /// @notice Build the calls that will be executed on L2 /// @dev Task writers implement this to define what actions should occur on L2. @@ -100,6 +139,34 @@ abstract contract MultisigScriptDeposit is MultisigScript { /// @return calls Array of calls to execute on L2 via CBMulticall function _buildL2Calls() internal view virtual returns (IMulticall3.Call3Value[] memory); + ////////////////////////////////////////////////////////////////////////////////////// + /// Overridden Entry Points /// + ////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Override sign to ensure L2 gas is estimated before building calls + function sign(address[] memory safes) public virtual override { + _ensureL2GasLimitCached(); + super.sign(safes); + } + + /// @notice Override approve to ensure L2 gas is estimated before building calls + function approve(address[] memory safes, bytes memory signatures) public virtual override { + _ensureL2GasLimitCached(); + super.approve(safes, signatures); + } + + /// @notice Override simulate to ensure L2 gas is estimated before building calls + function simulate(bytes memory signatures) public virtual override { + _ensureL2GasLimitCached(); + super.simulate(signatures); + } + + /// @notice Override run to ensure L2 gas is estimated before building calls + function run(bytes memory signatures) public virtual override { + _ensureL2GasLimitCached(); + super.run(signatures); + } + ////////////////////////////////////////////////////////////////////////////////////// /// Overridden Functions /// ////////////////////////////////////////////////////////////////////////////////////// @@ -118,12 +185,7 @@ abstract contract MultisigScriptDeposit is MultisigScript { /// specified `value` field - no additional developer action is required. function _buildCalls() internal view virtual override returns (IMulticall3.Call3Value[] memory) { IMulticall3.Call3Value[] memory l2Calls = _buildL2Calls(); - - // Sum ETH values from L2 calls for bridging - uint256 totalValue = 0; - for (uint256 i; i < l2Calls.length; i++) { - totalValue += l2Calls[i].value; - } + uint256 totalValue = _sumL2CallValues(l2Calls); // Encode L2 calls as a multicall // Note: We use aggregate3Value to support per-call ETH distribution on L2 @@ -149,5 +211,89 @@ abstract contract MultisigScriptDeposit is MultisigScript { return l1Calls; } -} + ////////////////////////////////////////////////////////////////////////////////////// + /// Internal Functions /// + ////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Ensures the L2 gas limit is cached before building calls + /// @dev Called by overridden entry points (sign, run, etc.) to trigger estimation. + /// If you override `_l2GasLimit()` to return a fixed value, you should also + /// override this function to be a no-op to skip the L2_RPC_URL requirement. + function _ensureL2GasLimitCached() internal virtual { + if (_l2GasLimitCached) return; + + // Get L2 RPC URL for forking + string memory l2RpcUrl; + try vm.envString("L2_RPC_URL") returns (string memory url) { + l2RpcUrl = url; + } catch { + revert( + "MultisigScriptDeposit: L2_RPC_URL env var required for gas estimation. " + "Alternatively, override _l2GasLimit() to specify a manual gas limit." + ); + } + + // Estimate gas via L2 fork + _cachedL2GasLimit = _estimateL2GasViaFork(l2RpcUrl); + _l2GasLimitCached = true; + } + + /// @notice Estimates L2 gas by forking the L2 chain and simulating the multicall + /// @param l2RpcUrl The RPC URL of the L2 chain to fork + /// @return estimatedGas The estimated gas limit with safety buffer applied + function _estimateL2GasViaFork(string memory l2RpcUrl) internal returns (uint64) { + // Build L2 call data + IMulticall3.Call3Value[] memory l2Calls = _buildL2Calls(); + bytes memory l2Data = abi.encodeCall(IMulticall3.aggregate3Value, (l2Calls)); + uint256 totalValue = _sumL2CallValues(l2Calls); + + // Store current fork (if any) to restore later + uint256 originalFork; + bool hadActiveFork; + try vm.activeFork() returns (uint256 forkId) { + originalFork = forkId; + hadActiveFork = true; + } catch { + hadActiveFork = false; + } + + // Create and select L2 fork + uint256 l2Fork = vm.createFork(l2RpcUrl); + vm.selectFork(l2Fork); + + // Fund the CBMulticall address if we need ETH for the simulation + if (totalValue > 0) { + vm.deal(CB_MULTICALL, totalValue); + } + + // Measure gas for the L2 call + uint256 gasBefore = gasleft(); + (bool success,) = CB_MULTICALL.call{value: totalValue}(l2Data); + uint256 gasUsed = gasBefore - gasleft(); + + // Restore original fork if there was one + if (hadActiveFork) { + vm.selectFork(originalFork); + } + + require(success, "MultisigScriptDeposit: L2 gas estimation failed, call reverted"); + + // Apply safety buffer and return + uint256 estimatedGas = (gasUsed * GAS_ESTIMATION_BUFFER_PERCENT) / 100; + + // Ensure we don't overflow uint64 + require(estimatedGas <= type(uint64).max, "MultisigScriptDeposit: estimated gas exceeds uint64"); + + return uint64(estimatedGas); + } + + /// @notice Sums the ETH values from an array of L2 calls + /// @param l2Calls The array of L2 calls to sum values from + /// @return total The total ETH value across all calls + function _sumL2CallValues(IMulticall3.Call3Value[] memory l2Calls) internal pure returns (uint256 total) { + for (uint256 i; i < l2Calls.length; i++) { + total += l2Calls[i].value; + } + } +} diff --git a/script/universal/README.md b/script/universal/README.md index a67e8a0..1585923 100644 --- a/script/universal/README.md +++ b/script/universal/README.md @@ -14,6 +14,14 @@ This is the core script for building Forge scripts that interact with Gnosis Saf - **Workflows**: Provides standard functions for `sign` (generating signatures), `approve` (onchain approval for nested Safes), `simulate` (dry-run with state overrides), and `run` (execution). - **Simulation**: Integrates with `Simulation.sol` to provide detailed simulation links and state diffs. +### `MultisigScriptDeposit.sol` + +An extension of `MultisigScript` for L1 → L2 deposit transactions. Task writers define L2 calls via `_buildL2Calls()`, and the framework automatically wraps them in an `OptimismPortal.depositTransaction` call. Features: + +- **Automatic Gas Estimation**: Forks L2 to estimate gas (requires `L2_RPC_URL` env var). +- **ETH Bridging**: Supports bridging ETH along with the message. +- **Chain-Aware Defaults**: Provides default `OptimismPortal` addresses for Mainnet and Sepolia. + ### `Simulation.sol` A library for simulating multisig transactions with state overrides. It is particularly useful for: @@ -34,3 +42,15 @@ _Deprecated_. Use `MultisigScript.sol` instead. This was an earlier version of t ## Usage These scripts are typically imported by specific task scripts in the `contract-deployments` repository. A typical task script inherits from `MultisigScript` and implements the `_buildCalls` method to define the actions to be taken. + +## Testing + +### Integration Test for L2 Gas Estimation + +To test the L2 gas estimation with a real RPC endpoint: + +```bash +L2_RPC_URL=https://sepolia.base.org forge test --match-test test_integration_gasEstimation -vvv +``` + +This test is automatically skipped in CI when `L2_RPC_URL` is not set. diff --git a/test/universal/MultisigScriptDeposit.t.sol b/test/universal/MultisigScriptDeposit.t.sol index 8ce482e..674c6df 100644 --- a/test/universal/MultisigScriptDeposit.t.sol +++ b/test/universal/MultisigScriptDeposit.t.sol @@ -86,6 +86,9 @@ contract MultisigScriptDepositTest is Test, MultisigScriptDeposit { return testGasLimit; } + /// @notice Skip gas estimation since we override _l2GasLimit() directly + function _ensureL2GasLimitCached() internal override {} + function _ownerSafe() internal view override returns (address) { return safe; } @@ -222,6 +225,33 @@ contract MultisigScriptDepositTest is Test, MultisigScriptDeposit { assertTrue(success, "Sign with value should succeed"); } + /// @notice Test default _optimismPortal() returns correct address for mainnet + function test_optimismPortal_mainnet() external { + // Create a separate test contract that uses default _optimismPortal() + vm.chainId(1); + DefaultPortalTest defaultTest = new DefaultPortalTest(); + assertEq( + defaultTest.getOptimismPortal(), 0x49048044D57e1C92A77f79988d21Fa8fAF74E97e, "Should return mainnet portal" + ); + } + + /// @notice Test default _optimismPortal() returns correct address for sepolia + function test_optimismPortal_sepolia() external { + vm.chainId(11155111); + DefaultPortalTest defaultTest = new DefaultPortalTest(); + assertEq( + defaultTest.getOptimismPortal(), 0x49f53e41452C74589E85cA1677426Ba426459e85, "Should return sepolia portal" + ); + } + + /// @notice Test default _optimismPortal() reverts for unknown chain + function test_optimismPortal_unknownChain_reverts() external { + vm.chainId(999999); + DefaultPortalTest defaultTest = new DefaultPortalTest(); + vm.expectRevert("MultisigScriptDeposit: unsupported chain, override _optimismPortal()"); + defaultTest.getOptimismPortal(); + } + ////////////////////////////////////////////////////////////////////////////////////// /// Helper Functions /// ////////////////////////////////////////////////////////////////////////////////////// @@ -296,3 +326,21 @@ contract MultisigScriptDepositTest is Test, MultisigScriptDeposit { } } +/// @notice Helper contract to test default _optimismPortal() implementation +/// @dev This contract does NOT override _optimismPortal(), so it uses the default chain-based logic +contract DefaultPortalTest is MultisigScriptDeposit { + function _ownerSafe() internal pure override returns (address) { + return address(1); + } + + function _buildL2Calls() internal pure override returns (IMulticall3.Call3Value[] memory) { + return new IMulticall3.Call3Value[](0); + } + + function _postCheck(Vm.AccountAccess[] memory, Simulation.Payload memory) internal pure override {} + + /// @notice Expose _optimismPortal() for testing + function getOptimismPortal() external view returns (address) { + return _optimismPortal(); + } +} diff --git a/test/universal/MultisigScriptDepositIntegration.t.sol b/test/universal/MultisigScriptDepositIntegration.t.sol new file mode 100644 index 0000000..d070574 --- /dev/null +++ b/test/universal/MultisigScriptDepositIntegration.t.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +import {IMulticall3} from "forge-std/interfaces/IMulticall3.sol"; +import {Test, console} from "forge-std/Test.sol"; +import {Vm} from "forge-std/Vm.sol"; + +import {MultisigScriptDeposit} from "script/universal/MultisigScriptDeposit.sol"; +import {Simulation} from "script/universal/Simulation.sol"; + +/// @notice Integration test for L2 gas estimation +/// @dev Run with: L2_RPC_URL= forge test --match-contract MultisigScriptDepositIntegrationTest -vvv +contract MultisigScriptDepositIntegrationTest is Test, MultisigScriptDeposit { + function _ownerSafe() internal pure override returns (address) { + return address(1); // Dummy address for testing + } + + function _postCheck(Vm.AccountAccess[] memory, Simulation.Payload memory) internal pure override {} + + /// @notice Build simple L2 calls for testing estimation + function _buildL2Calls() internal pure override returns (IMulticall3.Call3Value[] memory) { + IMulticall3.Call3Value[] memory calls = new IMulticall3.Call3Value[](2); + + // Call 1: Simple ETH transfer (minimal gas) + calls[0] = IMulticall3.Call3Value({target: address(0xdead), allowFailure: false, callData: "", value: 0}); + + // Call 2: Another simple call + calls[1] = IMulticall3.Call3Value({ + target: address(0xbeef), + allowFailure: false, + callData: abi.encodeWithSignature("nonExistentFunction()"), + value: 0 + }); + + return calls; + } + + /// @notice Integration test that actually forks L2 and estimates gas + /// @dev Requires L2_RPC_URL environment variable to be set + /// Run with: L2_RPC_URL=https://sepolia.base.org forge test --match-test test_integration_gasEstimation -vvv + function test_integration_gasEstimation() external { + // Skip if L2_RPC_URL is not set (allows CI to pass without network access) + try vm.envString("L2_RPC_URL") returns ( + string memory + ) { + // L2_RPC_URL is set, continue with test + } + catch { + vm.skip(true, "L2_RPC_URL not set - skipping integration test. Set L2_RPC_URL to run."); + } + + console.log("Starting L2 gas estimation integration test..."); + console.log("L2_RPC_URL is set, proceeding with fork-based estimation"); + + // Trigger estimation (will fork L2 and measure gas) + _ensureL2GasLimitCached(); + + // Get the estimated gas + uint64 estimatedGas = _l2GasLimit(); + + console.log("Estimated L2 gas limit:", estimatedGas); + + // Basic sanity checks + assertGt(estimatedGas, 0, "Estimated gas should be > 0"); + assertLt(estimatedGas, 10_000_000, "Estimated gas should be reasonable (< 10M)"); + + console.log("Integration test passed!"); + } +} + From b425f75daa429577832ba841d2c931d30c80a1f4 Mon Sep 17 00:00:00 2001 From: Leopold Joy Date: Tue, 13 Jan 2026 18:49:44 +0000 Subject: [PATCH 4/6] fix: adapt MultisigScriptDeposit to new Call struct from merged main --- script/universal/MultisigScriptDeposit.sol | 31 ++++----- test/universal/MultisigScriptDeposit.t.sol | 64 +++++++++---------- .../MultisigScriptDepositIntegration.t.sol | 10 +-- 3 files changed, 52 insertions(+), 53 deletions(-) diff --git a/script/universal/MultisigScriptDeposit.sol b/script/universal/MultisigScriptDeposit.sol index fb8d801..e124c95 100644 --- a/script/universal/MultisigScriptDeposit.sol +++ b/script/universal/MultisigScriptDeposit.sol @@ -1,9 +1,10 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.15; -import {IMulticall3} from "lib/forge-std/src/interfaces/IMulticall3.sol"; +import {CBMulticall} from "src/utils/CBMulticall.sol"; import {MultisigScript} from "./MultisigScript.sol"; +import {Enum} from "./IGnosisSafe.sol"; /// @notice Interface for OptimismPortal2's depositTransaction function interface IOptimismPortal2 { @@ -33,9 +34,9 @@ interface IOptimismPortal2 { /// return vm.envAddress("OWNER_SAFE"); /// } /// -/// function _buildL2Calls() internal view override returns (IMulticall3.Call3Value[] memory) { -/// IMulticall3.Call3Value[] memory calls = new IMulticall3.Call3Value[](1); -/// calls[0] = IMulticall3.Call3Value({ +/// function _buildL2Calls() internal view override returns (CBMulticall.Call3Value[] memory) { +/// CBMulticall.Call3Value[] memory calls = new CBMulticall.Call3Value[](1); +/// calls[0] = CBMulticall.Call3Value({ /// target: L2_CONTRACT, /// allowFailure: false, /// callData: abi.encodeCall(IL2Contract.someFunction, (arg1, arg2)), @@ -137,7 +138,7 @@ abstract contract MultisigScriptDeposit is MultisigScript { /// The `value` field in each Call3Value struct specifies ETH to send with that /// specific L2 call. The total ETH will be bridged via the deposit transaction. /// @return calls Array of calls to execute on L2 via CBMulticall - function _buildL2Calls() internal view virtual returns (IMulticall3.Call3Value[] memory); + function _buildL2Calls() internal view virtual returns (CBMulticall.Call3Value[] memory); ////////////////////////////////////////////////////////////////////////////////////// /// Overridden Entry Points /// @@ -183,20 +184,20 @@ abstract contract MultisigScriptDeposit is MultisigScript { /// summed and sent with the deposit transaction. The CBMulticall.aggregate3Value /// function on L2 automatically distributes the ETH to each call according to its /// specified `value` field - no additional developer action is required. - function _buildCalls() internal view virtual override returns (IMulticall3.Call3Value[] memory) { - IMulticall3.Call3Value[] memory l2Calls = _buildL2Calls(); + function _buildCalls() internal view virtual override returns (Call[] memory) { + CBMulticall.Call3Value[] memory l2Calls = _buildL2Calls(); uint256 totalValue = _sumL2CallValues(l2Calls); // Encode L2 calls as a multicall // Note: We use aggregate3Value to support per-call ETH distribution on L2 - bytes memory l2Data = abi.encodeCall(IMulticall3.aggregate3Value, (l2Calls)); + bytes memory l2Data = abi.encodeCall(CBMulticall.aggregate3Value, (l2Calls)); // Wrap in depositTransaction call to OptimismPortal - IMulticall3.Call3Value[] memory l1Calls = new IMulticall3.Call3Value[](1); - l1Calls[0] = IMulticall3.Call3Value({ + Call[] memory l1Calls = new Call[](1); + l1Calls[0] = Call({ + operation: Enum.Operation.Call, target: _optimismPortal(), - allowFailure: false, - callData: abi.encodeCall( + data: abi.encodeCall( IOptimismPortal2.depositTransaction, ( CB_MULTICALL, // L2 target: CBMulticall at same address on L2 @@ -244,8 +245,8 @@ abstract contract MultisigScriptDeposit is MultisigScript { /// @return estimatedGas The estimated gas limit with safety buffer applied function _estimateL2GasViaFork(string memory l2RpcUrl) internal returns (uint64) { // Build L2 call data - IMulticall3.Call3Value[] memory l2Calls = _buildL2Calls(); - bytes memory l2Data = abi.encodeCall(IMulticall3.aggregate3Value, (l2Calls)); + CBMulticall.Call3Value[] memory l2Calls = _buildL2Calls(); + bytes memory l2Data = abi.encodeCall(CBMulticall.aggregate3Value, (l2Calls)); uint256 totalValue = _sumL2CallValues(l2Calls); // Store current fork (if any) to restore later @@ -291,7 +292,7 @@ abstract contract MultisigScriptDeposit is MultisigScript { /// @notice Sums the ETH values from an array of L2 calls /// @param l2Calls The array of L2 calls to sum values from /// @return total The total ETH value across all calls - function _sumL2CallValues(IMulticall3.Call3Value[] memory l2Calls) internal pure returns (uint256 total) { + function _sumL2CallValues(CBMulticall.Call3Value[] memory l2Calls) internal pure returns (uint256 total) { for (uint256 i; i < l2Calls.length; i++) { total += l2Calls[i].value; } diff --git a/test/universal/MultisigScriptDeposit.t.sol b/test/universal/MultisigScriptDeposit.t.sol index 674c6df..a79fc69 100644 --- a/test/universal/MultisigScriptDeposit.t.sol +++ b/test/universal/MultisigScriptDeposit.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.15; -import {IMulticall3} from "forge-std/interfaces/IMulticall3.sol"; +import {CBMulticall} from "src/utils/CBMulticall.sol"; import {Test} from "forge-std/Test.sol"; import {Vm} from "forge-std/Vm.sol"; import {Preinstalls} from "lib/optimism/packages/contracts-bedrock/src/libraries/Preinstalls.sol"; @@ -53,7 +53,7 @@ contract MultisigScriptDepositTest is Test, MultisigScriptDeposit { address internal testL2Target; uint64 internal testGasLimit = 200_000; - function() internal view returns (IMulticall3.Call3Value[] memory) buildL2CallsInternal; + function() internal view returns (CBMulticall.Call3Value[] memory) buildL2CallsInternal; function setUp() public { // Deploy mock portal @@ -93,13 +93,12 @@ contract MultisigScriptDepositTest is Test, MultisigScriptDeposit { return safe; } - function _buildL2Calls() internal view override returns (IMulticall3.Call3Value[] memory) { + function _buildL2Calls() internal view override returns (CBMulticall.Call3Value[] memory) { return buildL2CallsInternal(); } - function _postCheck(Vm.AccountAccess[] memory, Simulation.Payload memory) internal view override { - // Verify deposit was made to portal - require(portal.depositCount() > 0, "No deposit made"); + function _postCheck(Vm.AccountAccess[] memory, Simulation.Payload memory) internal pure override { + // No-op for tests - the deposit is simulated but not executed during sign() } ////////////////////////////////////////////////////////////////////////////////////// @@ -110,17 +109,16 @@ contract MultisigScriptDepositTest is Test, MultisigScriptDeposit { function test_buildCalls_singleL2Call_noValue() external { buildL2CallsInternal = _buildSingleL2CallNoValue; - IMulticall3.Call3Value[] memory calls = _buildCalls(); + Call[] memory calls = _buildCalls(); // Should produce exactly one L1 call to the portal assertEq(calls.length, 1, "Should have one L1 call"); assertEq(calls[0].target, address(portal), "Target should be portal"); assertEq(calls[0].value, 0, "Value should be 0"); - assertFalse(calls[0].allowFailure, "Should not allow failure"); // Decode the depositTransaction call (address to, uint256 value, uint64 gasLimit, bool isCreation, bytes memory data) = - _decodeDepositTransaction(calls[0].callData); + _decodeDepositTransaction(calls[0].data); assertEq(to, CB_MULTICALL, "L2 target should be CB_MULTICALL"); assertEq(value, 0, "Bridged value should be 0"); @@ -130,21 +128,21 @@ contract MultisigScriptDepositTest is Test, MultisigScriptDeposit { // Verify the L2 data is an aggregate3Value call bytes4 selector = bytes4(data); - assertEq(selector, IMulticall3.aggregate3Value.selector, "Should be aggregate3Value call"); + assertEq(selector, CBMulticall.aggregate3Value.selector, "Should be aggregate3Value call"); } /// @notice Test that multiple L2 calls are batched correctly function test_buildCalls_multipleL2Calls_noValue() external { buildL2CallsInternal = _buildMultipleL2CallsNoValue; - IMulticall3.Call3Value[] memory calls = _buildCalls(); + Call[] memory calls = _buildCalls(); // Should still produce exactly one L1 call assertEq(calls.length, 1, "Should have one L1 call"); // Decode and verify (address to, uint256 value, uint64 gasLimit, bool isCreation, bytes memory data) = - _decodeDepositTransaction(calls[0].callData); + _decodeDepositTransaction(calls[0].data); assertEq(to, CB_MULTICALL, "L2 target should be CB_MULTICALL"); assertEq(value, 0, "Bridged value should be 0"); @@ -152,7 +150,7 @@ contract MultisigScriptDepositTest is Test, MultisigScriptDeposit { assertFalse(isCreation, "Should not be creation"); // Decode the aggregate3Value call to verify multiple L2 calls are included - IMulticall3.Call3Value[] memory l2Calls = abi.decode(_stripSelector(data), (IMulticall3.Call3Value[])); + CBMulticall.Call3Value[] memory l2Calls = abi.decode(_stripSelector(data), (CBMulticall.Call3Value[])); assertEq(l2Calls.length, 3, "Should have 3 L2 calls"); // Verify call parameters are preserved through the wrapping @@ -170,13 +168,13 @@ contract MultisigScriptDepositTest is Test, MultisigScriptDeposit { function test_buildCalls_withValue() external { buildL2CallsInternal = _buildL2CallsWithValue; - IMulticall3.Call3Value[] memory calls = _buildCalls(); + Call[] memory calls = _buildCalls(); // Value should be sum of all L2 call values (1 + 2 + 0.5 = 3.5 ether) assertEq(calls[0].value, 3.5 ether, "L1 call value should be sum of L2 values"); // Decode and verify bridged value - (, uint256 value,,,) = _decodeDepositTransaction(calls[0].callData); + (, uint256 value,,,) = _decodeDepositTransaction(calls[0].data); assertEq(value, 3.5 ether, "Bridged value should be 3.5 ether"); } @@ -184,12 +182,12 @@ contract MultisigScriptDepositTest is Test, MultisigScriptDeposit { function test_buildCalls_singleCallStillUsesMulticall() external { buildL2CallsInternal = _buildSingleL2CallNoValue; - IMulticall3.Call3Value[] memory calls = _buildCalls(); - (,,,, bytes memory data) = _decodeDepositTransaction(calls[0].callData); + Call[] memory calls = _buildCalls(); + (,,,, bytes memory data) = _decodeDepositTransaction(calls[0].data); // Even single calls should be wrapped in aggregate3Value for consistency bytes4 selector = bytes4(data); - assertEq(selector, IMulticall3.aggregate3Value.selector, "Single call should still use aggregate3Value"); + assertEq(selector, CBMulticall.aggregate3Value.selector, "Single call should still use aggregate3Value"); } /// @notice Test the full sign flow with deposit transaction @@ -256,23 +254,23 @@ contract MultisigScriptDepositTest is Test, MultisigScriptDeposit { /// Helper Functions /// ////////////////////////////////////////////////////////////////////////////////////// - function _buildSingleL2CallNoValue() internal view returns (IMulticall3.Call3Value[] memory) { - IMulticall3.Call3Value[] memory calls = new IMulticall3.Call3Value[](1); - calls[0] = IMulticall3.Call3Value({ + function _buildSingleL2CallNoValue() internal view returns (CBMulticall.Call3Value[] memory) { + CBMulticall.Call3Value[] memory calls = new CBMulticall.Call3Value[](1); + calls[0] = CBMulticall.Call3Value({ target: testL2Target, allowFailure: false, callData: abi.encodeCall(Counter.increment, ()), value: 0 }); return calls; } - function _buildMultipleL2CallsNoValue() internal view returns (IMulticall3.Call3Value[] memory) { - IMulticall3.Call3Value[] memory calls = new IMulticall3.Call3Value[](3); - calls[0] = IMulticall3.Call3Value({ + function _buildMultipleL2CallsNoValue() internal view returns (CBMulticall.Call3Value[] memory) { + CBMulticall.Call3Value[] memory calls = new CBMulticall.Call3Value[](3); + calls[0] = CBMulticall.Call3Value({ target: testL2Target, allowFailure: false, callData: abi.encodeCall(Counter.increment, ()), value: 0 }); - calls[1] = IMulticall3.Call3Value({ + calls[1] = CBMulticall.Call3Value({ target: testL2Target, allowFailure: false, callData: abi.encodeCall(Counter.increment, ()), value: 0 }); - calls[2] = IMulticall3.Call3Value({ + calls[2] = CBMulticall.Call3Value({ target: testL2Target, allowFailure: true, // Test allowFailure flag preservation callData: abi.encodeCall(Counter.increment, ()), @@ -281,21 +279,21 @@ contract MultisigScriptDepositTest is Test, MultisigScriptDeposit { return calls; } - function _buildL2CallsWithValue() internal view returns (IMulticall3.Call3Value[] memory) { - IMulticall3.Call3Value[] memory calls = new IMulticall3.Call3Value[](3); - calls[0] = IMulticall3.Call3Value({ + function _buildL2CallsWithValue() internal view returns (CBMulticall.Call3Value[] memory) { + CBMulticall.Call3Value[] memory calls = new CBMulticall.Call3Value[](3); + calls[0] = CBMulticall.Call3Value({ target: testL2Target, allowFailure: false, callData: abi.encodeCall(Counter.incrementPayable, ()), value: 1 ether }); - calls[1] = IMulticall3.Call3Value({ + calls[1] = CBMulticall.Call3Value({ target: testL2Target, allowFailure: false, callData: abi.encodeCall(Counter.incrementPayable, ()), value: 2 ether }); - calls[2] = IMulticall3.Call3Value({ + calls[2] = CBMulticall.Call3Value({ target: testL2Target, allowFailure: false, callData: abi.encodeCall(Counter.incrementPayable, ()), @@ -333,8 +331,8 @@ contract DefaultPortalTest is MultisigScriptDeposit { return address(1); } - function _buildL2Calls() internal pure override returns (IMulticall3.Call3Value[] memory) { - return new IMulticall3.Call3Value[](0); + function _buildL2Calls() internal pure override returns (CBMulticall.Call3Value[] memory) { + return new CBMulticall.Call3Value[](0); } function _postCheck(Vm.AccountAccess[] memory, Simulation.Payload memory) internal pure override {} diff --git a/test/universal/MultisigScriptDepositIntegration.t.sol b/test/universal/MultisigScriptDepositIntegration.t.sol index d070574..97ca14a 100644 --- a/test/universal/MultisigScriptDepositIntegration.t.sol +++ b/test/universal/MultisigScriptDepositIntegration.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.15; -import {IMulticall3} from "forge-std/interfaces/IMulticall3.sol"; +import {CBMulticall} from "src/utils/CBMulticall.sol"; import {Test, console} from "forge-std/Test.sol"; import {Vm} from "forge-std/Vm.sol"; @@ -18,14 +18,14 @@ contract MultisigScriptDepositIntegrationTest is Test, MultisigScriptDeposit { function _postCheck(Vm.AccountAccess[] memory, Simulation.Payload memory) internal pure override {} /// @notice Build simple L2 calls for testing estimation - function _buildL2Calls() internal pure override returns (IMulticall3.Call3Value[] memory) { - IMulticall3.Call3Value[] memory calls = new IMulticall3.Call3Value[](2); + function _buildL2Calls() internal pure override returns (CBMulticall.Call3Value[] memory) { + CBMulticall.Call3Value[] memory calls = new CBMulticall.Call3Value[](2); // Call 1: Simple ETH transfer (minimal gas) - calls[0] = IMulticall3.Call3Value({target: address(0xdead), allowFailure: false, callData: "", value: 0}); + calls[0] = CBMulticall.Call3Value({target: address(0xdead), allowFailure: false, callData: "", value: 0}); // Call 2: Another simple call - calls[1] = IMulticall3.Call3Value({ + calls[1] = CBMulticall.Call3Value({ target: address(0xbeef), allowFailure: false, callData: abi.encodeWithSignature("nonExistentFunction()"), From 88e732001187e6ad65a18dc217f692b51d47475d Mon Sep 17 00:00:00 2001 From: Leopold Joy Date: Wed, 14 Jan 2026 10:17:31 +0000 Subject: [PATCH 5/6] fix: rollback automatic L2 gas estimation, use L2_GAS_LIMIT env var instead --- script/universal/MultisigScript.sol | 8 +- script/universal/MultisigScriptDeposit.sol | 140 +----------------- script/universal/README.md | 14 +- test/universal/MultisigScriptDeposit.t.sol | 3 - .../MultisigScriptDepositIntegration.t.sol | 70 --------- 5 files changed, 12 insertions(+), 223 deletions(-) delete mode 100644 test/universal/MultisigScriptDepositIntegration.t.sol diff --git a/script/universal/MultisigScript.sol b/script/universal/MultisigScript.sol index 1c61e33..519f510 100644 --- a/script/universal/MultisigScript.sol +++ b/script/universal/MultisigScript.sol @@ -225,7 +225,7 @@ abstract contract MultisigScript is Script { /// multisig (see step 2). /// /// @param safes A list of nested safes (excluding the executing safe returned by `_ownerSafe`). - function sign(address[] memory safes) public virtual { + function sign(address[] memory safes) public { safes = _appendOwnerSafe({safes: safes}); // Snapshot and restore Safe nonce after simulation, otherwise the data logged to sign @@ -285,7 +285,7 @@ abstract contract MultisigScript is Script { /// /// @param safes A list of nested safes (excluding the executing safe returned by `_ownerSafe`). /// @param signatures The signatures from step 1 (concatenated, 65-bytes per sig) - function approve(address[] memory safes, bytes memory signatures) public virtual { + function approve(address[] memory safes, bytes memory signatures) public { safes = _appendOwnerSafe({safes: safes}); Call[] memory callsChain = _buildCallsChain({safes: safes}); @@ -304,7 +304,7 @@ abstract contract MultisigScript is Script { /// Differs from `run` in that you can override the safe nonce for simulation purposes. /// /// @param signatures The signatures from step 1 (concatenated, 65-bytes per sig) - function simulate(bytes memory signatures) public virtual { + function simulate(bytes memory signatures) public { address ownerSafe = _ownerSafe(); Call[] memory callsChain = _buildCallsChain({safes: _toArray(ownerSafe)}); @@ -324,7 +324,7 @@ abstract contract MultisigScript is Script { /// submitted onchain (nested case, see step 2, in which case `signatures` can be empty). /// /// @param signatures The signatures from step 1 (concatenated, 65-bytes per sig) - function run(bytes memory signatures) public virtual { + function run(bytes memory signatures) public { address ownerSafe = _ownerSafe(); Call[] memory callsChain = _buildCallsChain({safes: _toArray(ownerSafe)}); diff --git a/script/universal/MultisigScriptDeposit.sol b/script/universal/MultisigScriptDeposit.sol index e124c95..1442fc3 100644 --- a/script/universal/MultisigScriptDeposit.sol +++ b/script/universal/MultisigScriptDeposit.sol @@ -47,24 +47,14 @@ interface IOptimismPortal2 { /// } /// ``` /// -/// The example above uses default implementations for `_optimismPortal()` (chain-based) and -/// `_l2GasLimit()` (automatic estimation via L2 fork). Task writers can override these if needed. +/// The example above uses the default implementation for `_optimismPortal()` (chain-based). +/// Task writers must set the `L2_GAS_LIMIT` environment variable or override `_l2GasLimit()`. /// /// @dev Future Enhancements: /// 1. L2 Post-Check Hook: Currently, `_postCheck` runs on L1 and cannot verify L2 state changes. /// A future enhancement could add an `_postCheckL2` hook that forks L2 state and simulates /// the deposit transaction's effect. This is non-trivial because deposit transactions are /// not immediately reflected on L2. -/// -/// 2. Per-Call Gas Hints: With automatic gas estimation via L2 fork, per-call gas hints are -/// less critical. However, they could still be useful when L2 RPC is unavailable or when -/// task writers want manual control without overriding `_l2GasLimit()` entirely. -/// -/// @dev Testing Note: -/// Unit tests override `_l2GasLimit()` directly to avoid external dependencies. The L2 gas -/// estimation mechanism is tested via an integration test that requires `L2_RPC_URL`: -/// `L2_RPC_URL= forge test --match-test test_integration_gasEstimation -vvv` -/// This integration test is automatically skipped in CI when `L2_RPC_URL` is not set. abstract contract MultisigScriptDeposit is MultisigScript { ////////////////////////////////////////////////////////////////////////////////////// /// Constants /// @@ -76,20 +66,6 @@ abstract contract MultisigScriptDeposit is MultisigScript { /// @notice OptimismPortalProxy address on L1 Sepolia (for Base Sepolia) address internal constant OPTIMISM_PORTAL_SEPOLIA = 0x49f53e41452C74589E85cA1677426Ba426459e85; - /// @notice Gas estimation safety buffer (50% overhead) - /// @dev Applied to the estimated gas to account for variations in execution - uint256 internal constant GAS_ESTIMATION_BUFFER_PERCENT = 150; - - ////////////////////////////////////////////////////////////////////////////////////// - /// State Variables /// - ////////////////////////////////////////////////////////////////////////////////////// - - /// @notice Cached L2 gas limit from estimation - uint64 private _cachedL2GasLimit; - - /// @notice Whether the L2 gas limit has been cached - bool private _l2GasLimitCached; - ////////////////////////////////////////////////////////////////////////////////////// /// Virtual Functions /// ////////////////////////////////////////////////////////////////////////////////////// @@ -108,13 +84,12 @@ abstract contract MultisigScriptDeposit is MultisigScript { } /// @notice Returns the minimum gas limit for L2 execution - /// @dev Default implementation estimates gas by forking L2 and simulating the call. - /// Requires the `L2_RPC_URL` environment variable to be set. + /// @dev Default implementation reads from the `L2_GAS_LIMIT` environment variable. + /// All signers must use the same gas limit to produce matching signatures. /// - /// To manually specify a gas limit instead of using automatic estimation, - /// override this function in your task contract: + /// To specify a fixed gas limit, override this function in your task contract: /// ```solidity - /// function _l2GasLimit() internal view override returns (uint64) { + /// function _l2GasLimit() internal pure override returns (uint64) { /// return 200_000; // Your estimated gas limit /// } /// ``` @@ -126,8 +101,7 @@ abstract contract MultisigScriptDeposit is MultisigScript { /// If the gas limit is too low, the L2 transaction will fail but the deposit /// will still be recorded (ETH may be stuck until manually recovered). function _l2GasLimit() internal view virtual returns (uint64) { - require(_l2GasLimitCached, "MultisigScriptDeposit: L2 gas limit not estimated, ensure L2_RPC_URL is set"); - return _cachedL2GasLimit; + return uint64(vm.envUint("L2_GAS_LIMIT")); } /// @notice Build the calls that will be executed on L2 @@ -140,34 +114,6 @@ abstract contract MultisigScriptDeposit is MultisigScript { /// @return calls Array of calls to execute on L2 via CBMulticall function _buildL2Calls() internal view virtual returns (CBMulticall.Call3Value[] memory); - ////////////////////////////////////////////////////////////////////////////////////// - /// Overridden Entry Points /// - ////////////////////////////////////////////////////////////////////////////////////// - - /// @notice Override sign to ensure L2 gas is estimated before building calls - function sign(address[] memory safes) public virtual override { - _ensureL2GasLimitCached(); - super.sign(safes); - } - - /// @notice Override approve to ensure L2 gas is estimated before building calls - function approve(address[] memory safes, bytes memory signatures) public virtual override { - _ensureL2GasLimitCached(); - super.approve(safes, signatures); - } - - /// @notice Override simulate to ensure L2 gas is estimated before building calls - function simulate(bytes memory signatures) public virtual override { - _ensureL2GasLimitCached(); - super.simulate(signatures); - } - - /// @notice Override run to ensure L2 gas is estimated before building calls - function run(bytes memory signatures) public virtual override { - _ensureL2GasLimitCached(); - super.run(signatures); - } - ////////////////////////////////////////////////////////////////////////////////////// /// Overridden Functions /// ////////////////////////////////////////////////////////////////////////////////////// @@ -217,78 +163,6 @@ abstract contract MultisigScriptDeposit is MultisigScript { /// Internal Functions /// ////////////////////////////////////////////////////////////////////////////////////// - /// @notice Ensures the L2 gas limit is cached before building calls - /// @dev Called by overridden entry points (sign, run, etc.) to trigger estimation. - /// If you override `_l2GasLimit()` to return a fixed value, you should also - /// override this function to be a no-op to skip the L2_RPC_URL requirement. - function _ensureL2GasLimitCached() internal virtual { - if (_l2GasLimitCached) return; - - // Get L2 RPC URL for forking - string memory l2RpcUrl; - try vm.envString("L2_RPC_URL") returns (string memory url) { - l2RpcUrl = url; - } catch { - revert( - "MultisigScriptDeposit: L2_RPC_URL env var required for gas estimation. " - "Alternatively, override _l2GasLimit() to specify a manual gas limit." - ); - } - - // Estimate gas via L2 fork - _cachedL2GasLimit = _estimateL2GasViaFork(l2RpcUrl); - _l2GasLimitCached = true; - } - - /// @notice Estimates L2 gas by forking the L2 chain and simulating the multicall - /// @param l2RpcUrl The RPC URL of the L2 chain to fork - /// @return estimatedGas The estimated gas limit with safety buffer applied - function _estimateL2GasViaFork(string memory l2RpcUrl) internal returns (uint64) { - // Build L2 call data - CBMulticall.Call3Value[] memory l2Calls = _buildL2Calls(); - bytes memory l2Data = abi.encodeCall(CBMulticall.aggregate3Value, (l2Calls)); - uint256 totalValue = _sumL2CallValues(l2Calls); - - // Store current fork (if any) to restore later - uint256 originalFork; - bool hadActiveFork; - try vm.activeFork() returns (uint256 forkId) { - originalFork = forkId; - hadActiveFork = true; - } catch { - hadActiveFork = false; - } - - // Create and select L2 fork - uint256 l2Fork = vm.createFork(l2RpcUrl); - vm.selectFork(l2Fork); - - // Fund the CBMulticall address if we need ETH for the simulation - if (totalValue > 0) { - vm.deal(CB_MULTICALL, totalValue); - } - - // Measure gas for the L2 call - uint256 gasBefore = gasleft(); - (bool success,) = CB_MULTICALL.call{value: totalValue}(l2Data); - uint256 gasUsed = gasBefore - gasleft(); - - // Restore original fork if there was one - if (hadActiveFork) { - vm.selectFork(originalFork); - } - - require(success, "MultisigScriptDeposit: L2 gas estimation failed, call reverted"); - - // Apply safety buffer and return - uint256 estimatedGas = (gasUsed * GAS_ESTIMATION_BUFFER_PERCENT) / 100; - - // Ensure we don't overflow uint64 - require(estimatedGas <= type(uint64).max, "MultisigScriptDeposit: estimated gas exceeds uint64"); - - return uint64(estimatedGas); - } - /// @notice Sums the ETH values from an array of L2 calls /// @param l2Calls The array of L2 calls to sum values from /// @return total The total ETH value across all calls diff --git a/script/universal/README.md b/script/universal/README.md index 1585923..eec3c82 100644 --- a/script/universal/README.md +++ b/script/universal/README.md @@ -18,9 +18,9 @@ This is the core script for building Forge scripts that interact with Gnosis Saf An extension of `MultisigScript` for L1 → L2 deposit transactions. Task writers define L2 calls via `_buildL2Calls()`, and the framework automatically wraps them in an `OptimismPortal.depositTransaction` call. Features: -- **Automatic Gas Estimation**: Forks L2 to estimate gas (requires `L2_RPC_URL` env var). - **ETH Bridging**: Supports bridging ETH along with the message. - **Chain-Aware Defaults**: Provides default `OptimismPortal` addresses for Mainnet and Sepolia. +- **Gas Limit**: Set via `L2_GAS_LIMIT` env var (all signers must use the same value). ### `Simulation.sol` @@ -42,15 +42,3 @@ _Deprecated_. Use `MultisigScript.sol` instead. This was an earlier version of t ## Usage These scripts are typically imported by specific task scripts in the `contract-deployments` repository. A typical task script inherits from `MultisigScript` and implements the `_buildCalls` method to define the actions to be taken. - -## Testing - -### Integration Test for L2 Gas Estimation - -To test the L2 gas estimation with a real RPC endpoint: - -```bash -L2_RPC_URL=https://sepolia.base.org forge test --match-test test_integration_gasEstimation -vvv -``` - -This test is automatically skipped in CI when `L2_RPC_URL` is not set. diff --git a/test/universal/MultisigScriptDeposit.t.sol b/test/universal/MultisigScriptDeposit.t.sol index a79fc69..96c8fff 100644 --- a/test/universal/MultisigScriptDeposit.t.sol +++ b/test/universal/MultisigScriptDeposit.t.sol @@ -86,9 +86,6 @@ contract MultisigScriptDepositTest is Test, MultisigScriptDeposit { return testGasLimit; } - /// @notice Skip gas estimation since we override _l2GasLimit() directly - function _ensureL2GasLimitCached() internal override {} - function _ownerSafe() internal view override returns (address) { return safe; } diff --git a/test/universal/MultisigScriptDepositIntegration.t.sol b/test/universal/MultisigScriptDepositIntegration.t.sol deleted file mode 100644 index 97ca14a..0000000 --- a/test/universal/MultisigScriptDepositIntegration.t.sol +++ /dev/null @@ -1,70 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.15; - -import {CBMulticall} from "src/utils/CBMulticall.sol"; -import {Test, console} from "forge-std/Test.sol"; -import {Vm} from "forge-std/Vm.sol"; - -import {MultisigScriptDeposit} from "script/universal/MultisigScriptDeposit.sol"; -import {Simulation} from "script/universal/Simulation.sol"; - -/// @notice Integration test for L2 gas estimation -/// @dev Run with: L2_RPC_URL= forge test --match-contract MultisigScriptDepositIntegrationTest -vvv -contract MultisigScriptDepositIntegrationTest is Test, MultisigScriptDeposit { - function _ownerSafe() internal pure override returns (address) { - return address(1); // Dummy address for testing - } - - function _postCheck(Vm.AccountAccess[] memory, Simulation.Payload memory) internal pure override {} - - /// @notice Build simple L2 calls for testing estimation - function _buildL2Calls() internal pure override returns (CBMulticall.Call3Value[] memory) { - CBMulticall.Call3Value[] memory calls = new CBMulticall.Call3Value[](2); - - // Call 1: Simple ETH transfer (minimal gas) - calls[0] = CBMulticall.Call3Value({target: address(0xdead), allowFailure: false, callData: "", value: 0}); - - // Call 2: Another simple call - calls[1] = CBMulticall.Call3Value({ - target: address(0xbeef), - allowFailure: false, - callData: abi.encodeWithSignature("nonExistentFunction()"), - value: 0 - }); - - return calls; - } - - /// @notice Integration test that actually forks L2 and estimates gas - /// @dev Requires L2_RPC_URL environment variable to be set - /// Run with: L2_RPC_URL=https://sepolia.base.org forge test --match-test test_integration_gasEstimation -vvv - function test_integration_gasEstimation() external { - // Skip if L2_RPC_URL is not set (allows CI to pass without network access) - try vm.envString("L2_RPC_URL") returns ( - string memory - ) { - // L2_RPC_URL is set, continue with test - } - catch { - vm.skip(true, "L2_RPC_URL not set - skipping integration test. Set L2_RPC_URL to run."); - } - - console.log("Starting L2 gas estimation integration test..."); - console.log("L2_RPC_URL is set, proceeding with fork-based estimation"); - - // Trigger estimation (will fork L2 and measure gas) - _ensureL2GasLimitCached(); - - // Get the estimated gas - uint64 estimatedGas = _l2GasLimit(); - - console.log("Estimated L2 gas limit:", estimatedGas); - - // Basic sanity checks - assertGt(estimatedGas, 0, "Estimated gas should be > 0"); - assertLt(estimatedGas, 10_000_000, "Estimated gas should be reasonable (< 10M)"); - - console.log("Integration test passed!"); - } -} - From 6890a7bdddeacd27cfa2e8b2264b7f4d0445e956 Mon Sep 17 00:00:00 2001 From: Leopold Joy Date: Thu, 15 Jan 2026 21:32:01 +0000 Subject: [PATCH 6/6] add explicit return to _sumL2CallValues func in MultisigScriptDeposit --- script/universal/MultisigScriptDeposit.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/script/universal/MultisigScriptDeposit.sol b/script/universal/MultisigScriptDeposit.sol index 1442fc3..1ceb160 100644 --- a/script/universal/MultisigScriptDeposit.sol +++ b/script/universal/MultisigScriptDeposit.sol @@ -170,5 +170,6 @@ abstract contract MultisigScriptDeposit is MultisigScript { for (uint256 i; i < l2Calls.length; i++) { total += l2Calls[i].value; } + return total; } }