Skip to content
34 changes: 33 additions & 1 deletion script/universal/MultisigScript.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

//////////////////////////////////////////////////////////////////////////////////////
Expand Down Expand Up @@ -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--) {
Expand All @@ -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
Expand Down
57 changes: 54 additions & 3 deletions src/utils/CBMulticall.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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)
Expand All @@ -134,6 +183,7 @@ contract CBMulticall {
revert(0x00, 0x64)
}
}
returnData[i] = result;
unchecked {
++i;
}
Expand All @@ -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);
Expand All @@ -167,6 +217,7 @@ contract CBMulticall {
revert(0x00, 0x64)
}
}
returnData[i] = result;
unchecked {
++i;
}
Expand Down
189 changes: 189 additions & 0 deletions test/universal/MultisigScript.t.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading