diff --git a/script/universal/MultisigScript.sol b/script/universal/MultisigScript.sol index 4337264..fa945e1 100644 --- a/script/universal/MultisigScript.sol +++ b/script/universal/MultisigScript.sol @@ -11,6 +11,7 @@ import {IGnosisSafe, Enum} from "./IGnosisSafe.sol"; import {Signatures} from "./Signatures.sol"; import {Simulation} from "./Simulation.sol"; import {StateDiff} from "./StateDiff.sol"; +import {CBMulticall} from "../../src/utils/CBMulticall.sol"; /// @title MultisigScript /// @notice Script builder for Forge scripts that require signatures from Safes. Supports both non-nested @@ -183,12 +184,24 @@ abstract contract MultisigScript is Script { // By default, an empty (no-op) override is returned. function _simulationOverrides() internal view virtual returns (Simulation.StateOverride[] memory overrides_) {} + /// @notice If set to true, the executed aggregate call runs through the custom `CBMulticall` contract + /// as a `DELEGATECALL` for each individual call. + /// @dev In delegatecall mode: + /// - The multisig inherits the multicall logic and executes each target in its own context + /// (e.g. for Optimism's OPCM-style flows). + /// - The `value` field of each `IMulticall3.Call3Value` returned by `_buildCalls` MUST be zero. + /// Per-call value routing is not supported; any ETH attached to the Safe transaction is shared + /// across all calls according to the delegatee's logic. + function _useDelegateCall() internal view virtual returns (bool) { + return false; + } + constructor() { bool useCbMulticall; try vm.envBool("USE_CB_MULTICALL") { useCbMulticall = vm.envBool("USE_CB_MULTICALL"); } catch {} - multicallAddress = useCbMulticall ? CB_MULTICALL : MULTICALL3_ADDRESS; + multicallAddress = (useCbMulticall || _useDelegateCall()) ? CB_MULTICALL : MULTICALL3_ADDRESS; } ////////////////////////////////////////////////////////////////////////////////////// @@ -339,6 +352,10 @@ abstract contract MultisigScript is Script { datas = new bytes[](safes.length); datas[datas.length - 1] = abi.encodeCall(IMulticall3.aggregate3Value, (calls)); + if (_useDelegateCall()) { + datas[datas.length - 1] = abi.encodeCall(CBMulticall.aggregateDelegateCalls, (_toCall3Array(calls))); + } + // The first n-1 calls are the nested approval calls uint256 valueForCallToApprove = value; for (uint256 i = safes.length - 1; i > 0; i--) { @@ -354,6 +371,21 @@ abstract contract MultisigScript is Script { } } + /// @dev Converts `IMulticall3.Call3Value` calls into `CBMulticall.Call3` calls for delegatecall mode. + /// All `value` fields must be zero; delegatecall mode does not support per-call value routing. + function _toCall3Array(IMulticall3.Call3Value[] memory calls) private pure returns (CBMulticall.Call3[] memory) { + CBMulticall.Call3[] memory dCalls = new CBMulticall.Call3[](calls.length); + for (uint256 i; i < calls.length; i++) { + // Delegatecall mode relies on the Safe's `msg.value` handling rather than per-call value routing. + // Enforce that no per-call value is specified when using delegatecall mode. + require(calls[i].value == 0, "MultisigScript: delegatecall mode does not support call value"); + dCalls[i] = CBMulticall.Call3({ + target: calls[i].target, allowFailure: calls[i].allowFailure, callData: calls[i].callData + }); + } + return dCalls; + } + function _generateApproveCall(address safe, bytes memory data, uint256 value) internal view diff --git a/src/utils/CBMulticall.sol b/src/utils/CBMulticall.sol index ceb07b4..7718ad8 100644 --- a/src/utils/CBMulticall.sol +++ b/src/utils/CBMulticall.sol @@ -34,6 +34,14 @@ contract CBMulticall { bytes returnData; } + address private immutable THIS_CB_MULTICALL; + + error MustDelegateCall(); + + constructor() { + THIS_CB_MULTICALL = address(this); + } + /// @notice Backwards-compatible call aggregation with Multicall /// @param calls An array of Call structs /// @return blockNumber The block number where the calls were executed @@ -68,10 +76,11 @@ contract CBMulticall { returnData = new Result[](length); Call calldata call; for (uint256 i = 0; i < length;) { - Result memory result = returnData[i]; + Result memory result; call = calls[i]; (result.success, result.returnData) = call.target.call(call.callData); if (requireSuccess) require(result.success, "Multicall3: call failed"); + returnData[i] = result; unchecked { ++i; } @@ -116,12 +125,52 @@ contract CBMulticall { returnData = new Result[](length); Call3 calldata calli; for (uint256 i = 0; i < length;) { - Result memory result = returnData[i]; + Result memory result; calli = calls[i]; (result.success, result.returnData) = calli.target.call(calli.callData); assembly { // Revert if the call fails and failure is not allowed // `allowFailure := calldataload(add(calli, 0x20))` and `success := mload(result)` + // NOTE: We intentionally preserve the original Multicall3 error string + // ("Multicall3: call failed") for compatibility with existing tooling. + if iszero(or(calldataload(add(calli, 0x20)), mload(result))) { + // set "Error(string)" signature: bytes32(bytes4(keccak256("Error(string)"))) + mstore(0x00, 0x08c379a000000000000000000000000000000000000000000000000000000000) + // set data offset + mstore(0x04, 0x0000000000000000000000000000000000000000000000000000000000000020) + // set length of revert string + mstore(0x24, 0x0000000000000000000000000000000000000000000000000000000000000017) + // set revert string: bytes32(abi.encodePacked("Multicall3: call failed")) + mstore(0x44, 0x4d756c746963616c6c333a2063616c6c206661696c6564000000000000000000) + revert(0x00, 0x64) + } + } + returnData[i] = result; + unchecked { + ++i; + } + } + } + + /// @notice Aggregate calls, ensuring each returns success if required + /// @param calls An array of Call3 structs + /// @return returnData An array of Result structs + function aggregateDelegateCalls(Call3[] calldata calls) public payable returns (Result[] memory returnData) { + if (address(this) == THIS_CB_MULTICALL) { + revert MustDelegateCall(); + } + uint256 length = calls.length; + returnData = new Result[](length); + Call3 calldata calli; + for (uint256 i; i < length;) { + Result memory result; + calli = calls[i]; + (result.success, result.returnData) = calli.target.delegatecall(calli.callData); + assembly { + // Revert if the call fails and failure is not allowed + // `allowFailure := calldataload(add(calli, 0x20))` and `success := mload(result)` + // NOTE: We intentionally preserve the original Multicall3 error string + // ("Multicall3: call failed") for compatibility with existing tooling. if iszero(or(calldataload(add(calli, 0x20)), mload(result))) { // set "Error(string)" signature: bytes32(bytes4(keccak256("Error(string)"))) mstore(0x00, 0x08c379a000000000000000000000000000000000000000000000000000000000) @@ -134,6 +183,7 @@ contract CBMulticall { revert(0x00, 0x64) } } + returnData[i] = result; unchecked { ++i; } @@ -148,7 +198,7 @@ contract CBMulticall { returnData = new Result[](length); Call3Value calldata calli; for (uint256 i = 0; i < length;) { - Result memory result = returnData[i]; + Result memory result; calli = calls[i]; uint256 val = calli.value; (result.success, result.returnData) = calli.target.call{value: val}(calli.callData); @@ -167,6 +217,7 @@ contract CBMulticall { revert(0x00, 0x64) } } + returnData[i] = result; unchecked { ++i; } diff --git a/test/universal/MultisigScript.t.sol b/test/universal/MultisigScript.t.sol new file mode 100644 index 0000000..adc8171 --- /dev/null +++ b/test/universal/MultisigScript.t.sol @@ -0,0 +1,189 @@ +// 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 {MultisigScript} from "script/universal/MultisigScript.sol"; +import {Simulation} from "script/universal/Simulation.sol"; +import {IGnosisSafe, Enum} from "script/universal/IGnosisSafe.sol"; +import {Signatures} from "script/universal/Signatures.sol"; + +import {Counter} from "test/universal/Counter.sol"; + +contract MultisigScriptTest is Test, MultisigScript { + Vm.Wallet internal wallet1 = vm.createWallet("1"); + Vm.Wallet internal wallet2 = vm.createWallet("2"); + Vm.Wallet internal wallet3 = vm.createWallet("3"); + + address internal safe = address(1001); + Counter internal counter = new Counter(address(safe)); + + function() internal view returns (IMulticall3.Call3Value[] memory) buildCallsInternal; + + bytes internal dataToSign3of2 = + // solhint-disable-next-line max-line-length + hex"190132640243d7aade8c72f3d90d2dbf359e9897feba5fce1453bc8d9e7ba10d1715e6bf78f25eeee432952e1453c1b0d0bd867a1d4c4c859aa07ec7e2ef9cb87bc7"; + + function setUp() public { + vm.etch(safe, Preinstalls.getDeployedCode(Preinstalls.Safe_v130, block.chainid)); + vm.etch(Preinstalls.MultiCall3, Preinstalls.getDeployedCode(Preinstalls.MultiCall3, block.chainid)); + vm.deal(safe, 10 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)); + } + + function _postCheck(Vm.AccountAccess[] memory, Simulation.Payload memory) internal view override { + uint256 counterValue = counter.count(); + require(counterValue == 1, "Counter value is not 1"); + } + + function _buildCalls() internal view override returns (IMulticall3.Call3Value[] memory) { + return buildCallsInternal(); + } + + function _ownerSafe() internal view override returns (address) { + return address(safe); + } + + function _expectedTxDataForCurrentBuildCalls() internal view returns (bytes memory) { + IMulticall3.Call3Value[] memory calls = _buildCalls(); + uint256 value; + for (uint256 i; i < calls.length; i++) { + value += calls[i].value; + } + + // Non-nested case: single owner safe, last call is the aggregate call. + bytes memory data = abi.encodeCall(IMulticall3.aggregate3Value, (calls)); + return _encodeTransactionData(_ownerSafe(), data, value); + } + + function test_sign_no_value() external { + buildCallsInternal = _buildCallsNoValue; + + vm.recordLogs(); + bytes memory txData = abi.encodeWithSelector(this.sign.selector, new address[](0)); + vm.prank(wallet1.addr); + (bool success,) = address(this).call(txData); + vm.assertTrue(success); + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes memory logged = abi.decode(logs[logs.length - 1].data, (bytes)); + bytes memory expected = _expectedTxDataForCurrentBuildCalls(); + assertEq(keccak256(logged), keccak256(expected)); + } + + function test_sign_with_value() external { + buildCallsInternal = _buildCallsWithValue; + + vm.recordLogs(); + bytes memory txData = abi.encodeWithSelector(this.sign.selector, new address[](0)); + vm.prank(wallet1.addr); + (bool success,) = address(this).call(txData); + vm.assertTrue(success); + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes memory logged = abi.decode(logs[logs.length - 1].data, (bytes)); + bytes memory expected = _expectedTxDataForCurrentBuildCalls(); + assertEq(keccak256(logged), keccak256(expected)); + } + + function test_verify_valid_signatures() external { + buildCallsInternal = _buildCallsNoValue; + // Two-of-two signatures over the encoded transaction data should verify + bytes32 digest = keccak256(_expectedTxDataForCurrentBuildCalls()); + (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, digest); + (uint8 v2, bytes32 r2, bytes32 s2) = vm.sign(wallet2, digest); + bytes memory signatures = abi.encodePacked(r1, s1, v1, r2, s2, v2); + verify(new address[](0), signatures); + } + + function test_verify_reverts_with_invalid_signature() external { + buildCallsInternal = _buildCallsNoValue; + // One valid, one invalid should revert + bytes32 digest = keccak256(_expectedTxDataForCurrentBuildCalls()); + (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, digest); + bytes memory signatures = abi.encodePacked(r1, s1, v1, bytes32(0), bytes32(0), uint8(27)); + bytes memory callData = abi.encodeCall(this.verify, (new address[](0), signatures)); + (bool success, bytes memory ret) = address(this).call(callData); + assertFalse(success); + assertTrue(ret.length > 0); + } + + function test_simulate_only() external { + buildCallsInternal = _buildCallsNoValue; + bytes32 digest = keccak256(_expectedTxDataForCurrentBuildCalls()); + (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, digest); + (uint8 v2, bytes32 r2, bytes32 s2) = vm.sign(wallet2, digest); + bytes memory signatures = abi.encodePacked(r1, s1, v1, r2, s2, v2); + + // Simulate should execute successfully and satisfy _postCheck + simulate(signatures); + } + + function test_run_with_more_signatures_than_threshold() external { + // Create a safe with 3 owners but threshold of 2 + address safe3of2 = address(1002); + vm.etch(safe3of2, Preinstalls.getDeployedCode(Preinstalls.Safe_v130, block.chainid)); + vm.deal(safe3of2, 10 ether); + + address[] memory owners = new address[](3); + owners[0] = wallet1.addr; + owners[1] = wallet2.addr; + owners[2] = wallet3.addr; + IGnosisSafe(safe3of2).setup(owners, 2, address(0), "", address(0), address(0), 0, address(0)); + + Counter counter3of2 = new Counter(safe3of2); + bytes32 hash = keccak256(dataToSign3of2); + + (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, hash); + (uint8 v2, bytes32 r2, bytes32 s2) = vm.sign(wallet2, hash); + (uint8 v3, bytes32 r3, bytes32 s3) = vm.sign(wallet3, hash); + + bytes memory sigs = abi.encodePacked(r1, s1, v1, r2, s2, v2, r3, s3, v3); + sigs = Signatures.prepareSignatures({safe: safe3of2, hash: hash, signatures: sigs}); + + bool success = IGnosisSafe(safe3of2) + .execTransaction({ + to: address(counter3of2), + value: 0, + data: abi.encodeCall(Counter.increment, ()), + operation: Enum.Operation.Call, + safeTxGas: 0, + baseGas: 0, + gasPrice: 0, + gasToken: address(0), + refundReceiver: payable(address(0)), + signatures: sigs + }); + + assertTrue(success, "Should succeed with extra signatures"); + assertEq(counter3of2.count(), 1, "Counter should be incremented"); + } + + function _buildCallsNoValue() internal view returns (IMulticall3.Call3Value[] memory) { + IMulticall3.Call3Value[] memory calls = new IMulticall3.Call3Value[](1); + + calls[0] = IMulticall3.Call3Value({ + target: address(counter), allowFailure: false, callData: abi.encodeCall(Counter.increment, ()), value: 0 + }); + + return calls; + } + + function _buildCallsWithValue() internal view returns (IMulticall3.Call3Value[] memory) { + IMulticall3.Call3Value[] memory calls = new IMulticall3.Call3Value[](1); + + calls[0] = IMulticall3.Call3Value({ + target: address(counter), + allowFailure: false, + callData: abi.encodeCall(Counter.incrementPayable, ()), + value: 1 ether + }); + + return calls; + } +} diff --git a/test/universal/MultisigScriptDelegateCall.t.sol b/test/universal/MultisigScriptDelegateCall.t.sol new file mode 100644 index 0000000..57c0644 --- /dev/null +++ b/test/universal/MultisigScriptDelegateCall.t.sol @@ -0,0 +1,110 @@ +// 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 {MultisigScript} from "script/universal/MultisigScript.sol"; +import {Simulation} from "script/universal/Simulation.sol"; +import {IGnosisSafe, Enum} from "script/universal/IGnosisSafe.sol"; + +import {CBMulticall} from "src/utils/CBMulticall.sol"; +import {Counter} from "test/universal/Counter.sol"; + +/// @dev Variant of `MultisigScript` that always uses delegatecall via `CBMulticall`. +/// Used to assert the delegatecall path wiring and encoded data. +contract MultisigScriptDelegateCallTest is Test, MultisigScript { + Vm.Wallet internal wallet1 = vm.createWallet("1"); + Vm.Wallet internal wallet2 = vm.createWallet("2"); + + address internal safe = address(1101); + Counter internal counter = new Counter(address(safe)); + + function() internal view returns (IMulticall3.Call3Value[] memory) buildCallsInternal; + + function setUp() public { + // Deploy Safe and Multicall contracts. + vm.etch(safe, Preinstalls.getDeployedCode(Preinstalls.Safe_v130, block.chainid)); + + // Ensure there is code at the standard Multicall3 preinstall for simulation paths + // even though this test primarily exercises the CBMulticall delegatecall wiring. + vm.etch(Preinstalls.MultiCall3, Preinstalls.getDeployedCode(Preinstalls.MultiCall3, block.chainid)); + + // Deploy CBMulticall and map its code to the hard-coded CB_MULTICALL address + CBMulticall mc = new CBMulticall(); + vm.etch(CB_MULTICALL, address(mc).code); + + vm.deal(safe, 10 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)); + } + + function _postCheck(Vm.AccountAccess[] memory, Simulation.Payload memory) internal view override { + uint256 counterValue = counter.count(); + require(counterValue == 1, "Counter value is not 1"); + } + + function _buildCalls() internal view override returns (IMulticall3.Call3Value[] memory) { + return buildCallsInternal(); + } + + function _ownerSafe() internal view override returns (address) { + return address(safe); + } + + /// @dev Force delegatecall mode for this test contract. + function _useDelegateCall() internal view override returns (bool) { + return true; + } + + function test_delegatecall_mode_rejects_non_zero_call_value() external { + buildCallsInternal = _buildCallsWithValue; + + // In delegatecall mode, per-call value is not supported and `_toCall3Array` + // should enforce this invariant. + vm.expectRevert(bytes("MultisigScript: delegatecall mode does not support call value")); + bytes memory txData = abi.encodeWithSelector(this.sign.selector, new address[](0)); + vm.prank(wallet1.addr); + // This should bubble up the revert from `_toCall3Array`. + address(this).call(txData); + } + + function _buildCallsNoValue() internal view returns (IMulticall3.Call3Value[] memory) { + IMulticall3.Call3Value[] memory calls = new IMulticall3.Call3Value[](1); + + calls[0] = IMulticall3.Call3Value({ + target: address(counter), allowFailure: false, callData: abi.encodeCall(Counter.increment, ()), value: 0 + }); + + return calls; + } + + function _buildCallsWithValue() internal view returns (IMulticall3.Call3Value[] memory) { + IMulticall3.Call3Value[] memory calls = new IMulticall3.Call3Value[](1); + + calls[0] = IMulticall3.Call3Value({ + target: address(counter), + allowFailure: false, + callData: abi.encodeCall(Counter.incrementPayable, ()), + value: 1 ether + }); + + return calls; + } + + /// @dev Expose `multicallAddress` for test assertions. + function _getMulticallAddress() internal view returns (address) { + return multicallAddress; + } + + /// @dev Expose `CB_MULTICALL` for test assertions. + function _getCbMulticallConstant() internal pure returns (address) { + return CB_MULTICALL; + } +} + diff --git a/test/universal/MultisigScriptDoubleNested.t.sol b/test/universal/MultisigScriptDoubleNested.t.sol new file mode 100644 index 0000000..3bb0145 --- /dev/null +++ b/test/universal/MultisigScriptDoubleNested.t.sol @@ -0,0 +1,177 @@ +// 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 {MultisigScript} from "script/universal/MultisigScript.sol"; +import {Simulation} from "script/universal/Simulation.sol"; +import {IGnosisSafe} from "script/universal/IGnosisSafe.sol"; + +import {Counter} from "test/universal/Counter.sol"; + +contract MultisigScriptDoubleNestedTest is Test, MultisigScript { + Vm.Wallet internal wallet1 = vm.createWallet("1"); + Vm.Wallet internal wallet2 = vm.createWallet("2"); + + address internal safe1 = address(1001); + address internal safe2 = address(1002); + address internal safe3 = address(1003); + address internal safe4 = address(1004); + Counter internal counter = new Counter(address(safe4)); + + bytes internal dataToSign1 = + // solhint-disable max-line-length + hex"1901d4bb33110137810c444c1d9617abe97df097d587ecde64e6fcb38d7f49e1280c79f9c7295573dc135fa98d1fc9f5a01ae7e7caad046143376e34f9945288b7a0"; + bytes internal dataToSign2 = + hex"190132640243d7aade8c72f3d90d2dbf359e9897feba5fce1453bc8d9e7ba10d171579f9c7295573dc135fa98d1fc9f5a01ae7e7caad046143376e34f9945288b7a0"; + + function setUp() public { + bytes memory safeCode = Preinstalls.getDeployedCode(Preinstalls.Safe_v130, block.chainid); + vm.etch(safe1, safeCode); + vm.etch(safe2, safeCode); + vm.etch(safe3, safeCode); + vm.etch(safe4, safeCode); + vm.etch(Preinstalls.MultiCall3, Preinstalls.getDeployedCode(Preinstalls.MultiCall3, block.chainid)); + + address[] memory owners1 = new address[](1); + owners1[0] = wallet1.addr; + IGnosisSafe(safe1).setup(owners1, 1, address(0), "", address(0), address(0), 0, address(0)); + + address[] memory owners2 = new address[](1); + owners2[0] = wallet2.addr; + IGnosisSafe(safe2).setup(owners2, 1, address(0), "", address(0), address(0), 0, address(0)); + + address[] memory owners3 = new address[](2); + owners3[0] = safe1; + owners3[1] = safe2; + IGnosisSafe(safe3).setup(owners3, 2, address(0), "", address(0), address(0), 0, address(0)); + + address[] memory owners4 = new address[](1); + owners4[0] = safe3; + IGnosisSafe(safe4).setup(owners4, 1, address(0), "", address(0), address(0), 0, address(0)); + } + + function _postCheck(Vm.AccountAccess[] memory, Simulation.Payload memory) internal view override { + uint256 counterValue = counter.count(); + require(counterValue == 1, "Counter value is not 1"); + } + + function _buildCalls() internal view override returns (IMulticall3.Call3Value[] memory) { + IMulticall3.Call3Value[] memory calls = new IMulticall3.Call3Value[](1); + + calls[0] = IMulticall3.Call3Value({ + target: address(counter), allowFailure: false, callData: abi.encodeCall(Counter.increment, ()), value: 0 + }); + + return calls; + } + + function _ownerSafe() internal view override returns (address) { + return safe4; + } + + function test_sign_double_nested_safe1() external { + vm.recordLogs(); + address[] memory safes = new address[](2); + safes[0] = safe1; + safes[1] = safe3; + vm.prank(wallet1.addr); + bytes memory txData = abi.encodeWithSelector(this.sign.selector, safes); + (bool success,) = address(this).call(txData); + vm.assertTrue(success); + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(keccak256(logs[logs.length - 1].data), keccak256(abi.encode(dataToSign1))); + } + + function test_sign_double_nested_safe2() external { + vm.recordLogs(); + address[] memory safes = new address[](2); + safes[0] = safe2; + safes[1] = safe3; + vm.prank(wallet2.addr); + bytes memory txData = abi.encodeWithSelector(this.sign.selector, safes); + (bool success,) = address(this).call(txData); + vm.assertTrue(success); + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(keccak256(logs[logs.length - 1].data), keccak256(abi.encode(dataToSign2))); + } + + function test_approveInit_double_nested_safe1() external { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(wallet1, keccak256(dataToSign1)); + address[] memory safes = new address[](2); + safes[0] = safe1; + safes[1] = safe3; + approve(safes, abi.encodePacked(r, s, v)); + } + + function test_approveInit_double_nested_safe2() external { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(wallet2, keccak256(dataToSign2)); + address[] memory safes = new address[](2); + safes[0] = safe2; + safes[1] = safe3; + approve(safes, abi.encodePacked(r, s, v)); + } + + function test_approveInit_double_nested_notOwner() external { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(wallet1, keccak256(dataToSign1)); + address[] memory safes = new address[](2); + safes[0] = safe2; + safes[1] = safe3; + bytes memory data = abi.encodeCall(this.approve, (safes, abi.encodePacked(r, s, v))); + (bool success, bytes memory result) = address(this).call(data); + assertFalse(success); + assertEq(result, abi.encodeWithSignature("Error(string)", "not enough signatures")); + } + + function test_runInit_double_nested() external { + (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, keccak256(dataToSign1)); + (uint8 v2, bytes32 r2, bytes32 s2) = vm.sign(wallet2, keccak256(dataToSign2)); + address[] memory sA = new address[](2); + sA[0] = safe1; + sA[1] = safe3; + address[] memory sB = new address[](2); + sB[0] = safe2; + sB[1] = safe3; + approve(sA, abi.encodePacked(r1, s1, v1)); + approve(sB, abi.encodePacked(r2, s2, v2)); + address[] memory mid = new address[](1); + mid[0] = safe3; + approve(mid, ""); + } + + function test_runInit_double_nested_notApproved() external { + (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, keccak256(dataToSign1)); + address[] memory sA = new address[](2); + sA[0] = safe1; + sA[1] = safe3; + approve(sA, abi.encodePacked(r1, s1, v1)); + address[] memory mid = new address[](1); + mid[0] = safe3; + bytes memory data = abi.encodeCall(this.approve, (mid, "")); + (bool success, bytes memory result) = address(this).call(data); + assertFalse(success); + assertEq(result, abi.encodeWithSignature("Error(string)", "not enough signatures")); + } + + function test_run_double_nested() external { + (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, keccak256(dataToSign1)); + (uint8 v2, bytes32 r2, bytes32 s2) = vm.sign(wallet2, keccak256(dataToSign2)); + address[] memory sA = new address[](2); + sA[0] = safe1; + sA[1] = safe3; + address[] memory sB = new address[](2); + sB[0] = safe2; + sB[1] = safe3; + approve(sA, abi.encodePacked(r1, s1, v1)); + approve(sB, abi.encodePacked(r2, s2, v2)); + address[] memory mid = new address[](1); + mid[0] = safe3; + approve(mid, ""); + + run(""); + } +} + diff --git a/test/universal/MultisigScriptNested.t.sol b/test/universal/MultisigScriptNested.t.sol new file mode 100644 index 0000000..8b481e1 --- /dev/null +++ b/test/universal/MultisigScriptNested.t.sol @@ -0,0 +1,140 @@ +// 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 {MultisigScript} from "script/universal/MultisigScript.sol"; +import {Simulation} from "script/universal/Simulation.sol"; +import {IGnosisSafe} from "script/universal/IGnosisSafe.sol"; +import {Counter} from "test/universal/Counter.sol"; + +contract MultisigScriptNestedTest is Test, MultisigScript { + Vm.Wallet internal wallet1 = vm.createWallet("1"); + Vm.Wallet internal wallet2 = vm.createWallet("2"); + + address internal safe1 = address(1001); + address internal safe2 = address(1002); + address internal safe3 = address(1003); + Counter internal counter = new Counter(address(safe3)); + + bytes internal dataToSign1 = + // solhint-disable max-line-length + hex"1901d4bb33110137810c444c1d9617abe97df097d587ecde64e6fcb38d7f49e1280c5f51d24161b7d5dfddfd10cad9118e4e37e6fde740a81d2d84dc35a401b0f74c"; + bytes internal dataToSign2 = + hex"190132640243d7aade8c72f3d90d2dbf359e9897feba5fce1453bc8d9e7ba10d17155f51d24161b7d5dfddfd10cad9118e4e37e6fde740a81d2d84dc35a401b0f74c"; + + function setUp() public { + bytes memory safeCode = Preinstalls.getDeployedCode(Preinstalls.Safe_v130, block.chainid); + vm.etch(safe1, safeCode); + vm.etch(safe2, safeCode); + vm.etch(safe3, safeCode); + vm.etch(Preinstalls.MultiCall3, Preinstalls.getDeployedCode(Preinstalls.MultiCall3, block.chainid)); + + address[] memory owners1 = new address[](1); + owners1[0] = wallet1.addr; + IGnosisSafe(safe1).setup(owners1, 1, address(0), "", address(0), address(0), 0, address(0)); + + address[] memory owners2 = new address[](1); + owners2[0] = wallet2.addr; + IGnosisSafe(safe2).setup(owners2, 1, address(0), "", address(0), address(0), 0, address(0)); + + address[] memory owners3 = new address[](2); + owners3[0] = safe1; + owners3[1] = safe2; + IGnosisSafe(safe3).setup(owners3, 2, address(0), "", address(0), address(0), 0, address(0)); + } + + function _postCheck(Vm.AccountAccess[] memory, Simulation.Payload memory) internal view override { + uint256 counterValue = counter.count(); + require(counterValue == 1, "Counter value is not 1"); + } + + function _buildCalls() internal view override returns (IMulticall3.Call3Value[] memory) { + IMulticall3.Call3Value[] memory calls = new IMulticall3.Call3Value[](1); + + calls[0] = IMulticall3.Call3Value({ + target: address(counter), allowFailure: false, callData: abi.encodeCall(Counter.increment, ()), value: 0 + }); + + return calls; + } + + function _ownerSafe() internal view override returns (address) { + return address(safe3); + } + + function test_sign_safe1() external { + vm.recordLogs(); + address[] memory safes = new address[](1); + safes[0] = safe1; + vm.prank(wallet1.addr); + bytes memory txData = abi.encodeWithSelector(this.sign.selector, safes); + (bool success,) = address(this).call(txData); + vm.assertTrue(success); + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(keccak256(logs[logs.length - 1].data), keccak256(abi.encode(dataToSign1))); + } + + function test_sign_safe2() external { + vm.recordLogs(); + address[] memory safes = new address[](1); + safes[0] = safe2; + vm.prank(wallet2.addr); + bytes memory txData = abi.encodeWithSelector(this.sign.selector, safes); + (bool success,) = address(this).call(txData); + vm.assertTrue(success); + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(keccak256(logs[logs.length - 1].data), keccak256(abi.encode(dataToSign2))); + } + + function test_approve_safe1() external { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(wallet1, keccak256(dataToSign1)); + address[] memory safes = new address[](1); + safes[0] = safe1; + approve(safes, abi.encodePacked(r, s, v)); + } + + function test_approve_safe2() external { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(wallet2, keccak256(dataToSign2)); + address[] memory safes = new address[](1); + safes[0] = safe2; + approve(safes, abi.encodePacked(r, s, v)); + } + + function test_approve_notOwner() external { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(wallet1, keccak256(dataToSign1)); + address[] memory safes = new address[](1); + safes[0] = safe2; + bytes memory data = abi.encodeCall(this.approve, (safes, abi.encodePacked(r, s, v))); + (bool success, bytes memory result) = address(this).call(data); + assertFalse(success); + assertEq(result, abi.encodeWithSignature("Error(string)", "not enough signatures")); + } + + function test_run() external { + (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, keccak256(dataToSign1)); + (uint8 v2, bytes32 r2, bytes32 s2) = vm.sign(wallet2, keccak256(dataToSign2)); + address[] memory arr1 = new address[](1); + arr1[0] = safe1; + address[] memory arr2 = new address[](1); + arr2[0] = safe2; + approve(arr1, abi.encodePacked(r1, s1, v1)); + approve(arr2, abi.encodePacked(r2, s2, v2)); + run(""); + } + + function test_run_notApproved() external { + (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, keccak256(dataToSign1)); + address[] memory arr1 = new address[](1); + arr1[0] = safe1; + approve(arr1, abi.encodePacked(r1, s1, v1)); + bytes memory data = abi.encodeCall(this.run, ("")); + (bool success, bytes memory result) = address(this).call(data); + assertFalse(success); + assertEq(result, abi.encodeWithSignature("Error(string)", "not enough signatures")); + } +} + diff --git a/test/utils/CBMulticall.t.sol b/test/utils/CBMulticall.t.sol index 606db33..617956f 100644 --- a/test/utils/CBMulticall.t.sol +++ b/test/utils/CBMulticall.t.sol @@ -5,14 +5,33 @@ import {CBMulticall} from "src/utils/CBMulticall.sol"; import {CommonTest} from "test/CommonTest.t.sol"; import {MockReceiver} from "test/mocks/MockReceiver.sol"; +/// @dev Helper contract used to invoke `aggregateDelegateCalls` via `delegatecall`. +/// This simulates the intended multisig usage pattern where the multicall +/// logic is executed in the context of another contract. +contract CBMulticallDelegateCaller { + CBMulticall public mc; + + constructor(CBMulticall _mc) { + mc = _mc; + } + + function aggregateDelegateCalls(CBMulticall.Call3[] calldata calls) external returns (CBMulticall.Result[] memory) { + (, bytes memory data) = + address(mc).delegatecall(abi.encodeWithSelector(CBMulticall.aggregateDelegateCalls.selector, calls)); + return abi.decode(data, (CBMulticall.Result[])); + } +} + contract CBMulticallTest is CommonTest { CBMulticall mc; MockReceiver target; + CBMulticallDelegateCaller delegateCaller; function setUp() public override { super.setUp(); mc = new CBMulticall(); target = new MockReceiver(); + delegateCaller = new CBMulticallDelegateCaller(mc); } function test_aggregate_returnsBlockNumberAndData() external { @@ -160,6 +179,52 @@ contract CBMulticallTest is CommonTest { mc.aggregate3(calls3); } + function test_aggregateDelegateCalls_success() external { + CBMulticall.Call3[] memory calls3 = new CBMulticall.Call3[](1); + calls3[0] = CBMulticall.Call3({ + target: address(target), + allowFailure: false, + callData: abi.encodeWithSelector(MockReceiver.bump.selector, 4) + }); + CBMulticall.Result[] memory ret3 = delegateCaller.aggregateDelegateCalls(calls3); + assertTrue(ret3[0].success); + assertEq(abi.decode(ret3[0].returnData, (uint256)), 5); + } + + function test_aggregateDelegateCalls_allowedFailure_returnsFalse() external { + CBMulticall.Call3[] memory calls3 = new CBMulticall.Call3[](1); + calls3[0] = CBMulticall.Call3({ + target: address(target), + allowFailure: true, + callData: abi.encodeWithSelector(MockReceiver.willRevert.selector) + }); + CBMulticall.Result[] memory ret3 = delegateCaller.aggregateDelegateCalls(calls3); + assertFalse(ret3[0].success); + } + + function test_aggregateDelegateCalls_revertsOnNonAllowedFailure() external { + CBMulticall.Call3[] memory calls3 = new CBMulticall.Call3[](1); + calls3[0] = CBMulticall.Call3({ + target: address(target), + allowFailure: false, + callData: abi.encodeWithSelector(MockReceiver.willRevert.selector) + }); + vm.expectRevert(); + delegateCaller.aggregateDelegateCalls(calls3); + } + + function test_aggregateDelegateCalls_directCall_revertsWithMustDelegateCall() external { + CBMulticall.Call3[] memory calls3 = new CBMulticall.Call3[](1); + calls3[0] = CBMulticall.Call3({ + target: address(target), + allowFailure: false, + callData: abi.encodeWithSelector(MockReceiver.bump.selector, 1) + }); + + vm.expectRevert(CBMulticall.MustDelegateCall.selector); + mc.aggregateDelegateCalls(calls3); + } + function test_aggregate3Value_success_usesContractBalance() external { vm.deal(address(mc), 1 ether); CBMulticall.Call3Value[] memory callsV = new CBMulticall.Call3Value[](1);