From c244f83a04a1bac8100ae72c3002c578e61b19ae Mon Sep 17 00:00:00 2001 From: Baptiste Oueriagli Date: Mon, 24 Nov 2025 10:18:01 +0000 Subject: [PATCH 1/9] refactor MultisigScript --- script/universal/MultisigScriptV2.sol | 939 ++++++++++++++++++++++++++ test/universal/MultisigScriptV2.t.sol | 196 ++++++ 2 files changed, 1135 insertions(+) create mode 100644 script/universal/MultisigScriptV2.sol create mode 100644 test/universal/MultisigScriptV2.t.sol diff --git a/script/universal/MultisigScriptV2.sol b/script/universal/MultisigScriptV2.sol new file mode 100644 index 0000000..387bf9d --- /dev/null +++ b/script/universal/MultisigScriptV2.sol @@ -0,0 +1,939 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +// solhint-disable no-console +import {console} from "lib/forge-std/src/console.sol"; +import {IMulticall3} from "lib/forge-std/src/interfaces/IMulticall3.sol"; +import {Script} from "lib/forge-std/src/Script.sol"; +import {Vm} from "lib/forge-std/src/Vm.sol"; + +import {CBMulticall} from "../../src/utils/CBMulticall.sol"; + +import {IGnosisSafe, Enum} from "./IGnosisSafe.sol"; +import {Signatures} from "./Signatures.sol"; +import {Simulation} from "./Simulation.sol"; + +/// @title MultisigScript +/// @notice Script builder for Forge scripts that require signatures from Safes. Supports both non-nested +/// Safes, as well as nested Safes of arbitrary depth (Safes where the signers are other Safes). +/// +/// 1. Non-nested example: +/// +/// Setup: +/// ┌───────┐┌───────┐ +/// │Signer1││Signer2│ +/// └┬──────┘└┬──────┘ +/// ┌▽────────▽┐ +/// │Multisig │ +/// └┬─────────┘ +/// ┌▽─────────┐ +/// │ProxyAdmin│ +/// └──────────┘ +/// +/// Sequence: +/// ┌───────┐┌───────┐┌───────────┐┌──────────────┐ +/// │Signer1││Signer2││Facilitator││MultisigScript│ +/// └───┬───┘└───┬───┘└─────┬─────┘└───────┬──────┘ +/// │ │ sign() │ +/// │─────────────────────────────────>│ +/// │ │ │ +/// │──────────────────>│ │ +/// │ │ sign() │ +/// │ │────────────────────────>│ +/// │ │ │ │ +/// │ │─────────>│ │ +/// │ │ │run(sig1,sig2)│ +/// │ │ │─────────────>│ +/// +/// +/// 2. Single-layer nested example: +/// +/// Setup: +/// ┌───────┐┌───────┐┌───────┐┌───────┐ +/// │Signer1││Signer2││Signer3││Signer4│ +/// └┬──────┘└┬──────┘└┬──────┘└┬──────┘ +/// ┌▽────────▽┐┌──────▽────────▽┐ +/// │Safe1 ││Safe2 │ +/// └┬─────────┘└┬───────────────┘ +/// ┌▽───────────▽┐ +/// │Safe3 │ +/// └┬────────────┘ +/// ┌▽─────────┐ +/// │ProxyAdmin│ +/// └──────────┘ +/// +/// Sequence: +/// ┌───────┐┌───────┐┌───────┐┌───────┐┌───────────┐ ┌──────────────┐ +/// │Signer1││Signer2││Signer3││Signer4││Facilitator│ │MultisigScript│ +/// └───┬───┘└───┬───┘└───┬───┘└───┬───┘└─────┬─────┘ └───────┬──────┘ +/// │ │ │ sign(Safe1) │ │ +/// │─────────────────────────────────────────────────────────────>│ +/// │ │ │ │ │ +/// │────────────────────────────────────>│ │ +/// │ │ │ │ sign(Safe1) │ +/// │ │────────────────────────────────────────────────────>│ +/// │ │ │ │ │ │ +/// │ │───────────────────────────>│ │ +/// │ │ │ │ │approve(Safe1,sig1|sig2)│ +/// │ │ │ │ │───────────────────────>│ +/// │ │ │ │ sign(Safe2) │ +/// │ │ │───────────────────────────────────────────>│ +/// │ │ │ │ │ +/// │ │ │──────────────────>│ │ +/// │ │ │ │ │ sign(Safe2) │ +/// │ │ │ │──────────────────────────────────>│ +/// │ │ │ │ │ │ +/// │ │ │ │─────────>│ │ +/// │ │ │ │ │approve(Safe2,sig3|sig4)│ +/// │ │ │ │ │───────────────────────>│ +/// │ │ │ │ │ run() │ +/// │ │ │ │ │───────────────────────>│ +/// +/// +/// 3. Multi-layer nested example: +/// +/// Setup: +/// ┌───────┐┌───────┐┌───────┐┌───────┐┌───────┐┌───────┐ +/// │Signer1││Signer2││Signer3││Signer4││Signer5││Signer6│ +/// └┬──────┘└┬──────┘└┬──────┘└┬──────┘└┬──────┘└┬──────┘ +/// ┌▽────────▽┐┌──────▽────────▽┐┌──────▽────────▽┐ +/// │Safe1 ││Safe2 ││Safe3 │ +/// └┬─────────┘└┬───────────────┘└┬───────────────┘ +/// ┌▽───────────▽┐ │ +/// │Safe4 │ │ +/// └┬────────────┘ │ +/// ┌▽─────────────────────────────▽┐ +/// │Safe5 │ +/// └┬──────────────────────────────┘ +/// ┌▽─────────┐ +/// │ProxyAdmin│ +/// └──────────┘ +/// +/// Sequence: +/// ┌───────┐┌───────┐┌───────┐┌───────┐┌───────┐┌───────┐┌───────────┐ ┌──────────────┐ +/// │Signer1││Signer2││Signer3││Signer4││Signer5││Signer6││Facilitator│ │MultisigScript│ +/// └───┬───┘└───┬───┘└───┬───┘└───┬───┘└───┬───┘└───┬───┘└─────┬─────┘ └───────┬──────┘ +/// │ │ │ │ sign(Safe1,Safe4) │ │ +/// │─────────────────────────────────────────────────────────────────────────────────────>│ +/// │ │ │ │ │ │ │ +/// │──────────────────────────────────────────────────────>│ │ +/// │ │ │ │ │ sign(Safe1,Safe4) │ +/// │ │────────────────────────────────────────────────────────────────────────────>│ +/// │ │ │ │ │ │ │ │ +/// │ │─────────────────────────────────────────────>│ │ +/// │ │ │ │ │ │ │approve(Safe1,Safe4,sig1|sig2)│ +/// │ │ │ │ │ │ │─────────────────────────────>│ +/// │ │ │ │ │ sign(Safe2,Safe4) │ +/// │ │ │───────────────────────────────────────────────────────────────────>│ +/// │ │ │ │ │ │ │ +/// │ │ │────────────────────────────────────>│ │ +/// │ │ │ │ │ │ sign(Safe2,Safe4) │ +/// │ │ │ │──────────────────────────────────────────────────────────>│ +/// │ │ │ │ │ │ │ │ +/// │ │ │ │───────────────────────────>│ │ +/// │ │ │ │ │ │ │approve(Safe2,Safe4,sig3|sig4)│ +/// │ │ │ │ │ │ │─────────────────────────────>│ +/// │ │ │ │ │ │ │ approve(Safe4) │ +/// │ │ │ │ │ │ │─────────────────────────────>│ +/// │ │ │ │ │ │ sign(Safe3) │ +/// │ │ │ │ │─────────────────────────────────────────────────>│ +/// │ │ │ │ │ │ │ +/// │ │ │ │ │──────────────────>│ │ +/// │ │ │ │ │ │ │ sign(Safe3) │ +/// │ │ │ │ │ │────────────────────────────────────────>│ +/// │ │ │ │ │ │ │ │ +/// │ │ │ │ │ │─────────>│ │ +/// │ │ │ │ │ │ │ approve(Safe3,sig5|sig6) │ +/// │ │ │ │ │ │ │─────────────────────────────>│ +/// │ │ │ │ │ │ │ run() │ +/// │ │ │ │ │ │ │─────────────────────────────>│ +abstract contract MultisigScriptV2 is Script { + bytes32 internal constant SAFE_NONCE_SLOT = bytes32(uint256(5)); + address internal constant CB_MULTICALL = 0xA8B8CA1d6F0F5Ce63dCEA9121A01b302c5801303; + + /// @notice A struct representing a call to a contract. + /// + /// @param operation The operation to perform on the contract. + /// @param target The address of the contract to call. + /// @param data The data to call the contract with. + /// @param value The value to send with the call. + struct Call { + Enum.Operation operation; + address target; + bytes data; + uint256 value; + } + + /// @notice The available types of for call3 calls. + enum Call3Type { + DELEGATE_CALL, + CALL, + CALL_VALUE + } + + /// @dev Event emitted from a `sign()` call containing the data to sign. Used in testing. + event DataToSign(bytes data); + + ////////////////////////////////////////////////////////////////////////////////////// + /// Virtual Functions /// + ////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Returns the safe address to execute the final transaction from + function _ownerSafe() internal view virtual returns (address); + + /// @notice Creates the calldata for signatures (`sign`), approvals (`approve`), and execution (`run`) + function _buildCalls() internal view virtual returns (Call[] memory); + + /// @notice Follow up assertions to ensure that the script ran to completion. + /// @dev Called after `sign` and `run`, but not `approve`. + function _postCheck(Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) internal virtual; + + /// @notice Follow up assertions on state and simulation after a `sign` call. + function _postSign(Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) internal virtual {} + + /// @notice Follow up assertions on state and simulation after a `approve` call. + function _postApprove(Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) internal virtual {} + + /// @notice Follow up assertions on state and simulation after a `run` call. + function _postRun(Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) internal virtual {} + + // Tenderly simulations can accept generic state overrides. This hook enables this functionality. + // By default, an empty (no-op) override is returned. + function _simulationOverrides() internal view virtual returns (Simulation.StateOverride[] memory overrides_) {} + + ////////////////////////////////////////////////////////////////////////////////////// + /// Public Functions /// + ////////////////////////////////////////////////////////////////////////////////////// + + /// Step 1 + /// ====== + /// Generate a transaction approval data to sign. This method should be called by a threshold of + /// multisig owners. + /// + /// For non-nested multisigs, the signatures can then be used to execute the transaction (see step 3). + /// + /// For nested multisigs, the signatures can be used to execute an approval transaction for each + /// multisig (see step 2). + /// + /// @param safes A list of nested safes (excluding the executing safe returned by `_ownerSafe`). + function sign(address[] memory safes) public { + safes = _appendOwnerSafe({safes: safes}); + + // Snapshot and restore Safe nonce after simulation, otherwise the data logged to sign + // would not match the actual data we need to sign, because the simulation + // would increment the nonce. + uint256[] memory originalNonces = new uint256[](safes.length); + for (uint256 i; i < safes.length; i++) { + originalNonces[i] = _getNonce({safe: safes[i]}); + } + + Call[] memory callsChain = _buildCallsChain({safes: safes}); + (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = + _simulateForSigner({safes: safes, callsChain: callsChain}); + + _postSign({accesses: accesses, simPayload: simPayload}); + _postCheck({accesses: accesses, simPayload: simPayload}); + + // Restore the original nonce. + for (uint256 i; i < safes.length; i++) { + vm.store({target: safes[i], slot: SAFE_NONCE_SLOT, value: bytes32(originalNonces[i])}); + } + _printDataToSign({safe: safes[0], call: callsChain[0]}); + } + + /// Step 1.1 (optional) + /// ====== + /// Verify the signatures generated from step 1 are valid. + /// This allows transactions to be pre-signed and stored safely before execution. + /// + /// @param safes A list of nested safes (excluding the executing safe returned by `_ownerSafe`). + /// @param signatures The signatures to verify (concatenated, 65-bytes per sig). + function verify(address[] memory safes, bytes memory signatures) public view { + safes = _appendOwnerSafe({safes: safes}); + + Call[] memory callsChain = _buildCallsChain({safes: safes}); + _checkSignatures({safe: safes[0], call: callsChain[0], signatures: signatures}); + } + + /// Step 2 (optional for non-nested setups) + /// ====== + /// Execute an approval transaction. This method should be called by a facilitator + /// (non-signer), once for each of the multisigs involved in the nested multisig, + /// after collecting a threshold of signatures for each multisig (see step 1). + /// + /// For multiple layers of nesting, this should be called for each layer of nesting (once + /// the inner multisigs have registered their approval). The array of safes passed to + /// `safes` should get smaller by one for each layer of nesting. + /// + /// @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 { + safes = _appendOwnerSafe({safes: safes}); + + Call[] memory callsChain = _buildCallsChain({safes: safes}); + (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = + _executeTransaction({safe: safes[0], call: callsChain[0], signatures: signatures, broadcast: true}); + + _postApprove({accesses: accesses, simPayload: simPayload}); + } + + /// Step 2.1 (optional) + /// ====== + /// Simulate the transaction. This method should be called by a facilitator (non-signer), after all of the + /// signatures have been collected (non-nested case, see step 1), or the approval transactions have been + /// submitted onchain (nested case, see step 2, in which case `signatures` can be empty). + /// + /// 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 { + address ownerSafe = _ownerSafe(); + Call[] memory callsChain = _buildCallsChain({safes: _toArray(ownerSafe)}); + + vm.store({target: ownerSafe, slot: SAFE_NONCE_SLOT, value: bytes32(_getNonce({safe: ownerSafe}))}); + + (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = + _executeTransaction({safe: ownerSafe, call: callsChain[0], signatures: signatures, broadcast: false}); + + _postRun({accesses: accesses, simPayload: simPayload}); + _postCheck({accesses: accesses, simPayload: simPayload}); + } + + /// Step 3 + /// ====== + /// Execute the transaction. This method should be called by a facilitator (non-signer), after all of the + /// signatures have been collected (non-nested case, see step 1), or the approval transactions have been + /// 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 { + address ownerSafe = _ownerSafe(); + Call[] memory callsChain = _buildCallsChain({safes: _toArray(ownerSafe)}); + + (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = + _executeTransaction({safe: ownerSafe, call: callsChain[0], signatures: signatures, broadcast: true}); + + _postRun({accesses: accesses, simPayload: simPayload}); + _postCheck({accesses: accesses, simPayload: simPayload}); + } + + ////////////////////////////////////////////////////////////////////////////////////// + /// Internal Functions /// + ////////////////////////////////////////////////////////////////////////////////////// + + /// @notice Appends the owner safe to the list of safes. + /// + /// @param safes The list of safes to append the owner safe to. + /// + /// @return The list of safes with the owner safe appended. + function _appendOwnerSafe(address[] memory safes) internal view returns (address[] memory) { + address[] memory extendedSafes = new address[](safes.length + 1); + for (uint256 i; i < safes.length; i++) { + extendedSafes[i] = safes[i]; + } + extendedSafes[extendedSafes.length - 1] = _ownerSafe(); + return extendedSafes; + } + + /// @notice Wrapper around `_buildCalls` that checks that the script calls are valid. + /// + /// @return The list of calls. + function _buildCallsChecked() internal view returns (Call[] memory) { + Call[] memory scriptCalls = _buildCalls(); + for (uint256 i; i < scriptCalls.length; i++) { + Call memory call = scriptCalls[i]; + + require( + call.operation == Enum.Operation.Call || call.value == 0, + "MultisigScript::_buildCallsChecked: Value must be 0 for delegate calls" + ); + } + + return scriptCalls; + } + + /// @notice Build the list of safe-to-safe approval calls followed by the final script call. + /// + /// @param safes The list of safes to build the calls chain for. + /// + /// @return callsChain The calls chain for the given safes. + function _buildCallsChain(address[] memory safes) internal view returns (Call[] memory callsChain) { + // Build the script calls + Call[] memory scriptCalls = _buildCallsChecked(); + + // When there are multiple calls, we aggregate them into a single `aggregate3` call that will be executed via a + // delegate call to the CB_MULTICALL contract. + Call memory aggregatedScriptCall = _buildAggregatedScriptCall({scriptCalls: scriptCalls}); + console.logBytes(aggregatedScriptCall.data); + + // The very last call is the actual call to execute + callsChain = new Call[](safes.length); + callsChain[callsChain.length - 1] = aggregatedScriptCall; + + // The first n-1 calls are the nested approval calls. We build the approvals backwards, starting from the last safe. + for (uint256 i = safes.length - 1; i > 0; i--) { + address safe = safes[i]; + Call memory callToApprove = callsChain[i]; + + callsChain[i - 1] = _buildApproveCall({safe: safe, call: callToApprove}); + } + } + + /// @notice Builds the aggregated script call. + /// + /// @param scriptCalls The list of script calls to aggregate. + /// + /// @return The aggregated script call. + function _buildAggregatedScriptCall(Call[] memory scriptCalls) internal pure returns (Call memory) { + // When there is only one call, we return it directly as there is no need to aggregate it into a Multicall call. + if (scriptCalls.length == 1) { + return scriptCalls[0]; + } + + CBMulticall.Call3[] memory rootCalls = new CBMulticall.Call3[](scriptCalls.length); + uint256 rootCallsIndex; + + Call[] memory currentGroup = new Call[](scriptCalls.length); + currentGroup[0] = scriptCalls[0]; + uint256 currentGroupIndex; + + for (uint256 i; i < scriptCalls.length; i++) { + Call memory currentCall = scriptCalls[i]; + Call3Type currentType = _getCall3Type(currentCall); + Call3Type groupType = _getCall3Type(currentGroup[0]); + + // If the current call has the same type as the current group, add it to the current group and continue. + if (groupType == currentType) { + currentGroup[currentGroupIndex] = currentCall; + currentGroupIndex++; + continue; + } + + // Consume the current group and append the calls to the root calls. + rootCallsIndex += _aggregateCalls({ + groupType: groupType, + rootCalls: rootCalls, + rootCallsIndex: rootCallsIndex, + currentGroup: currentGroup, + currentGroupIndex: currentGroupIndex + }); + + // Reset the current group (for the next group) + currentGroup[0] = currentCall; + currentGroupIndex = 1; + } + + // Process the final group left in the current group. + rootCallsIndex += _aggregateCalls({ + groupType: _getCall3Type(currentGroup[0]), + rootCalls: rootCalls, + rootCallsIndex: rootCallsIndex, + currentGroup: currentGroup, + currentGroupIndex: currentGroupIndex + }); + + return Call({ + operation: Enum.Operation.DelegateCall, + target: CB_MULTICALL, + data: abi.encodeCall(CBMulticall.aggregateDelegateCalls, (rootCalls)), + value: 0 + }); + } + + /// @notice Aggregates the current group of calls into a single Multicall call. + /// + /// @param groupType The type of the current group. + /// @param rootCalls The root calls to append the calls to. + /// @param rootCallsIndex The index of the root calls to append the calls to. + /// @param currentGroup The current group of calls to consume. + /// @param currentGroupIndex The index of the current group. + /// + /// @return rootCallsCount The number of root calls appended. + function _aggregateCalls( + Call3Type groupType, + CBMulticall.Call3[] memory rootCalls, + uint256 rootCallsIndex, + Call[] memory currentGroup, + uint256 currentGroupIndex + ) internal pure returns (uint256 rootCallsCount) { + uint256 rootCallsIndexSaved = rootCallsIndex; + + // Append the call3 delegate calls directly to the root calls. + if (groupType == Call3Type.DELEGATE_CALL) { + for (uint256 j; j < currentGroupIndex; j++) { + rootCalls[rootCallsIndex] = _toDelegateCall3(currentGroup[j]); + rootCallsIndex++; + } + } + // Otherwise aggregate the calls into a single Multicall call. + else { + CBMulticall.Call3 memory rootCall; + + if (groupType == Call3Type.CALL) { + CBMulticall.Call3[] memory call3s = new CBMulticall.Call3[](currentGroupIndex); + for (uint256 j; j < currentGroupIndex; j++) { + call3s[j] = _toCall3(currentGroup[j]); + } + + rootCall = CBMulticall.Call3({ + target: CB_MULTICALL, + allowFailure: false, + callData: abi.encodeCall(CBMulticall.aggregate3, (call3s)) + }); + } else { + CBMulticall.Call3Value[] memory call3Values = new CBMulticall.Call3Value[](currentGroupIndex); + for (uint256 j; j < currentGroupIndex; j++) { + call3Values[j] = _toCall3Value(currentGroup[j]); + } + + rootCall = CBMulticall.Call3({ + target: CB_MULTICALL, + allowFailure: false, + callData: abi.encodeCall(CBMulticall.aggregate3Value, (call3Values)) + }); + } + + rootCalls[rootCallsIndex] = rootCall; + rootCallsIndex++; + } + + // Return the number of root calls appended. + rootCallsCount = rootCallsIndex - rootCallsIndexSaved; + } + + /// @notice Builds the approve call (`approveHash`) for the given safe and call. + /// + /// @param safe The address of the safe to approve. + /// @param call The call to approve. + /// + /// @return The approve call. + function _buildApproveCall(address safe, Call memory call) internal view returns (Call memory) { + bytes32 hash = _getTransactionHash({safe: safe, call: call}); + + console.log("---\nNested hash for safe %s:", safe); + console.logBytes32(hash); + + return Call({ + operation: Enum.Operation.Call, + target: safe, + data: abi.encodeCall(IGnosisSafe(safe).approveHash, (hash)), + value: 0 + }); + } + + /// @notice Prints the data to sign for the given safe and call. + /// + /// @param safe The address of the safe to print the data to sign for. + /// @param call The call to print the data to sign for. + function _printDataToSign(address safe, Call memory call) internal { + bytes memory txData = _encodeTransactionData({safe: safe, call: call}); + emit DataToSign({data: txData}); + + console.log("---\nIf submitting onchain, call Safe.approveHash on %s with the following hash:", safe); + console.logBytes32(_getTransactionHash({safe: safe, call: call})); + + console.log("---\nData to sign:"); + console.log("vvvvvvvv"); + console.logBytes(txData); + console.log("^^^^^^^^\n"); + + console.log("########## IMPORTANT ##########"); + console.log( + // solhint-disable-next-line max-line-length + "Please make sure that the 'Data to sign' displayed above matches what you see in the simulation and on your hardware wallet." + ); + console.log("This is a critical step that must not be skipped."); + console.log("###############################"); + } + + /// @notice Executes the given transaction. + /// + /// @param safe The address of the safe to execute the transaction from. + /// @param call The call to execute. + /// @param signatures The signatures to use for the transaction. + /// @param broadcast Whether to broadcast the transaction. + /// + /// @return The account accesses and simulation payload. + function _executeTransaction(address safe, Call memory call, bytes memory signatures, bool broadcast) + internal + returns (Vm.AccountAccess[] memory, Simulation.Payload memory) + { + bytes32 hash = _getTransactionHash({safe: safe, call: call}); + signatures = Signatures.prepareSignatures({safe: safe, hash: hash, signatures: signatures}); + + Call memory simCall = _buildExecTransactionCall({safe: safe, call: call, signatures: signatures}); + Simulation.logSimulationLink({to: safe, data: simCall.data, from: msg.sender}); + + vm.startStateDiffRecording(); + bool success = _execTransaction({safe: safe, call: call, signatures: signatures, broadcast: broadcast}); + Vm.AccountAccess[] memory accesses = vm.stopAndReturnStateDiff(); + require(success, "MultisigScript::_executeTransaction: Transaction failed"); + require(accesses.length > 0, "MultisigScript::_executeTransaction: No state changes"); + + // This can be used to e.g. call out to the Tenderly API and get additional + // data about the state diff before broadcasting the transaction. + Simulation.Payload memory simPayload = Simulation.Payload({ + from: msg.sender, + to: safe, + data: simCall.data, + stateOverrides: new Simulation.StateOverride[](0) + }); + return (accesses, simPayload); + } + + /// @notice Simulates the given `callsChain` associated to the given `safes` as if initiated by `msg.sender`. + /// + /// @param safes The list of safes to simulate the transaction for. + /// @param callsChain The list of calls to simulate the transaction for. + /// + /// @return The account accesses and simulation payload. + function _simulateForSigner(address[] memory safes, Call[] memory callsChain) + internal + returns (Vm.AccountAccess[] memory, Simulation.Payload memory) + { + // Define the state overrides for the simulation. + bytes32 firstCallDataHash = _getTransactionHash({safe: safes[0], call: callsChain[0]}); + Simulation.StateOverride[] memory overrides = _overrides({safes: safes, firstCallDataHash: firstCallDataHash}); + + // Build the execution calls chain for all the safe-to-safe approvals followed by the final script call. + Call[] memory executionCallsChain = _buildExecutionCalls({safes: safes, callsChain: callsChain}); + bytes memory txData = abi.encodeCall(CBMulticall.aggregate3, (_toCall3s(executionCallsChain))); + console.logBytes(txData); + + console.log("---\nSimulation link:"); + Simulation.logSimulationLink({to: CB_MULTICALL, data: txData, from: msg.sender, overrides: overrides}); + + // Forge simulation of the data logged in the link. If the simulation fails we revert to make it explicit that the simulation failed. + Simulation.Payload memory simPayload = + Simulation.Payload({to: CB_MULTICALL, data: txData, from: msg.sender, stateOverrides: overrides}); + Vm.AccountAccess[] memory accesses = Simulation.simulateFromSimPayload({simPayload: simPayload}); + return (accesses, simPayload); + } + + /// @notice Wraps each of the given calls in a `execTransaction` call. + /// + /// @param safes The list of safes to execute the calls from. + /// @param callsChain The list of calls to wrap in a `execTransaction` call. + /// + /// @return executionCalls The list of `execTransaction` calls. + function _buildExecutionCalls(address[] memory safes, Call[] memory callsChain) + internal + view + returns (Call[] memory executionCalls) + { + require( + safes.length == callsChain.length, + "MultisigScript::_buildExecutionCalls: Safes and callsChain must have the same length" + ); + + executionCalls = new Call[](safes.length); + for (uint256 i; i < safes.length; i++) { + address signer = i == 0 ? msg.sender : safes[i - 1]; + + executionCalls[i] = _buildExecTransactionCall({ + safe: safes[i], + call: callsChain[i], + signatures: Signatures.genPrevalidatedSignature(signer) + }); + } + } + + // The state change simulation can set the threshold, owner address and/or nonce. + // This allows simulation of the final transaction by overriding the threshold to 1. + // State changes reflected in the simulation as a result of these overrides will + // not be reflected in the prod execution. + function _overrides(address[] memory safes, bytes32 firstCallDataHash) + internal + view + returns (Simulation.StateOverride[] memory) + { + Simulation.StateOverride[] memory simOverrides = _simulationOverrides(); + Simulation.StateOverride[] memory overrides = new Simulation.StateOverride[](safes.length + simOverrides.length); + + uint256 nonce = _getNonce({safe: safes[0]}); + overrides[0] = Simulation.overrideSafeThresholdApprovalAndNonce({ + safe: safes[0], + nonce: nonce, + owner: msg.sender, + dataHash: firstCallDataHash + }); + + for (uint256 i = 1; i < safes.length; i++) { + overrides[i] = + Simulation.overrideSafeThresholdAndNonce({safe: safes[i], nonce: _getNonce({safe: safes[i]})}); + } + + for (uint256 i; i < simOverrides.length; i++) { + overrides[i + safes.length] = simOverrides[i]; + } + + return overrides; + } + + // Get the nonce to use for the given safe, for signing and simulations. + // + // If you override it, ensure that the behavior is correct for all contexts. + // As an example, if you are pre-signing a message that needs safe.nonce+1 (before + // safe.nonce is executed), you should explicitly set the nonce value with an env var. + // Overriding this method with safe.nonce+1 will cause issues upon execution because + // the transaction hash will differ from the one signed. + // + // The process for determining a nonce override is as follows: + // 1. We look for an env var of the name SAFE_NONCE_{UPPERCASE_SAFE_ADDRESS}. For example, + // SAFE_NONCE_0X6DF4742A3C28790E63FE933F7D108FE9FCE51EA4. + // 2. If it exists, we use it as the nonce override for the safe. + // 3. If it does not exist, we do the same for the SAFE_NONCE env var. + // 4. Otherwise we fallback to the safe's current nonce (no override). + function _getNonce(address safe) internal view virtual returns (uint256 nonce) { + uint256 safeNonce = IGnosisSafe(safe).nonce(); + nonce = safeNonce; + + // first try SAFE_NONCE + try vm.envUint({name: "SAFE_NONCE"}) { + nonce = vm.envUint({name: "SAFE_NONCE"}); + } catch {} + + // then try SAFE_NONCE_{UPPERCASE_SAFE_ADDRESS} + string memory envVarName = string.concat("SAFE_NONCE_", vm.toUppercase({input: vm.toString({value: safe})})); + try vm.envUint({name: envVarName}) { + nonce = vm.envUint({name: envVarName}); + } catch {} + + // print if any override + if (nonce != safeNonce) { + console.log("Overriding nonce for safe %s: %d -> %d", safe, safeNonce, nonce); + } + } + + /// @notice Returns the result of `encodeTransactionData` function from the given safe for the given call. + /// + /// @param safe The address of the safe that will execute the transaction. + /// @param call The call to get the encoded transaction data for. + /// + /// @return The result of `encodeTransactionData` function from the given safe for the given call. + function _encodeTransactionData(address safe, Call memory call) internal view returns (bytes memory) { + return IGnosisSafe(safe).encodeTransactionData({ + to: call.target, + value: call.value, + data: call.data, + operation: call.operation, + safeTxGas: 0, + baseGas: 0, + gasPrice: 0, + gasToken: address(0), + refundReceiver: address(0), + _nonce: _getNonce(safe) + }); + } + + /// @notice Checks the signatures for the given safe and call. + /// + /// @param safe The address of the safe to check the signatures for. + /// @param call The call to check the signatures for. + /// @param signatures The signatures to check. + function _checkSignatures(address safe, Call memory call, bytes memory signatures) internal view { + bytes32 hash = _getTransactionHash({safe: safe, call: call}); + signatures = Signatures.prepareSignatures({safe: safe, hash: hash, signatures: signatures}); + + IGnosisSafe(safe).checkSignatures({ + dataHash: hash, + data: _encodeTransactionData({safe: safe, call: call}), // NOTE: This field is the data preimage but not strictly required as `checkSignatures` ignores it. + signatures: signatures + }); + } + + /// @notice Gets the transaction hash for the given safe and call. + /// + /// @param safe The address of the safe that will execute the transaction. + /// @param call The call to get the transaction hash for. + /// + /// @return The transaction hash for the given safe and call. + function _getTransactionHash(address safe, Call memory call) internal view returns (bytes32) { + return keccak256(_encodeTransactionData({safe: safe, call: call})); + } + + /// @notice Wrapps the given `call` in a `execTransaction` call. + /// + /// @param safe The address of the safe to execute the transaction from. + /// @param call The call to execute. + /// @param signatures The signatures to use for the transaction. + /// + /// @return The execTransaction call. + function _buildExecTransactionCall(address safe, Call memory call, bytes memory signatures) + internal + pure + returns (Call memory) + { + return Call({ + operation: Enum.Operation.Call, + target: safe, + data: abi.encodeCall( + IGnosisSafe(safe).execTransaction, + ( + call.target, // to + call.value, // value + call.data, // data + call.operation, // operation + 0, // safeTxGas + 0, // baseGas + 0, // gasPrice + address(0), // gasToken + payable(address(0)), // refundReceiver + signatures // signatures + ) + ), + value: 0 + }); + } + + /// @notice Executes the given call from the given safe. + /// + /// @param safe The address of the safe to execute the call from. + /// @param call The call to execute. + /// @param signatures The signatures to use for the transaction. + /// @param broadcast Whether to broadcast the transaction. + /// + /// @return The result of the transaction. + function _execTransaction(address safe, Call memory call, bytes memory signatures, bool broadcast) + internal + returns (bool) + { + if (broadcast) { + vm.broadcast(); + } + + return IGnosisSafe(safe).execTransaction({ + to: call.target, + value: call.value, + data: call.data, + operation: call.operation, + safeTxGas: 0, + baseGas: 0, + gasPrice: 0, + gasToken: address(0), + refundReceiver: payable(address(0)), + signatures: signatures + }); + } + + /// @notice Gets the type for the given call. + /// + /// @param call The call to get the type for. + /// + /// @return The type for the given call. + function _getCall3Type(Call memory call) internal pure returns (Call3Type) { + if (call.operation == Enum.Operation.DelegateCall) { + return Call3Type.DELEGATE_CALL; + } else if (call.value == 0) { + return Call3Type.CALL; + } else { + return Call3Type.CALL_VALUE; + } + } + + /// @notice Converts the given call to the format expected by the `CBMulticall.aggregate3` function. + /// + /// @param call The call to convert to the format expected by the `CBMulticall.aggregate3` function. + /// + /// @return The call in the format expected by the `CBMulticall.aggregate3` function. + function _toCall3(Call memory call) internal pure returns (CBMulticall.Call3 memory) { + require(call.operation == Enum.Operation.Call, "MultisigScript::_toCall3: Operation must be Call"); + require(call.value == 0, "MultisigScript::_toCall3: Value must be 0"); + + return CBMulticall.Call3({target: call.target, allowFailure: false, callData: call.data}); + } + + /// @notice Converts the given call to the format expected by the `CBMulticall.aggregate3Value` function. + /// + /// @param call The call to convert to the format expected by the `CBMulticall.aggregate3Value` function. + /// + /// @return The call in the format expected by the `CBMulticall.aggregate3Value` function. + function _toCall3Value(Call memory call) internal pure returns (CBMulticall.Call3Value memory) { + require(call.operation == Enum.Operation.Call, "MultisigScript::_toCall3Value: Operation must be Call"); + require(call.value > 0, "MultisigScript::_toCall3Value: Value must be greater than 0"); + + return + CBMulticall.Call3Value({target: call.target, allowFailure: false, value: call.value, callData: call.data}); + } + + /// @notice Converts the given call to the format expected by the `CBMulticall.aggregateDelegateCalls` function. + /// + /// @param call The call to convert to the format expected by the `CBMulticall.aggregateDelegateCalls` function. + /// + /// @return The call in the format expected by the `CBMulticall.aggregateDelegateCalls` function. + function _toDelegateCall3(Call memory call) internal pure returns (CBMulticall.Call3 memory) { + require( + call.operation == Enum.Operation.DelegateCall, + "MultisigScript::_toDelegateCall3: Operation must be DelegateCall" + ); + require(call.value == 0, "MultisigScript::_toDelegateCall3: Value must be 0"); + + return CBMulticall.Call3({target: call.target, allowFailure: false, callData: call.data}); + } + + /// @notice Converts the given calls to the format expected by the `aggregate3` function. + /// + /// @param calls The calls to get the call3 values for. + /// + /// @return The calls in the format expected by the `aggregate3` function. + function _toCall3s(Call[] memory calls) internal pure returns (CBMulticall.Call3[] memory) { + CBMulticall.Call3[] memory call3s = new CBMulticall.Call3[](calls.length); + for (uint256 i; i < calls.length; i++) { + call3s[i] = _toCall3(calls[i]); + } + + return call3s; + } + + /// @notice Converts the given calls to the format expected by the `aggregate3Value` function. + /// + /// @param calls The calls to get the call3 values for. + /// + /// @return The calls in the format expected by the `aggregate3` function. + function _toCall3Values(Call[] memory calls) internal pure returns (CBMulticall.Call3Value[] memory) { + CBMulticall.Call3Value[] memory call3Values = new CBMulticall.Call3Value[](calls.length); + for (uint256 i; i < calls.length; i++) { + call3Values[i] = _toCall3Value(calls[i]); + } + + return call3Values; + } + + /// @notice Converts the given calls to the format expected by the `aggregateDelegateCalls` function. + /// + /// @param calls The calls to get the call3 values for. + /// + /// @return The calls in the format expected by the `aggregateDelegateCalls` function. + function _toDelegateCall3s(Call[] memory calls) internal pure returns (CBMulticall.Call3[] memory) { + CBMulticall.Call3[] memory delegateCall3s = new CBMulticall.Call3[](calls.length); + for (uint256 i; i < calls.length; i++) { + delegateCall3s[i] = _toDelegateCall3(calls[i]); + } + + return delegateCall3s; + } + + /// @notice Wraps the given address in an array of one address. + /// + /// @param addr The address to wrap. + /// + /// @return The address wrapped in an array of one address. + function _toArray(address addr) internal pure returns (address[] memory) { + address[] memory array = new address[](1); + array[0] = addr; + return array; + } + + /// @notice Wraps the given addresses in an array of two addresses. + /// + /// @param address1 The first address to wrap. + /// @param address2 The second address to wrap. + /// + /// @return The addresses wrapped in an array of two addresses. + function _toArray(address address1, address address2) internal pure returns (address[] memory) { + address[] memory array = new address[](2); + array[0] = address1; + array[1] = address2; + return array; + } +} diff --git a/test/universal/MultisigScriptV2.t.sol b/test/universal/MultisigScriptV2.t.sol new file mode 100644 index 0000000..ac30344 --- /dev/null +++ b/test/universal/MultisigScriptV2.t.sol @@ -0,0 +1,196 @@ +// 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 {console} from "forge-std/console.sol"; +import {Preinstalls} from "lib/optimism/packages/contracts-bedrock/src/libraries/Preinstalls.sol"; + +import {MultisigScriptV2} from "script/universal/MultisigScriptV2.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"; + +import {CBMulticall} from "src/utils/CBMulticall.sol"; + +contract MultisigScriptV2Test is Test, MultisigScriptV2 { + 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)); + + 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)); + deployCodeTo("CBMulticall.sol", "", CB_MULTICALL); + 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(); + assertEq(counterValue, 6, "Counter value is not 6"); + + uint256 counterBalance = address(counter).balance; + assertEq(counterBalance, 3 ether, "Counter balance is not 1 ether"); + } + + function _buildCalls() internal view override returns (Call[] memory) { + Call memory counterIncrementCall = Call({ + operation: Enum.Operation.Call, + target: address(counter), + data: abi.encodeCall(Counter.increment, ()), + value: 0 + }); + + Call memory counterIncrementCallPayable = Call({ + operation: Enum.Operation.Call, + target: address(counter), + data: abi.encodeCall(Counter.incrementPayable, ()), + value: 1 ether + }); + + Call[] memory counterIncrementCalls = new Call[](2); + counterIncrementCalls[0] = counterIncrementCall; + counterIncrementCalls[1] = counterIncrementCall; + + Call[] memory counterIncrementCallsPayable = new Call[](2); + counterIncrementCallsPayable[0] = counterIncrementCallPayable; + counterIncrementCallsPayable[1] = counterIncrementCallPayable; + + Call[] memory calls = new Call[](4); + + calls[0] = Call({ + operation: Enum.Operation.Call, + target: address(counter), + data: abi.encodeCall(Counter.increment, ()), + value: 0 + }); + + // Use multicall to test the delegatecall use case + calls[1] = Call({ + operation: Enum.Operation.DelegateCall, + target: CB_MULTICALL, + data: abi.encodeCall(CBMulticall.aggregate3, (_toCall3s(counterIncrementCalls))), + value: 0 + }); + + calls[2] = Call({ + operation: Enum.Operation.Call, + target: address(counter), + data: abi.encodeCall(Counter.incrementPayable, ()), + value: 1 ether + }); + + calls[3] = Call({ + operation: Enum.Operation.DelegateCall, + target: CB_MULTICALL, + data: abi.encodeCall(CBMulticall.aggregate3Value, (_toCall3Values(counterIncrementCallsPayable))), + value: 0 + }); + + return calls; + } + + function _ownerSafe() internal view override returns (address) { + return address(safe); + } + + function _expectedTxDataForCurrentBuildCalls() internal view returns (bytes memory) { + return _encodeTransactionData(_ownerSafe(), _buildAggregatedScriptCall({scriptCalls: _buildCalls()})); + } + + function test_sign() external { + vm.recordLogs(); + + vm.prank(wallet1.addr); + this.sign(new address[](0)); + + 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 { + // 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 { + // 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 { + 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"); + } +} From ea2c2b5b7df14dc31dcb14c3469f9b3a225879c4 Mon Sep 17 00:00:00 2001 From: Baptiste Oueriagli Date: Mon, 24 Nov 2025 11:05:52 +0000 Subject: [PATCH 2/9] chore: remove debug console log --- script/universal/MultisigScriptV2.sol | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/script/universal/MultisigScriptV2.sol b/script/universal/MultisigScriptV2.sol index 387bf9d..f888a58 100644 --- a/script/universal/MultisigScriptV2.sol +++ b/script/universal/MultisigScriptV2.sol @@ -358,13 +358,11 @@ abstract contract MultisigScriptV2 is Script { /// /// @return callsChain The calls chain for the given safes. function _buildCallsChain(address[] memory safes) internal view returns (Call[] memory callsChain) { - // Build the script calls + // Build the script calls. Call[] memory scriptCalls = _buildCallsChecked(); - // When there are multiple calls, we aggregate them into a single `aggregate3` call that will be executed via a - // delegate call to the CB_MULTICALL contract. + // Build the final script call. Call memory aggregatedScriptCall = _buildAggregatedScriptCall({scriptCalls: scriptCalls}); - console.logBytes(aggregatedScriptCall.data); // The very last call is the actual call to execute callsChain = new Call[](safes.length); @@ -432,6 +430,8 @@ abstract contract MultisigScriptV2 is Script { currentGroupIndex: currentGroupIndex }); + // NOTE: When aggregating via a Multicall call, the root call is always a delegatecall to `aggregateDelegateCalls` + // as it offers the most flexibility and allows perofming any other type of call. return Call({ operation: Enum.Operation.DelegateCall, target: CB_MULTICALL, From eceb3dfd3ff1580efae3f1b29e85176c2b8d5586 Mon Sep 17 00:00:00 2001 From: Baptiste Oueriagli Date: Mon, 24 Nov 2025 11:12:01 +0000 Subject: [PATCH 3/9] chore: minor naming change --- script/universal/MultisigScriptV2.sol | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/script/universal/MultisigScriptV2.sol b/script/universal/MultisigScriptV2.sol index f888a58..fedc5eb 100644 --- a/script/universal/MultisigScriptV2.sol +++ b/script/universal/MultisigScriptV2.sol @@ -595,9 +595,9 @@ abstract contract MultisigScriptV2 is Script { bytes32 firstCallDataHash = _getTransactionHash({safe: safes[0], call: callsChain[0]}); Simulation.StateOverride[] memory overrides = _overrides({safes: safes, firstCallDataHash: firstCallDataHash}); - // Build the execution calls chain for all the safe-to-safe approvals followed by the final script call. - Call[] memory executionCallsChain = _buildExecutionCalls({safes: safes, callsChain: callsChain}); - bytes memory txData = abi.encodeCall(CBMulticall.aggregate3, (_toCall3s(executionCallsChain))); + // Build the `execTransaction` calls chain for all the safe-to-safe approvals followed by the final script call. + Call[] memory execTransactionCalls = _buildExecTransactionCalls({safes: safes, callsChain: callsChain}); + bytes memory txData = abi.encodeCall(CBMulticall.aggregate3, (_toCall3s(execTransactionCalls))); console.logBytes(txData); console.log("---\nSimulation link:"); @@ -615,22 +615,22 @@ abstract contract MultisigScriptV2 is Script { /// @param safes The list of safes to execute the calls from. /// @param callsChain The list of calls to wrap in a `execTransaction` call. /// - /// @return executionCalls The list of `execTransaction` calls. - function _buildExecutionCalls(address[] memory safes, Call[] memory callsChain) + /// @return execTransactionCalls The list of `execTransaction` calls. + function _buildExecTransactionCalls(address[] memory safes, Call[] memory callsChain) internal view - returns (Call[] memory executionCalls) + returns (Call[] memory execTransactionCalls) { require( safes.length == callsChain.length, - "MultisigScript::_buildExecutionCalls: Safes and callsChain must have the same length" + "MultisigScript::_buildExecTransactionCalls: Safes and callsChain must have the same length" ); - executionCalls = new Call[](safes.length); + execTransactionCalls = new Call[](safes.length); for (uint256 i; i < safes.length; i++) { address signer = i == 0 ? msg.sender : safes[i - 1]; - executionCalls[i] = _buildExecTransactionCall({ + execTransactionCalls[i] = _buildExecTransactionCall({ safe: safes[i], call: callsChain[i], signatures: Signatures.genPrevalidatedSignature(signer) From cefc80ece2447951be23e07d202c3fbe0314ce12 Mon Sep 17 00:00:00 2001 From: Baptiste Oueriagli Date: Mon, 24 Nov 2025 14:59:30 +0000 Subject: [PATCH 4/9] chore: re-add state diff generation --- script/universal/MultisigScriptV2.sol | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/script/universal/MultisigScriptV2.sol b/script/universal/MultisigScriptV2.sol index fedc5eb..0c72009 100644 --- a/script/universal/MultisigScriptV2.sol +++ b/script/universal/MultisigScriptV2.sol @@ -11,6 +11,7 @@ import {CBMulticall} from "../../src/utils/CBMulticall.sol"; import {IGnosisSafe, Enum} from "./IGnosisSafe.sol"; import {Signatures} from "./Signatures.sol"; +import {StateDiff} from "./StateDiff.sol"; import {Simulation} from "./Simulation.sol"; /// @title MultisigScript @@ -230,6 +231,8 @@ abstract contract MultisigScriptV2 is Script { Call[] memory callsChain = _buildCallsChain({safes: safes}); (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = _simulateForSigner({safes: safes, callsChain: callsChain}); + (StateDiff.MappingParent[] memory parents, string memory json) = + StateDiff.collectStateDiff(StateDiff.CollectStateDiffOpts({accesses: accesses, simPayload: simPayload})); _postSign({accesses: accesses, simPayload: simPayload}); _postCheck({accesses: accesses, simPayload: simPayload}); @@ -238,6 +241,10 @@ abstract contract MultisigScriptV2 is Script { for (uint256 i; i < safes.length; i++) { vm.store({target: safes[i], slot: SAFE_NONCE_SLOT, value: bytes32(originalNonces[i])}); } + + bytes memory txData = _encodeTransactionData({safe: safes[0], call: callsChain[0]}); + StateDiff.recordStateDiff({json: json, parents: parents, txData: txData, targetSafe: _ownerSafe()}); + _printDataToSign({safe: safes[0], call: callsChain[0]}); } From e215ad41c52480462d6341353e09643686a38275 Mon Sep 17 00:00:00 2001 From: Baptiste Oueriagli Date: Mon, 24 Nov 2025 17:33:05 +0000 Subject: [PATCH 5/9] address review comments --- script/universal/MultisigScriptV2.sol | 97 +++++++++++---------------- test/universal/MultisigScriptV2.t.sol | 29 ++++---- 2 files changed, 54 insertions(+), 72 deletions(-) diff --git a/script/universal/MultisigScriptV2.sol b/script/universal/MultisigScriptV2.sol index 0c72009..12281fa 100644 --- a/script/universal/MultisigScriptV2.sol +++ b/script/universal/MultisigScriptV2.sol @@ -3,7 +3,6 @@ pragma solidity ^0.8.15; // solhint-disable no-console import {console} from "lib/forge-std/src/console.sol"; -import {IMulticall3} from "lib/forge-std/src/interfaces/IMulticall3.sol"; import {Script} from "lib/forge-std/src/Script.sol"; import {Vm} from "lib/forge-std/src/Vm.sol"; @@ -580,10 +579,7 @@ abstract contract MultisigScriptV2 is Script { // This can be used to e.g. call out to the Tenderly API and get additional // data about the state diff before broadcasting the transaction. Simulation.Payload memory simPayload = Simulation.Payload({ - from: msg.sender, - to: safe, - data: simCall.data, - stateOverrides: new Simulation.StateOverride[](0) + from: msg.sender, to: safe, data: simCall.data, stateOverrides: new Simulation.StateOverride[](0) }); return (accesses, simPayload); } @@ -638,9 +634,7 @@ abstract contract MultisigScriptV2 is Script { address signer = i == 0 ? msg.sender : safes[i - 1]; execTransactionCalls[i] = _buildExecTransactionCall({ - safe: safes[i], - call: callsChain[i], - signatures: Signatures.genPrevalidatedSignature(signer) + safe: safes[i], call: callsChain[i], signatures: Signatures.genPrevalidatedSignature(signer) }); } } @@ -659,10 +653,7 @@ abstract contract MultisigScriptV2 is Script { uint256 nonce = _getNonce({safe: safes[0]}); overrides[0] = Simulation.overrideSafeThresholdApprovalAndNonce({ - safe: safes[0], - nonce: nonce, - owner: msg.sender, - dataHash: firstCallDataHash + safe: safes[0], nonce: nonce, owner: msg.sender, dataHash: firstCallDataHash }); for (uint256 i = 1; i < safes.length; i++) { @@ -719,18 +710,19 @@ abstract contract MultisigScriptV2 is Script { /// /// @return The result of `encodeTransactionData` function from the given safe for the given call. function _encodeTransactionData(address safe, Call memory call) internal view returns (bytes memory) { - return IGnosisSafe(safe).encodeTransactionData({ - to: call.target, - value: call.value, - data: call.data, - operation: call.operation, - safeTxGas: 0, - baseGas: 0, - gasPrice: 0, - gasToken: address(0), - refundReceiver: address(0), - _nonce: _getNonce(safe) - }); + return IGnosisSafe(safe) + .encodeTransactionData({ + to: call.target, + value: call.value, + data: call.data, + operation: call.operation, + safeTxGas: 0, + baseGas: 0, + gasPrice: 0, + gasToken: address(0), + refundReceiver: address(0), + _nonce: _getNonce(safe) + }); } /// @notice Checks the signatures for the given safe and call. @@ -742,11 +734,12 @@ abstract contract MultisigScriptV2 is Script { bytes32 hash = _getTransactionHash({safe: safe, call: call}); signatures = Signatures.prepareSignatures({safe: safe, hash: hash, signatures: signatures}); - IGnosisSafe(safe).checkSignatures({ - dataHash: hash, - data: _encodeTransactionData({safe: safe, call: call}), // NOTE: This field is the data preimage but not strictly required as `checkSignatures` ignores it. - signatures: signatures - }); + IGnosisSafe(safe) + .checkSignatures({ + dataHash: hash, + data: _encodeTransactionData({safe: safe, call: call}), // NOTE: This field is the data preimage but not strictly required as `checkSignatures` ignores it. + signatures: signatures + }); } /// @notice Gets the transaction hash for the given safe and call. @@ -809,18 +802,19 @@ abstract contract MultisigScriptV2 is Script { vm.broadcast(); } - return IGnosisSafe(safe).execTransaction({ - to: call.target, - value: call.value, - data: call.data, - operation: call.operation, - safeTxGas: 0, - baseGas: 0, - gasPrice: 0, - gasToken: address(0), - refundReceiver: payable(address(0)), - signatures: signatures - }); + return IGnosisSafe(safe) + .execTransaction({ + to: call.target, + value: call.value, + data: call.data, + operation: call.operation, + safeTxGas: 0, + baseGas: 0, + gasPrice: 0, + gasToken: address(0), + refundReceiver: payable(address(0)), + signatures: signatures + }); } /// @notice Gets the type for the given call. @@ -831,11 +825,13 @@ abstract contract MultisigScriptV2 is Script { function _getCall3Type(Call memory call) internal pure returns (Call3Type) { if (call.operation == Enum.Operation.DelegateCall) { return Call3Type.DELEGATE_CALL; - } else if (call.value == 0) { + } + + if (call.value == 0) { return Call3Type.CALL; - } else { - return Call3Type.CALL_VALUE; } + + return Call3Type.CALL_VALUE; } /// @notice Converts the given call to the format expected by the `CBMulticall.aggregate3` function. @@ -930,17 +926,4 @@ abstract contract MultisigScriptV2 is Script { array[0] = addr; return array; } - - /// @notice Wraps the given addresses in an array of two addresses. - /// - /// @param address1 The first address to wrap. - /// @param address2 The second address to wrap. - /// - /// @return The addresses wrapped in an array of two addresses. - function _toArray(address address1, address address2) internal pure returns (address[] memory) { - address[] memory array = new address[](2); - array[0] = address1; - array[1] = address2; - return array; - } } diff --git a/test/universal/MultisigScriptV2.t.sol b/test/universal/MultisigScriptV2.t.sol index ac30344..4a5c378 100644 --- a/test/universal/MultisigScriptV2.t.sol +++ b/test/universal/MultisigScriptV2.t.sol @@ -1,10 +1,8 @@ // 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 {console} from "forge-std/console.sol"; import {Preinstalls} from "lib/optimism/packages/contracts-bedrock/src/libraries/Preinstalls.sol"; import {MultisigScriptV2} from "script/universal/MultisigScriptV2.sol"; @@ -26,7 +24,7 @@ contract MultisigScriptV2Test is Test, MultisigScriptV2 { bytes internal dataToSign3of2 = // solhint-disable-next-line max-line-length - hex"190132640243d7aade8c72f3d90d2dbf359e9897feba5fce1453bc8d9e7ba10d1715e6bf78f25eeee432952e1453c1b0d0bd867a1d4c4c859aa07ec7e2ef9cb87bc7"; + hex"190132640243d7aade8c72f3d90d2dbf359e9897feba5fce1453bc8d9e7ba10d1715e6bf78f25eeee432952e1453c1b0d0bd867a1d4c4c859aa07ec7e2ef9cb87bc7"; function setUp() public { vm.etch(safe, Preinstalls.getDeployedCode(Preinstalls.Safe_v130, block.chainid)); @@ -177,18 +175,19 @@ contract MultisigScriptV2Test is Test, MultisigScriptV2 { 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 - }); + 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"); From 82dc35ffa0dfff7923a777b81deaaebce2d3ba1b Mon Sep 17 00:00:00 2001 From: Baptiste Oueriagli Date: Wed, 3 Dec 2025 10:48:58 +0000 Subject: [PATCH 6/9] chore: remove deprecated MultisigBuilder and clean tests --- script/deploy/l1/SetGasLimit.sol | 13 +- .../universal/DoubleNestedMultisigBuilder.sol | 38 - script/universal/MultisigBuilder.sol | 18 - script/universal/MultisigScript.sol | 630 ++++++++---- script/universal/MultisigScriptV2.sol | 929 ------------------ script/universal/NestedMultisigBuilder.sol | 33 - .../DoubleNestedMultisigBuilder.t.sol | 141 --- test/universal/MultisigBuilder.t.sol | 159 --- test/universal/MultisigScript.t.sol | 214 ++-- .../MultisigScriptDelegateCall.t.sol | 110 --- .../MultisigScriptDoubleNested.t.sol | 153 ++- test/universal/MultisigScriptNested.t.sol | 136 ++- test/universal/MultisigScriptV2.t.sol | 195 ---- test/universal/NestedMultisigBuilder.t.sol | 125 --- 14 files changed, 766 insertions(+), 2128 deletions(-) delete mode 100644 script/universal/DoubleNestedMultisigBuilder.sol delete mode 100644 script/universal/MultisigBuilder.sol delete mode 100644 script/universal/MultisigScriptV2.sol delete mode 100644 script/universal/NestedMultisigBuilder.sol delete mode 100644 test/universal/DoubleNestedMultisigBuilder.t.sol delete mode 100644 test/universal/MultisigBuilder.t.sol delete mode 100644 test/universal/MultisigScriptDelegateCall.t.sol delete mode 100644 test/universal/MultisigScriptV2.t.sol delete mode 100644 test/universal/NestedMultisigBuilder.t.sol diff --git a/script/deploy/l1/SetGasLimit.sol b/script/deploy/l1/SetGasLimit.sol index f68f6fe..16be341 100644 --- a/script/deploy/l1/SetGasLimit.sol +++ b/script/deploy/l1/SetGasLimit.sol @@ -4,7 +4,8 @@ pragma solidity 0.8.15; import {Vm} from "lib/forge-std/src/Vm.sol"; import {SystemConfig} from "lib/optimism/packages/contracts-bedrock/src/L1/SystemConfig.sol"; -import {MultisigScript, IMulticall3, Simulation} from "../../universal/MultisigScript.sol"; +import {Enum} from "../../universal/IGnosisSafe.sol"; +import {MultisigScript, Simulation} from "../../universal/MultisigScript.sol"; /// @title SetGasLimit /// @@ -34,13 +35,13 @@ contract SetGasLimit is MultisigScript { assert(SystemConfig(L1_SYSTEM_CONFIG).gasLimit() == _toGasLimit()); } - function _buildCalls() internal view override returns (IMulticall3.Call3Value[] memory) { - IMulticall3.Call3Value[] memory calls = new IMulticall3.Call3Value[](1); + function _buildCalls() internal view override returns (Call[] memory) { + Call[] memory calls = new Call[](1); - calls[0] = IMulticall3.Call3Value({ + calls[0] = Call({ + operation: Enum.Operation.Call, target: L1_SYSTEM_CONFIG, - allowFailure: false, - callData: abi.encodeCall(SystemConfig.setGasLimit, (_toGasLimit())), + data: abi.encodeCall(SystemConfig.setGasLimit, (_toGasLimit())), value: 0 }); diff --git a/script/universal/DoubleNestedMultisigBuilder.sol b/script/universal/DoubleNestedMultisigBuilder.sol deleted file mode 100644 index 5a9eac7..0000000 --- a/script/universal/DoubleNestedMultisigBuilder.sol +++ /dev/null @@ -1,38 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.15; - -import {MultisigScript} from "./MultisigScript.sol"; - -/// @title DoubleNestedMultisigBuilder -/// @custom:deprecated Use `MultisigScript` instead. -abstract contract DoubleNestedMultisigBuilder is MultisigScript { - /// @custom:deprecated Use `sign(address[] memory safes)` instead. - function sign(address signerSafe, address intermediateSafe) external { - sign({safes: _toArray(signerSafe, intermediateSafe)}); - } - - /// @custom:deprecated Use `approve(address[] memory safes, bytes memory signatures)` instead. - function approveOnBehalfOfSignerSafe(address signerSafe, address intermediateSafe, bytes memory signatures) public { - approve({safes: _toArray(signerSafe, intermediateSafe), signatures: signatures}); - } - - /// @custom:deprecated Use `approve(address[] memory safes, bytes memory signatures)` instead. - function approveOnBehalfOfIntermediateSafe(address intermediateSafe) public { - approve({safes: _toArray(intermediateSafe), signatures: ""}); - } - - /// @custom:deprecated Use `simulate(bytes memory signatures)` instead, with empty `signatures`. - function simulate() public { - simulate({signatures: ""}); - } - - /// @custom:deprecated Use `run(bytes memory signatures)` instead, with empty `signatures`. - function run() public { - run({signatures: ""}); - } - - /// @custom:deprecated Use `verify(address[] memory safes, bytes memory signatures)` instead. - function verify(address signerSafe, address intermediateSafe, bytes memory signatures) public view { - verify({safes: _toArray(signerSafe, intermediateSafe), signatures: signatures}); - } -} diff --git a/script/universal/MultisigBuilder.sol b/script/universal/MultisigBuilder.sol deleted file mode 100644 index 614cf4e..0000000 --- a/script/universal/MultisigBuilder.sol +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.15; - -import {MultisigScript} from "./MultisigScript.sol"; - -/// @title MultisigBuilder -/// @custom:deprecated Use `MultisigScript` instead. -abstract contract MultisigBuilder is MultisigScript { - /// @custom:deprecated Use `sign(address[] memory safes)` instead, with an empty array. - function sign() external { - sign({safes: new address[](0)}); - } - - /// @custom:deprecated Use `verify(address[] memory safes, bytes memory signatures)` instead, with an empty array. - function verify(bytes memory signatures) external view { - verify({safes: new address[](0), signatures: signatures}); - } -} diff --git a/script/universal/MultisigScript.sol b/script/universal/MultisigScript.sol index 966e63b..f29eaea 100644 --- a/script/universal/MultisigScript.sol +++ b/script/universal/MultisigScript.sol @@ -3,15 +3,15 @@ pragma solidity ^0.8.15; // solhint-disable no-console import {console} from "lib/forge-std/src/console.sol"; -import {IMulticall3} from "lib/forge-std/src/interfaces/IMulticall3.sol"; import {Script} from "lib/forge-std/src/Script.sol"; import {Vm} from "lib/forge-std/src/Vm.sol"; +import {CBMulticall} from "../../src/utils/CBMulticall.sol"; + 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"; +import {Simulation} from "./Simulation.sol"; /// @title MultisigScript /// @notice Script builder for Forge scripts that require signatures from Safes. Supports both non-nested @@ -149,10 +149,27 @@ import {CBMulticall} from "../../src/utils/CBMulticall.sol"; /// │ │ │ │ │ │ │─────────────────────────────>│ abstract contract MultisigScript is Script { bytes32 internal constant SAFE_NONCE_SLOT = bytes32(uint256(5)); - address internal constant CB_MULTICALL = 0xA8B8CA1d6F0F5Ce63dCEA9121A01b302c5801303; - address internal multicallAddress; + /// @notice A struct representing a call to a contract. + /// + /// @param operation The operation to perform on the contract. + /// @param target The address of the contract to call. + /// @param data The data to call the contract with. + /// @param value The value to send with the call. + struct Call { + Enum.Operation operation; + address target; + bytes data; + uint256 value; + } + + /// @notice The available types of for call3 calls. + enum Call3Type { + DELEGATE_CALL, + CALL, + CALL_VALUE + } /// @dev Event emitted from a `sign()` call containing the data to sign. Used in testing. event DataToSign(bytes data); @@ -165,7 +182,7 @@ abstract contract MultisigScript is Script { function _ownerSafe() internal view virtual returns (address); /// @notice Creates the calldata for signatures (`sign`), approvals (`approve`), and execution (`run`) - function _buildCalls() internal view virtual returns (IMulticall3.Call3Value[] memory); + function _buildCalls() internal view virtual returns (Call[] memory); /// @notice Follow up assertions to ensure that the script ran to completion. /// @dev Called after `sign` and `run`, but not `approve`. @@ -184,26 +201,6 @@ 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 || _useDelegateCall()) ? CB_MULTICALL : MULTICALL3_ADDRESS; - } - ////////////////////////////////////////////////////////////////////////////////////// /// Public Functions /// ////////////////////////////////////////////////////////////////////////////////////// @@ -230,14 +227,11 @@ abstract contract MultisigScript is Script { originalNonces[i] = _getNonce({safe: safes[i]}); } - (bytes[] memory datas, uint256 value) = _transactionDatas({safes: safes}); - - vm.startMappingRecording(); + Call[] memory callsChain = _buildCallsChain({safes: safes}); (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = - _simulateForSigner({safes: safes, datas: datas, value: value}); + _simulateForSigner({safes: safes, callsChain: callsChain}); (StateDiff.MappingParent[] memory parents, string memory json) = StateDiff.collectStateDiff(StateDiff.CollectStateDiffOpts({accesses: accesses, simPayload: simPayload})); - vm.stopMappingRecording(); _postSign({accesses: accesses, simPayload: simPayload}); _postCheck({accesses: accesses, simPayload: simPayload}); @@ -247,10 +241,10 @@ abstract contract MultisigScript is Script { vm.store({target: safes[i], slot: SAFE_NONCE_SLOT, value: bytes32(originalNonces[i])}); } - bytes memory txData = _encodeTransactionData({safe: safes[0], data: datas[0], value: value}); + bytes memory txData = _encodeTransactionData({safe: safes[0], call: callsChain[0]}); StateDiff.recordStateDiff({json: json, parents: parents, txData: txData, targetSafe: _ownerSafe()}); - _printDataToSign({safe: safes[0], data: datas[0], value: value, txData: txData}); + _printDataToSign({safe: safes[0], call: callsChain[0]}); } /// Step 1.1 (optional) @@ -262,8 +256,9 @@ abstract contract MultisigScript is Script { /// @param signatures The signatures to verify (concatenated, 65-bytes per sig). function verify(address[] memory safes, bytes memory signatures) public view { safes = _appendOwnerSafe({safes: safes}); - (bytes[] memory datas, uint256 value) = _transactionDatas({safes: safes}); - _checkSignatures({safe: safes[0], data: datas[0], value: value, signatures: signatures}); + + Call[] memory callsChain = _buildCallsChain({safes: safes}); + _checkSignatures({safe: safes[0], call: callsChain[0], signatures: signatures}); } /// Step 2 (optional for non-nested setups) @@ -280,9 +275,11 @@ abstract contract MultisigScript is Script { /// @param signatures The signatures from step 1 (concatenated, 65-bytes per sig) function approve(address[] memory safes, bytes memory signatures) public { safes = _appendOwnerSafe({safes: safes}); - (bytes[] memory datas, uint256 value) = _transactionDatas({safes: safes}); + + Call[] memory callsChain = _buildCallsChain({safes: safes}); (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = - _executeTransaction({safe: safes[0], data: datas[0], value: value, signatures: signatures, broadcast: true}); + _executeTransaction({safe: safes[0], call: callsChain[0], signatures: signatures, broadcast: true}); + _postApprove({accesses: accesses, simPayload: simPayload}); } @@ -297,13 +294,12 @@ abstract contract MultisigScript is Script { /// @param signatures The signatures from step 1 (concatenated, 65-bytes per sig) function simulate(bytes memory signatures) public { address ownerSafe = _ownerSafe(); - (bytes[] memory datas, uint256 value) = _transactionDatas({safes: _toArray(ownerSafe)}); + Call[] memory callsChain = _buildCallsChain({safes: _toArray(ownerSafe)}); vm.store({target: ownerSafe, slot: SAFE_NONCE_SLOT, value: bytes32(_getNonce({safe: ownerSafe}))}); - (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = _executeTransaction({ - safe: ownerSafe, data: datas[0], value: value, signatures: signatures, broadcast: false - }); + (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = + _executeTransaction({safe: ownerSafe, call: callsChain[0], signatures: signatures, broadcast: false}); _postRun({accesses: accesses, simPayload: simPayload}); _postCheck({accesses: accesses, simPayload: simPayload}); @@ -318,11 +314,10 @@ abstract contract MultisigScript is Script { /// @param signatures The signatures from step 1 (concatenated, 65-bytes per sig) function run(bytes memory signatures) public { address ownerSafe = _ownerSafe(); - (bytes[] memory datas, uint256 value) = _transactionDatas({safes: _toArray(ownerSafe)}); + Call[] memory callsChain = _buildCallsChain({safes: _toArray(ownerSafe)}); - (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = _executeTransaction({ - safe: ownerSafe, data: datas[0], value: value, signatures: signatures, broadcast: true - }); + (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = + _executeTransaction({safe: ownerSafe, call: callsChain[0], signatures: signatures, broadcast: true}); _postRun({accesses: accesses, simPayload: simPayload}); _postCheck({accesses: accesses, simPayload: simPayload}); @@ -332,6 +327,11 @@ abstract contract MultisigScript is Script { /// Internal Functions /// ////////////////////////////////////////////////////////////////////////////////////// + /// @notice Appends the owner safe to the list of safes. + /// + /// @param safes The list of safes to append the owner safe to. + /// + /// @return The list of safes with the owner safe appended. function _appendOwnerSafe(address[] memory safes) internal view returns (address[] memory) { address[] memory extendedSafes = new address[](safes.length + 1); for (uint256 i; i < safes.length; i++) { @@ -341,73 +341,202 @@ abstract contract MultisigScript is Script { return extendedSafes; } - function _transactionDatas(address[] memory safes) private view returns (bytes[] memory datas, uint256 value) { - // Build the calls and sum the values - IMulticall3.Call3Value[] memory calls = _buildCalls(); - for (uint256 i; i < calls.length; i++) { - value += calls[i].value; + /// @notice Wrapper around `_buildCalls` that checks that the script calls are valid. + /// + /// @return The list of calls. + function _buildCallsChecked() internal view returns (Call[] memory) { + Call[] memory scriptCalls = _buildCalls(); + for (uint256 i; i < scriptCalls.length; i++) { + Call memory call = scriptCalls[i]; + + require( + call.operation == Enum.Operation.Call || call.value == 0, + "MultisigScript::_buildCallsChecked: Value must be 0 for delegate calls" + ); } - // The very last call is the actual (aggregated) call to execute - datas = new bytes[](safes.length); - datas[datas.length - 1] = abi.encodeCall(IMulticall3.aggregate3Value, (calls)); + return scriptCalls; + } - if (_useDelegateCall()) { - datas[datas.length - 1] = abi.encodeCall(CBMulticall.aggregateDelegateCalls, (_toCall3Array(calls))); - } + /// @notice Build the list of safe-to-safe approval calls followed by the final script call. + /// + /// @param safes The list of safes to build the calls chain for. + /// + /// @return callsChain The calls chain for the given safes. + function _buildCallsChain(address[] memory safes) internal view returns (Call[] memory callsChain) { + // Build the script calls. + Call[] memory scriptCalls = _buildCallsChecked(); - // The first n-1 calls are the nested approval calls - uint256 valueForCallToApprove = value; - for (uint256 i = safes.length - 1; i > 0; i--) { - address targetSafe = safes[i]; - bytes memory callToApprove = datas[i]; + // Build the final script call. + Call memory aggregatedScriptCall = _buildAggregatedScriptCall({scriptCalls: scriptCalls}); - IMulticall3.Call3[] memory approvalCall = new IMulticall3.Call3[](1); - approvalCall[0] = - _generateApproveCall({safe: targetSafe, data: callToApprove, value: valueForCallToApprove}); - datas[i - 1] = abi.encodeCall(IMulticall3.aggregate3, (approvalCall)); + // The very last call is the actual call to execute + callsChain = new Call[](safes.length); + callsChain[callsChain.length - 1] = aggregatedScriptCall; - valueForCallToApprove = 0; + // The first n-1 calls are the nested approval calls. We build the approvals backwards, starting from the last safe. + for (uint256 i = safes.length - 1; i > 0; i--) { + address safe = safes[i]; + Call memory callToApprove = callsChain[i]; + + callsChain[i - 1] = _buildApproveCall({safe: safe, call: callToApprove}); } } - /// @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 + /// @notice Builds the aggregated script call. + /// + /// @param scriptCalls The list of script calls to aggregate. + /// + /// @return The aggregated script call. + function _buildAggregatedScriptCall(Call[] memory scriptCalls) internal pure returns (Call memory) { + // When there is only one call, we return it directly as there is no need to aggregate it into a Multicall call. + if (scriptCalls.length == 1) { + return scriptCalls[0]; + } + + CBMulticall.Call3[] memory rootCalls = new CBMulticall.Call3[](scriptCalls.length); + uint256 rootCallsIndex; + + Call[] memory currentGroup = new Call[](scriptCalls.length); + currentGroup[0] = scriptCalls[0]; + uint256 currentGroupIndex; + + for (uint256 i; i < scriptCalls.length; i++) { + Call memory currentCall = scriptCalls[i]; + Call3Type currentType = _getCall3Type(currentCall); + Call3Type groupType = _getCall3Type(currentGroup[0]); + + // If the current call has the same type as the current group, add it to the current group and continue. + if (groupType == currentType) { + currentGroup[currentGroupIndex] = currentCall; + currentGroupIndex++; + continue; + } + + // Consume the current group and append the calls to the root calls. + rootCallsIndex += _aggregateCalls({ + groupType: groupType, + rootCalls: rootCalls, + rootCallsIndex: rootCallsIndex, + currentGroup: currentGroup, + currentGroupIndex: currentGroupIndex }); + + // Reset the current group (for the next group) + currentGroup[0] = currentCall; + currentGroupIndex = 1; } - return dCalls; + + // Process the final group left in the current group. + rootCallsIndex += _aggregateCalls({ + groupType: _getCall3Type(currentGroup[0]), + rootCalls: rootCalls, + rootCallsIndex: rootCallsIndex, + currentGroup: currentGroup, + currentGroupIndex: currentGroupIndex + }); + + // NOTE: When aggregating via a Multicall call, the root call is always a delegatecall to `aggregateDelegateCalls` + // as it offers the most flexibility and allows perofming any other type of call. + return Call({ + operation: Enum.Operation.DelegateCall, + target: CB_MULTICALL, + data: abi.encodeCall(CBMulticall.aggregateDelegateCalls, (rootCalls)), + value: 0 + }); } - function _generateApproveCall(address safe, bytes memory data, uint256 value) - internal - view - returns (IMulticall3.Call3 memory) - { - bytes32 hash = _getTransactionHash({safe: safe, data: data, value: value}); + /// @notice Aggregates the current group of calls into a single Multicall call. + /// + /// @param groupType The type of the current group. + /// @param rootCalls The root calls to append the calls to. + /// @param rootCallsIndex The index of the root calls to append the calls to. + /// @param currentGroup The current group of calls to consume. + /// @param currentGroupIndex The index of the current group. + /// + /// @return rootCallsCount The number of root calls appended. + function _aggregateCalls( + Call3Type groupType, + CBMulticall.Call3[] memory rootCalls, + uint256 rootCallsIndex, + Call[] memory currentGroup, + uint256 currentGroupIndex + ) internal pure returns (uint256 rootCallsCount) { + uint256 rootCallsIndexSaved = rootCallsIndex; + + // Append the call3 delegate calls directly to the root calls. + if (groupType == Call3Type.DELEGATE_CALL) { + for (uint256 j; j < currentGroupIndex; j++) { + rootCalls[rootCallsIndex] = _toDelegateCall3(currentGroup[j]); + rootCallsIndex++; + } + } + // Otherwise aggregate the calls into a single Multicall call. + else { + CBMulticall.Call3 memory rootCall; + + if (groupType == Call3Type.CALL) { + CBMulticall.Call3[] memory call3s = new CBMulticall.Call3[](currentGroupIndex); + for (uint256 j; j < currentGroupIndex; j++) { + call3s[j] = _toCall3(currentGroup[j]); + } + + rootCall = CBMulticall.Call3({ + target: CB_MULTICALL, + allowFailure: false, + callData: abi.encodeCall(CBMulticall.aggregate3, (call3s)) + }); + } else { + CBMulticall.Call3Value[] memory call3Values = new CBMulticall.Call3Value[](currentGroupIndex); + for (uint256 j; j < currentGroupIndex; j++) { + call3Values[j] = _toCall3Value(currentGroup[j]); + } + + rootCall = CBMulticall.Call3({ + target: CB_MULTICALL, + allowFailure: false, + callData: abi.encodeCall(CBMulticall.aggregate3Value, (call3Values)) + }); + } + + rootCalls[rootCallsIndex] = rootCall; + rootCallsIndex++; + } + + // Return the number of root calls appended. + rootCallsCount = rootCallsIndex - rootCallsIndexSaved; + } + + /// @notice Builds the approve call (`approveHash`) for the given safe and call. + /// + /// @param safe The address of the safe to approve. + /// @param call The call to approve. + /// + /// @return The approve call. + function _buildApproveCall(address safe, Call memory call) internal view returns (Call memory) { + bytes32 hash = _getTransactionHash({safe: safe, call: call}); console.log("---\nNested hash for safe %s:", safe); console.logBytes32(hash); - return IMulticall3.Call3({ - target: safe, allowFailure: false, callData: abi.encodeCall(IGnosisSafe(safe).approveHash, (hash)) + return Call({ + operation: Enum.Operation.Call, + target: safe, + data: abi.encodeCall(IGnosisSafe(safe).approveHash, (hash)), + value: 0 }); } - function _printDataToSign(address safe, bytes memory data, uint256 value, bytes memory txData) internal { - bytes32 hash = _getTransactionHash({safe: safe, data: data, value: value}); - + /// @notice Prints the data to sign for the given safe and call. + /// + /// @param safe The address of the safe to print the data to sign for. + /// @param call The call to print the data to sign for. + function _printDataToSign(address safe, Call memory call) internal { + bytes memory txData = _encodeTransactionData({safe: safe, call: call}); emit DataToSign({data: txData}); console.log("---\nIf submitting onchain, call Safe.approveHash on %s with the following hash:", safe); - console.logBytes32(hash); + console.logBytes32(_getTransactionHash({safe: safe, call: call})); console.log("---\nData to sign:"); console.log("vvvvvvvv"); @@ -423,80 +552,91 @@ abstract contract MultisigScript is Script { console.log("###############################"); } - function _executeTransaction( - address safe, - bytes memory data, - uint256 value, - bytes memory signatures, - bool broadcast - ) internal returns (Vm.AccountAccess[] memory, Simulation.Payload memory) { - bytes32 hash = _getTransactionHash({safe: safe, data: data, value: value}); + /// @notice Executes the given transaction. + /// + /// @param safe The address of the safe to execute the transaction from. + /// @param call The call to execute. + /// @param signatures The signatures to use for the transaction. + /// @param broadcast Whether to broadcast the transaction. + /// + /// @return The account accesses and simulation payload. + function _executeTransaction(address safe, Call memory call, bytes memory signatures, bool broadcast) + internal + returns (Vm.AccountAccess[] memory, Simulation.Payload memory) + { + bytes32 hash = _getTransactionHash({safe: safe, call: call}); signatures = Signatures.prepareSignatures({safe: safe, hash: hash, signatures: signatures}); - bytes memory simData = _execTransactionCalldata({safe: safe, data: data, value: value, signatures: signatures}); - Simulation.logSimulationLink({to: safe, data: simData, from: msg.sender}); + Call memory simCall = _buildExecTransactionCall({safe: safe, call: call, signatures: signatures}); + Simulation.logSimulationLink({to: safe, data: simCall.data, from: msg.sender}); vm.startStateDiffRecording(); - bool success = - _execTransaction({safe: safe, data: data, value: value, signatures: signatures, broadcast: broadcast}); + bool success = _execTransaction({safe: safe, call: call, signatures: signatures, broadcast: broadcast}); Vm.AccountAccess[] memory accesses = vm.stopAndReturnStateDiff(); - require(success, "MultisigBase::_executeTransaction: Transaction failed"); - require(accesses.length > 0, "MultisigBase::_executeTransaction: No state changes"); + require(success, "MultisigScript::_executeTransaction: Transaction failed"); + require(accesses.length > 0, "MultisigScript::_executeTransaction: No state changes"); // This can be used to e.g. call out to the Tenderly API and get additional // data about the state diff before broadcasting the transaction. Simulation.Payload memory simPayload = Simulation.Payload({ - from: msg.sender, to: safe, data: simData, stateOverrides: new Simulation.StateOverride[](0) + from: msg.sender, to: safe, data: simCall.data, stateOverrides: new Simulation.StateOverride[](0) }); return (accesses, simPayload); } - function _simulateForSigner(address[] memory safes, bytes[] memory datas, uint256 value) + /// @notice Simulates the given `callsChain` associated to the given `safes` as if initiated by `msg.sender`. + /// + /// @param safes The list of safes to simulate the transaction for. + /// @param callsChain The list of calls to simulate the transaction for. + /// + /// @return The account accesses and simulation payload. + function _simulateForSigner(address[] memory safes, Call[] memory callsChain) internal returns (Vm.AccountAccess[] memory, Simulation.Payload memory) { - IMulticall3.Call3[] memory calls = _simulateForSignerCalls({safes: safes, datas: datas, value: value}); - - bytes32 firstCallDataHash = _getTransactionHash({safe: safes[0], data: datas[0], value: value}); - - // Now define the state overrides for the simulation. + // Define the state overrides for the simulation. + bytes32 firstCallDataHash = _getTransactionHash({safe: safes[0], call: callsChain[0]}); Simulation.StateOverride[] memory overrides = _overrides({safes: safes, firstCallDataHash: firstCallDataHash}); - bytes memory txData = abi.encodeCall(IMulticall3.aggregate3, (calls)); + // Build the `execTransaction` calls chain for all the safe-to-safe approvals followed by the final script call. + Call[] memory execTransactionCalls = _buildExecTransactionCalls({safes: safes, callsChain: callsChain}); + bytes memory txData = abi.encodeCall(CBMulticall.aggregate3, (_toCall3s(execTransactionCalls))); + console.logBytes(txData); + console.log("---\nSimulation link:"); - // solhint-disable max-line-length - Simulation.logSimulationLink({to: multicallAddress, data: txData, from: msg.sender, overrides: overrides}); + Simulation.logSimulationLink({to: CB_MULTICALL, data: txData, from: msg.sender, overrides: overrides}); - // Forge simulation of the data logged in the link. If the simulation fails - // we revert to make it explicit that the simulation failed. + // Forge simulation of the data logged in the link. If the simulation fails we revert to make it explicit that the simulation failed. Simulation.Payload memory simPayload = - Simulation.Payload({to: multicallAddress, data: txData, from: msg.sender, stateOverrides: overrides}); + Simulation.Payload({to: CB_MULTICALL, data: txData, from: msg.sender, stateOverrides: overrides}); Vm.AccountAccess[] memory accesses = Simulation.simulateFromSimPayload({simPayload: simPayload}); return (accesses, simPayload); } - function _simulateForSignerCalls(address[] memory safes, bytes[] memory datas, uint256 value) - private + /// @notice Wraps each of the given calls in a `execTransaction` call. + /// + /// @param safes The list of safes to execute the calls from. + /// @param callsChain The list of calls to wrap in a `execTransaction` call. + /// + /// @return execTransactionCalls The list of `execTransaction` calls. + function _buildExecTransactionCalls(address[] memory safes, Call[] memory callsChain) + internal view - returns (IMulticall3.Call3[] memory) + returns (Call[] memory execTransactionCalls) { - IMulticall3.Call3[] memory calls = new IMulticall3.Call3[](safes.length); + require( + safes.length == callsChain.length, + "MultisigScript::_buildExecTransactionCalls: Safes and callsChain must have the same length" + ); + + execTransactionCalls = new Call[](safes.length); for (uint256 i; i < safes.length; i++) { address signer = i == 0 ? msg.sender : safes[i - 1]; - calls[i] = IMulticall3.Call3({ - target: safes[i], - allowFailure: false, - callData: _execTransactionCalldata({ - safe: safes[i], - data: datas[i], - value: value, - signatures: Signatures.genPrevalidatedSignature(signer) - }) + execTransactionCalls[i] = _buildExecTransactionCall({ + safe: safes[i], call: callsChain[i], signatures: Signatures.genPrevalidatedSignature(signer) }); } - - return calls; } // The state change simulation can set the threshold, owner address and/or nonce. @@ -563,27 +703,19 @@ abstract contract MultisigScript is Script { } } - function _checkSignatures(address safe, bytes memory data, uint256 value, bytes memory signatures) internal view { - bytes32 hash = _getTransactionHash({safe: safe, data: data, value: value}); - signatures = Signatures.prepareSignatures({safe: safe, hash: hash, signatures: signatures}); - IGnosisSafe(safe).checkSignatures({dataHash: hash, data: data, signatures: signatures}); - } - - function _getTransactionHash(address safe, bytes memory data, uint256 value) internal view returns (bytes32) { - return keccak256(_encodeTransactionData({safe: safe, data: data, value: value})); - } - - function _encodeTransactionData(address safe, bytes memory data, uint256 value) - internal - view - returns (bytes memory) - { + /// @notice Returns the result of `encodeTransactionData` function from the given safe for the given call. + /// + /// @param safe The address of the safe that will execute the transaction. + /// @param call The call to get the encoded transaction data for. + /// + /// @return The result of `encodeTransactionData` function from the given safe for the given call. + function _encodeTransactionData(address safe, Call memory call) internal view returns (bytes memory) { return IGnosisSafe(safe) .encodeTransactionData({ - to: multicallAddress, - value: value, - data: data, - operation: _getOperation(value), + to: call.target, + value: call.value, + data: call.data, + operation: call.operation, safeTxGas: 0, baseGas: 0, gasPrice: 0, @@ -593,30 +725,89 @@ abstract contract MultisigScript is Script { }); } - function _execTransactionCalldata(address safe, bytes memory data, uint256 value, bytes memory signatures) + /// @notice Checks the signatures for the given safe and call. + /// + /// @param safe The address of the safe to check the signatures for. + /// @param call The call to check the signatures for. + /// @param signatures The signatures to check. + function _checkSignatures(address safe, Call memory call, bytes memory signatures) internal view { + bytes32 hash = _getTransactionHash({safe: safe, call: call}); + signatures = Signatures.prepareSignatures({safe: safe, hash: hash, signatures: signatures}); + + IGnosisSafe(safe) + .checkSignatures({ + dataHash: hash, + data: _encodeTransactionData({safe: safe, call: call}), // NOTE: This field is the data preimage but not strictly required as `checkSignatures` ignores it. + signatures: signatures + }); + } + + /// @notice Gets the transaction hash for the given safe and call. + /// + /// @param safe The address of the safe that will execute the transaction. + /// @param call The call to get the transaction hash for. + /// + /// @return The transaction hash for the given safe and call. + function _getTransactionHash(address safe, Call memory call) internal view returns (bytes32) { + return keccak256(_encodeTransactionData({safe: safe, call: call})); + } + + /// @notice Wrapps the given `call` in a `execTransaction` call. + /// + /// @param safe The address of the safe to execute the transaction from. + /// @param call The call to execute. + /// @param signatures The signatures to use for the transaction. + /// + /// @return The execTransaction call. + function _buildExecTransactionCall(address safe, Call memory call, bytes memory signatures) internal - view - returns (bytes memory) + pure + returns (Call memory) { - return abi.encodeCall( - IGnosisSafe(safe).execTransaction, - (multicallAddress, value, data, _getOperation(value), 0, 0, 0, address(0), payable(address(0)), signatures) - ); + return Call({ + operation: Enum.Operation.Call, + target: safe, + data: abi.encodeCall( + IGnosisSafe(safe).execTransaction, + ( + call.target, // to + call.value, // value + call.data, // data + call.operation, // operation + 0, // safeTxGas + 0, // baseGas + 0, // gasPrice + address(0), // gasToken + payable(address(0)), // refundReceiver + signatures // signatures + ) + ), + value: 0 + }); } - function _execTransaction(address safe, bytes memory data, uint256 value, bytes memory signatures, bool broadcast) + /// @notice Executes the given call from the given safe. + /// + /// @param safe The address of the safe to execute the call from. + /// @param call The call to execute. + /// @param signatures The signatures to use for the transaction. + /// @param broadcast Whether to broadcast the transaction. + /// + /// @return The result of the transaction. + function _execTransaction(address safe, Call memory call, bytes memory signatures, bool broadcast) internal returns (bool) { if (broadcast) { vm.broadcast(); } + return IGnosisSafe(safe) .execTransaction({ - to: multicallAddress, - value: value, - data: data, - operation: _getOperation(value), + to: call.target, + value: call.value, + data: call.data, + operation: call.operation, safeTxGas: 0, baseGas: 0, gasPrice: 0, @@ -626,24 +817,113 @@ abstract contract MultisigScript is Script { }); } - function _toArray(address addr) internal pure returns (address[] memory) { - address[] memory array = new address[](1); - array[0] = addr; - return array; + /// @notice Gets the type for the given call. + /// + /// @param call The call to get the type for. + /// + /// @return The type for the given call. + function _getCall3Type(Call memory call) internal pure returns (Call3Type) { + if (call.operation == Enum.Operation.DelegateCall) { + return Call3Type.DELEGATE_CALL; + } + + if (call.value == 0) { + return Call3Type.CALL; + } + + return Call3Type.CALL_VALUE; } - function _toArray(address address1, address address2) internal pure returns (address[] memory) { - address[] memory array = new address[](2); - array[0] = address1; - array[1] = address2; - return array; + /// @notice Converts the given call to the format expected by the `CBMulticall.aggregate3` function. + /// + /// @param call The call to convert to the format expected by the `CBMulticall.aggregate3` function. + /// + /// @return The call in the format expected by the `CBMulticall.aggregate3` function. + function _toCall3(Call memory call) internal pure returns (CBMulticall.Call3 memory) { + require(call.operation == Enum.Operation.Call, "MultisigScript::_toCall3: Operation must be Call"); + require(call.value == 0, "MultisigScript::_toCall3: Value must be 0"); + + return CBMulticall.Call3({target: call.target, allowFailure: false, callData: call.data}); + } + + /// @notice Converts the given call to the format expected by the `CBMulticall.aggregate3Value` function. + /// + /// @param call The call to convert to the format expected by the `CBMulticall.aggregate3Value` function. + /// + /// @return The call in the format expected by the `CBMulticall.aggregate3Value` function. + function _toCall3Value(Call memory call) internal pure returns (CBMulticall.Call3Value memory) { + require(call.operation == Enum.Operation.Call, "MultisigScript::_toCall3Value: Operation must be Call"); + require(call.value > 0, "MultisigScript::_toCall3Value: Value must be greater than 0"); + + return + CBMulticall.Call3Value({target: call.target, allowFailure: false, value: call.value, callData: call.data}); + } + + /// @notice Converts the given call to the format expected by the `CBMulticall.aggregateDelegateCalls` function. + /// + /// @param call The call to convert to the format expected by the `CBMulticall.aggregateDelegateCalls` function. + /// + /// @return The call in the format expected by the `CBMulticall.aggregateDelegateCalls` function. + function _toDelegateCall3(Call memory call) internal pure returns (CBMulticall.Call3 memory) { + require( + call.operation == Enum.Operation.DelegateCall, + "MultisigScript::_toDelegateCall3: Operation must be DelegateCall" + ); + require(call.value == 0, "MultisigScript::_toDelegateCall3: Value must be 0"); + + return CBMulticall.Call3({target: call.target, allowFailure: false, callData: call.data}); + } + + /// @notice Converts the given calls to the format expected by the `aggregate3` function. + /// + /// @param calls The calls to get the call3 values for. + /// + /// @return The calls in the format expected by the `aggregate3` function. + function _toCall3s(Call[] memory calls) internal pure returns (CBMulticall.Call3[] memory) { + CBMulticall.Call3[] memory call3s = new CBMulticall.Call3[](calls.length); + for (uint256 i; i < calls.length; i++) { + call3s[i] = _toCall3(calls[i]); + } + + return call3s; } - function _getOperation(uint256 value) private view returns (Enum.Operation) { - if (multicallAddress == CB_MULTICALL || value == 0) { - return Enum.Operation.DelegateCall; + /// @notice Converts the given calls to the format expected by the `aggregate3Value` function. + /// + /// @param calls The calls to get the call3 values for. + /// + /// @return The calls in the format expected by the `aggregate3` function. + function _toCall3Values(Call[] memory calls) internal pure returns (CBMulticall.Call3Value[] memory) { + CBMulticall.Call3Value[] memory call3Values = new CBMulticall.Call3Value[](calls.length); + for (uint256 i; i < calls.length; i++) { + call3Values[i] = _toCall3Value(calls[i]); } - return Enum.Operation.Call; + return call3Values; + } + + /// @notice Converts the given calls to the format expected by the `aggregateDelegateCalls` function. + /// + /// @param calls The calls to get the call3 values for. + /// + /// @return The calls in the format expected by the `aggregateDelegateCalls` function. + function _toDelegateCall3s(Call[] memory calls) internal pure returns (CBMulticall.Call3[] memory) { + CBMulticall.Call3[] memory delegateCall3s = new CBMulticall.Call3[](calls.length); + for (uint256 i; i < calls.length; i++) { + delegateCall3s[i] = _toDelegateCall3(calls[i]); + } + + return delegateCall3s; + } + + /// @notice Wraps the given address in an array of one address. + /// + /// @param addr The address to wrap. + /// + /// @return The address wrapped in an array of one address. + function _toArray(address addr) internal pure returns (address[] memory) { + address[] memory array = new address[](1); + array[0] = addr; + return array; } } diff --git a/script/universal/MultisigScriptV2.sol b/script/universal/MultisigScriptV2.sol deleted file mode 100644 index 12281fa..0000000 --- a/script/universal/MultisigScriptV2.sol +++ /dev/null @@ -1,929 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.15; - -// solhint-disable no-console -import {console} from "lib/forge-std/src/console.sol"; -import {Script} from "lib/forge-std/src/Script.sol"; -import {Vm} from "lib/forge-std/src/Vm.sol"; - -import {CBMulticall} from "../../src/utils/CBMulticall.sol"; - -import {IGnosisSafe, Enum} from "./IGnosisSafe.sol"; -import {Signatures} from "./Signatures.sol"; -import {StateDiff} from "./StateDiff.sol"; -import {Simulation} from "./Simulation.sol"; - -/// @title MultisigScript -/// @notice Script builder for Forge scripts that require signatures from Safes. Supports both non-nested -/// Safes, as well as nested Safes of arbitrary depth (Safes where the signers are other Safes). -/// -/// 1. Non-nested example: -/// -/// Setup: -/// ┌───────┐┌───────┐ -/// │Signer1││Signer2│ -/// └┬──────┘└┬──────┘ -/// ┌▽────────▽┐ -/// │Multisig │ -/// └┬─────────┘ -/// ┌▽─────────┐ -/// │ProxyAdmin│ -/// └──────────┘ -/// -/// Sequence: -/// ┌───────┐┌───────┐┌───────────┐┌──────────────┐ -/// │Signer1││Signer2││Facilitator││MultisigScript│ -/// └───┬───┘└───┬───┘└─────┬─────┘└───────┬──────┘ -/// │ │ sign() │ -/// │─────────────────────────────────>│ -/// │ │ │ -/// │──────────────────>│ │ -/// │ │ sign() │ -/// │ │────────────────────────>│ -/// │ │ │ │ -/// │ │─────────>│ │ -/// │ │ │run(sig1,sig2)│ -/// │ │ │─────────────>│ -/// -/// -/// 2. Single-layer nested example: -/// -/// Setup: -/// ┌───────┐┌───────┐┌───────┐┌───────┐ -/// │Signer1││Signer2││Signer3││Signer4│ -/// └┬──────┘└┬──────┘└┬──────┘└┬──────┘ -/// ┌▽────────▽┐┌──────▽────────▽┐ -/// │Safe1 ││Safe2 │ -/// └┬─────────┘└┬───────────────┘ -/// ┌▽───────────▽┐ -/// │Safe3 │ -/// └┬────────────┘ -/// ┌▽─────────┐ -/// │ProxyAdmin│ -/// └──────────┘ -/// -/// Sequence: -/// ┌───────┐┌───────┐┌───────┐┌───────┐┌───────────┐ ┌──────────────┐ -/// │Signer1││Signer2││Signer3││Signer4││Facilitator│ │MultisigScript│ -/// └───┬───┘└───┬───┘└───┬───┘└───┬───┘└─────┬─────┘ └───────┬──────┘ -/// │ │ │ sign(Safe1) │ │ -/// │─────────────────────────────────────────────────────────────>│ -/// │ │ │ │ │ -/// │────────────────────────────────────>│ │ -/// │ │ │ │ sign(Safe1) │ -/// │ │────────────────────────────────────────────────────>│ -/// │ │ │ │ │ │ -/// │ │───────────────────────────>│ │ -/// │ │ │ │ │approve(Safe1,sig1|sig2)│ -/// │ │ │ │ │───────────────────────>│ -/// │ │ │ │ sign(Safe2) │ -/// │ │ │───────────────────────────────────────────>│ -/// │ │ │ │ │ -/// │ │ │──────────────────>│ │ -/// │ │ │ │ │ sign(Safe2) │ -/// │ │ │ │──────────────────────────────────>│ -/// │ │ │ │ │ │ -/// │ │ │ │─────────>│ │ -/// │ │ │ │ │approve(Safe2,sig3|sig4)│ -/// │ │ │ │ │───────────────────────>│ -/// │ │ │ │ │ run() │ -/// │ │ │ │ │───────────────────────>│ -/// -/// -/// 3. Multi-layer nested example: -/// -/// Setup: -/// ┌───────┐┌───────┐┌───────┐┌───────┐┌───────┐┌───────┐ -/// │Signer1││Signer2││Signer3││Signer4││Signer5││Signer6│ -/// └┬──────┘└┬──────┘└┬──────┘└┬──────┘└┬──────┘└┬──────┘ -/// ┌▽────────▽┐┌──────▽────────▽┐┌──────▽────────▽┐ -/// │Safe1 ││Safe2 ││Safe3 │ -/// └┬─────────┘└┬───────────────┘└┬───────────────┘ -/// ┌▽───────────▽┐ │ -/// │Safe4 │ │ -/// └┬────────────┘ │ -/// ┌▽─────────────────────────────▽┐ -/// │Safe5 │ -/// └┬──────────────────────────────┘ -/// ┌▽─────────┐ -/// │ProxyAdmin│ -/// └──────────┘ -/// -/// Sequence: -/// ┌───────┐┌───────┐┌───────┐┌───────┐┌───────┐┌───────┐┌───────────┐ ┌──────────────┐ -/// │Signer1││Signer2││Signer3││Signer4││Signer5││Signer6││Facilitator│ │MultisigScript│ -/// └───┬───┘└───┬───┘└───┬───┘└───┬───┘└───┬───┘└───┬───┘└─────┬─────┘ └───────┬──────┘ -/// │ │ │ │ sign(Safe1,Safe4) │ │ -/// │─────────────────────────────────────────────────────────────────────────────────────>│ -/// │ │ │ │ │ │ │ -/// │──────────────────────────────────────────────────────>│ │ -/// │ │ │ │ │ sign(Safe1,Safe4) │ -/// │ │────────────────────────────────────────────────────────────────────────────>│ -/// │ │ │ │ │ │ │ │ -/// │ │─────────────────────────────────────────────>│ │ -/// │ │ │ │ │ │ │approve(Safe1,Safe4,sig1|sig2)│ -/// │ │ │ │ │ │ │─────────────────────────────>│ -/// │ │ │ │ │ sign(Safe2,Safe4) │ -/// │ │ │───────────────────────────────────────────────────────────────────>│ -/// │ │ │ │ │ │ │ -/// │ │ │────────────────────────────────────>│ │ -/// │ │ │ │ │ │ sign(Safe2,Safe4) │ -/// │ │ │ │──────────────────────────────────────────────────────────>│ -/// │ │ │ │ │ │ │ │ -/// │ │ │ │───────────────────────────>│ │ -/// │ │ │ │ │ │ │approve(Safe2,Safe4,sig3|sig4)│ -/// │ │ │ │ │ │ │─────────────────────────────>│ -/// │ │ │ │ │ │ │ approve(Safe4) │ -/// │ │ │ │ │ │ │─────────────────────────────>│ -/// │ │ │ │ │ │ sign(Safe3) │ -/// │ │ │ │ │─────────────────────────────────────────────────>│ -/// │ │ │ │ │ │ │ -/// │ │ │ │ │──────────────────>│ │ -/// │ │ │ │ │ │ │ sign(Safe3) │ -/// │ │ │ │ │ │────────────────────────────────────────>│ -/// │ │ │ │ │ │ │ │ -/// │ │ │ │ │ │─────────>│ │ -/// │ │ │ │ │ │ │ approve(Safe3,sig5|sig6) │ -/// │ │ │ │ │ │ │─────────────────────────────>│ -/// │ │ │ │ │ │ │ run() │ -/// │ │ │ │ │ │ │─────────────────────────────>│ -abstract contract MultisigScriptV2 is Script { - bytes32 internal constant SAFE_NONCE_SLOT = bytes32(uint256(5)); - address internal constant CB_MULTICALL = 0xA8B8CA1d6F0F5Ce63dCEA9121A01b302c5801303; - - /// @notice A struct representing a call to a contract. - /// - /// @param operation The operation to perform on the contract. - /// @param target The address of the contract to call. - /// @param data The data to call the contract with. - /// @param value The value to send with the call. - struct Call { - Enum.Operation operation; - address target; - bytes data; - uint256 value; - } - - /// @notice The available types of for call3 calls. - enum Call3Type { - DELEGATE_CALL, - CALL, - CALL_VALUE - } - - /// @dev Event emitted from a `sign()` call containing the data to sign. Used in testing. - event DataToSign(bytes data); - - ////////////////////////////////////////////////////////////////////////////////////// - /// Virtual Functions /// - ////////////////////////////////////////////////////////////////////////////////////// - - /// @notice Returns the safe address to execute the final transaction from - function _ownerSafe() internal view virtual returns (address); - - /// @notice Creates the calldata for signatures (`sign`), approvals (`approve`), and execution (`run`) - function _buildCalls() internal view virtual returns (Call[] memory); - - /// @notice Follow up assertions to ensure that the script ran to completion. - /// @dev Called after `sign` and `run`, but not `approve`. - function _postCheck(Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) internal virtual; - - /// @notice Follow up assertions on state and simulation after a `sign` call. - function _postSign(Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) internal virtual {} - - /// @notice Follow up assertions on state and simulation after a `approve` call. - function _postApprove(Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) internal virtual {} - - /// @notice Follow up assertions on state and simulation after a `run` call. - function _postRun(Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) internal virtual {} - - // Tenderly simulations can accept generic state overrides. This hook enables this functionality. - // By default, an empty (no-op) override is returned. - function _simulationOverrides() internal view virtual returns (Simulation.StateOverride[] memory overrides_) {} - - ////////////////////////////////////////////////////////////////////////////////////// - /// Public Functions /// - ////////////////////////////////////////////////////////////////////////////////////// - - /// Step 1 - /// ====== - /// Generate a transaction approval data to sign. This method should be called by a threshold of - /// multisig owners. - /// - /// For non-nested multisigs, the signatures can then be used to execute the transaction (see step 3). - /// - /// For nested multisigs, the signatures can be used to execute an approval transaction for each - /// multisig (see step 2). - /// - /// @param safes A list of nested safes (excluding the executing safe returned by `_ownerSafe`). - function sign(address[] memory safes) public { - safes = _appendOwnerSafe({safes: safes}); - - // Snapshot and restore Safe nonce after simulation, otherwise the data logged to sign - // would not match the actual data we need to sign, because the simulation - // would increment the nonce. - uint256[] memory originalNonces = new uint256[](safes.length); - for (uint256 i; i < safes.length; i++) { - originalNonces[i] = _getNonce({safe: safes[i]}); - } - - Call[] memory callsChain = _buildCallsChain({safes: safes}); - (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = - _simulateForSigner({safes: safes, callsChain: callsChain}); - (StateDiff.MappingParent[] memory parents, string memory json) = - StateDiff.collectStateDiff(StateDiff.CollectStateDiffOpts({accesses: accesses, simPayload: simPayload})); - - _postSign({accesses: accesses, simPayload: simPayload}); - _postCheck({accesses: accesses, simPayload: simPayload}); - - // Restore the original nonce. - for (uint256 i; i < safes.length; i++) { - vm.store({target: safes[i], slot: SAFE_NONCE_SLOT, value: bytes32(originalNonces[i])}); - } - - bytes memory txData = _encodeTransactionData({safe: safes[0], call: callsChain[0]}); - StateDiff.recordStateDiff({json: json, parents: parents, txData: txData, targetSafe: _ownerSafe()}); - - _printDataToSign({safe: safes[0], call: callsChain[0]}); - } - - /// Step 1.1 (optional) - /// ====== - /// Verify the signatures generated from step 1 are valid. - /// This allows transactions to be pre-signed and stored safely before execution. - /// - /// @param safes A list of nested safes (excluding the executing safe returned by `_ownerSafe`). - /// @param signatures The signatures to verify (concatenated, 65-bytes per sig). - function verify(address[] memory safes, bytes memory signatures) public view { - safes = _appendOwnerSafe({safes: safes}); - - Call[] memory callsChain = _buildCallsChain({safes: safes}); - _checkSignatures({safe: safes[0], call: callsChain[0], signatures: signatures}); - } - - /// Step 2 (optional for non-nested setups) - /// ====== - /// Execute an approval transaction. This method should be called by a facilitator - /// (non-signer), once for each of the multisigs involved in the nested multisig, - /// after collecting a threshold of signatures for each multisig (see step 1). - /// - /// For multiple layers of nesting, this should be called for each layer of nesting (once - /// the inner multisigs have registered their approval). The array of safes passed to - /// `safes` should get smaller by one for each layer of nesting. - /// - /// @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 { - safes = _appendOwnerSafe({safes: safes}); - - Call[] memory callsChain = _buildCallsChain({safes: safes}); - (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = - _executeTransaction({safe: safes[0], call: callsChain[0], signatures: signatures, broadcast: true}); - - _postApprove({accesses: accesses, simPayload: simPayload}); - } - - /// Step 2.1 (optional) - /// ====== - /// Simulate the transaction. This method should be called by a facilitator (non-signer), after all of the - /// signatures have been collected (non-nested case, see step 1), or the approval transactions have been - /// submitted onchain (nested case, see step 2, in which case `signatures` can be empty). - /// - /// 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 { - address ownerSafe = _ownerSafe(); - Call[] memory callsChain = _buildCallsChain({safes: _toArray(ownerSafe)}); - - vm.store({target: ownerSafe, slot: SAFE_NONCE_SLOT, value: bytes32(_getNonce({safe: ownerSafe}))}); - - (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = - _executeTransaction({safe: ownerSafe, call: callsChain[0], signatures: signatures, broadcast: false}); - - _postRun({accesses: accesses, simPayload: simPayload}); - _postCheck({accesses: accesses, simPayload: simPayload}); - } - - /// Step 3 - /// ====== - /// Execute the transaction. This method should be called by a facilitator (non-signer), after all of the - /// signatures have been collected (non-nested case, see step 1), or the approval transactions have been - /// 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 { - address ownerSafe = _ownerSafe(); - Call[] memory callsChain = _buildCallsChain({safes: _toArray(ownerSafe)}); - - (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = - _executeTransaction({safe: ownerSafe, call: callsChain[0], signatures: signatures, broadcast: true}); - - _postRun({accesses: accesses, simPayload: simPayload}); - _postCheck({accesses: accesses, simPayload: simPayload}); - } - - ////////////////////////////////////////////////////////////////////////////////////// - /// Internal Functions /// - ////////////////////////////////////////////////////////////////////////////////////// - - /// @notice Appends the owner safe to the list of safes. - /// - /// @param safes The list of safes to append the owner safe to. - /// - /// @return The list of safes with the owner safe appended. - function _appendOwnerSafe(address[] memory safes) internal view returns (address[] memory) { - address[] memory extendedSafes = new address[](safes.length + 1); - for (uint256 i; i < safes.length; i++) { - extendedSafes[i] = safes[i]; - } - extendedSafes[extendedSafes.length - 1] = _ownerSafe(); - return extendedSafes; - } - - /// @notice Wrapper around `_buildCalls` that checks that the script calls are valid. - /// - /// @return The list of calls. - function _buildCallsChecked() internal view returns (Call[] memory) { - Call[] memory scriptCalls = _buildCalls(); - for (uint256 i; i < scriptCalls.length; i++) { - Call memory call = scriptCalls[i]; - - require( - call.operation == Enum.Operation.Call || call.value == 0, - "MultisigScript::_buildCallsChecked: Value must be 0 for delegate calls" - ); - } - - return scriptCalls; - } - - /// @notice Build the list of safe-to-safe approval calls followed by the final script call. - /// - /// @param safes The list of safes to build the calls chain for. - /// - /// @return callsChain The calls chain for the given safes. - function _buildCallsChain(address[] memory safes) internal view returns (Call[] memory callsChain) { - // Build the script calls. - Call[] memory scriptCalls = _buildCallsChecked(); - - // Build the final script call. - Call memory aggregatedScriptCall = _buildAggregatedScriptCall({scriptCalls: scriptCalls}); - - // The very last call is the actual call to execute - callsChain = new Call[](safes.length); - callsChain[callsChain.length - 1] = aggregatedScriptCall; - - // The first n-1 calls are the nested approval calls. We build the approvals backwards, starting from the last safe. - for (uint256 i = safes.length - 1; i > 0; i--) { - address safe = safes[i]; - Call memory callToApprove = callsChain[i]; - - callsChain[i - 1] = _buildApproveCall({safe: safe, call: callToApprove}); - } - } - - /// @notice Builds the aggregated script call. - /// - /// @param scriptCalls The list of script calls to aggregate. - /// - /// @return The aggregated script call. - function _buildAggregatedScriptCall(Call[] memory scriptCalls) internal pure returns (Call memory) { - // When there is only one call, we return it directly as there is no need to aggregate it into a Multicall call. - if (scriptCalls.length == 1) { - return scriptCalls[0]; - } - - CBMulticall.Call3[] memory rootCalls = new CBMulticall.Call3[](scriptCalls.length); - uint256 rootCallsIndex; - - Call[] memory currentGroup = new Call[](scriptCalls.length); - currentGroup[0] = scriptCalls[0]; - uint256 currentGroupIndex; - - for (uint256 i; i < scriptCalls.length; i++) { - Call memory currentCall = scriptCalls[i]; - Call3Type currentType = _getCall3Type(currentCall); - Call3Type groupType = _getCall3Type(currentGroup[0]); - - // If the current call has the same type as the current group, add it to the current group and continue. - if (groupType == currentType) { - currentGroup[currentGroupIndex] = currentCall; - currentGroupIndex++; - continue; - } - - // Consume the current group and append the calls to the root calls. - rootCallsIndex += _aggregateCalls({ - groupType: groupType, - rootCalls: rootCalls, - rootCallsIndex: rootCallsIndex, - currentGroup: currentGroup, - currentGroupIndex: currentGroupIndex - }); - - // Reset the current group (for the next group) - currentGroup[0] = currentCall; - currentGroupIndex = 1; - } - - // Process the final group left in the current group. - rootCallsIndex += _aggregateCalls({ - groupType: _getCall3Type(currentGroup[0]), - rootCalls: rootCalls, - rootCallsIndex: rootCallsIndex, - currentGroup: currentGroup, - currentGroupIndex: currentGroupIndex - }); - - // NOTE: When aggregating via a Multicall call, the root call is always a delegatecall to `aggregateDelegateCalls` - // as it offers the most flexibility and allows perofming any other type of call. - return Call({ - operation: Enum.Operation.DelegateCall, - target: CB_MULTICALL, - data: abi.encodeCall(CBMulticall.aggregateDelegateCalls, (rootCalls)), - value: 0 - }); - } - - /// @notice Aggregates the current group of calls into a single Multicall call. - /// - /// @param groupType The type of the current group. - /// @param rootCalls The root calls to append the calls to. - /// @param rootCallsIndex The index of the root calls to append the calls to. - /// @param currentGroup The current group of calls to consume. - /// @param currentGroupIndex The index of the current group. - /// - /// @return rootCallsCount The number of root calls appended. - function _aggregateCalls( - Call3Type groupType, - CBMulticall.Call3[] memory rootCalls, - uint256 rootCallsIndex, - Call[] memory currentGroup, - uint256 currentGroupIndex - ) internal pure returns (uint256 rootCallsCount) { - uint256 rootCallsIndexSaved = rootCallsIndex; - - // Append the call3 delegate calls directly to the root calls. - if (groupType == Call3Type.DELEGATE_CALL) { - for (uint256 j; j < currentGroupIndex; j++) { - rootCalls[rootCallsIndex] = _toDelegateCall3(currentGroup[j]); - rootCallsIndex++; - } - } - // Otherwise aggregate the calls into a single Multicall call. - else { - CBMulticall.Call3 memory rootCall; - - if (groupType == Call3Type.CALL) { - CBMulticall.Call3[] memory call3s = new CBMulticall.Call3[](currentGroupIndex); - for (uint256 j; j < currentGroupIndex; j++) { - call3s[j] = _toCall3(currentGroup[j]); - } - - rootCall = CBMulticall.Call3({ - target: CB_MULTICALL, - allowFailure: false, - callData: abi.encodeCall(CBMulticall.aggregate3, (call3s)) - }); - } else { - CBMulticall.Call3Value[] memory call3Values = new CBMulticall.Call3Value[](currentGroupIndex); - for (uint256 j; j < currentGroupIndex; j++) { - call3Values[j] = _toCall3Value(currentGroup[j]); - } - - rootCall = CBMulticall.Call3({ - target: CB_MULTICALL, - allowFailure: false, - callData: abi.encodeCall(CBMulticall.aggregate3Value, (call3Values)) - }); - } - - rootCalls[rootCallsIndex] = rootCall; - rootCallsIndex++; - } - - // Return the number of root calls appended. - rootCallsCount = rootCallsIndex - rootCallsIndexSaved; - } - - /// @notice Builds the approve call (`approveHash`) for the given safe and call. - /// - /// @param safe The address of the safe to approve. - /// @param call The call to approve. - /// - /// @return The approve call. - function _buildApproveCall(address safe, Call memory call) internal view returns (Call memory) { - bytes32 hash = _getTransactionHash({safe: safe, call: call}); - - console.log("---\nNested hash for safe %s:", safe); - console.logBytes32(hash); - - return Call({ - operation: Enum.Operation.Call, - target: safe, - data: abi.encodeCall(IGnosisSafe(safe).approveHash, (hash)), - value: 0 - }); - } - - /// @notice Prints the data to sign for the given safe and call. - /// - /// @param safe The address of the safe to print the data to sign for. - /// @param call The call to print the data to sign for. - function _printDataToSign(address safe, Call memory call) internal { - bytes memory txData = _encodeTransactionData({safe: safe, call: call}); - emit DataToSign({data: txData}); - - console.log("---\nIf submitting onchain, call Safe.approveHash on %s with the following hash:", safe); - console.logBytes32(_getTransactionHash({safe: safe, call: call})); - - console.log("---\nData to sign:"); - console.log("vvvvvvvv"); - console.logBytes(txData); - console.log("^^^^^^^^\n"); - - console.log("########## IMPORTANT ##########"); - console.log( - // solhint-disable-next-line max-line-length - "Please make sure that the 'Data to sign' displayed above matches what you see in the simulation and on your hardware wallet." - ); - console.log("This is a critical step that must not be skipped."); - console.log("###############################"); - } - - /// @notice Executes the given transaction. - /// - /// @param safe The address of the safe to execute the transaction from. - /// @param call The call to execute. - /// @param signatures The signatures to use for the transaction. - /// @param broadcast Whether to broadcast the transaction. - /// - /// @return The account accesses and simulation payload. - function _executeTransaction(address safe, Call memory call, bytes memory signatures, bool broadcast) - internal - returns (Vm.AccountAccess[] memory, Simulation.Payload memory) - { - bytes32 hash = _getTransactionHash({safe: safe, call: call}); - signatures = Signatures.prepareSignatures({safe: safe, hash: hash, signatures: signatures}); - - Call memory simCall = _buildExecTransactionCall({safe: safe, call: call, signatures: signatures}); - Simulation.logSimulationLink({to: safe, data: simCall.data, from: msg.sender}); - - vm.startStateDiffRecording(); - bool success = _execTransaction({safe: safe, call: call, signatures: signatures, broadcast: broadcast}); - Vm.AccountAccess[] memory accesses = vm.stopAndReturnStateDiff(); - require(success, "MultisigScript::_executeTransaction: Transaction failed"); - require(accesses.length > 0, "MultisigScript::_executeTransaction: No state changes"); - - // This can be used to e.g. call out to the Tenderly API and get additional - // data about the state diff before broadcasting the transaction. - Simulation.Payload memory simPayload = Simulation.Payload({ - from: msg.sender, to: safe, data: simCall.data, stateOverrides: new Simulation.StateOverride[](0) - }); - return (accesses, simPayload); - } - - /// @notice Simulates the given `callsChain` associated to the given `safes` as if initiated by `msg.sender`. - /// - /// @param safes The list of safes to simulate the transaction for. - /// @param callsChain The list of calls to simulate the transaction for. - /// - /// @return The account accesses and simulation payload. - function _simulateForSigner(address[] memory safes, Call[] memory callsChain) - internal - returns (Vm.AccountAccess[] memory, Simulation.Payload memory) - { - // Define the state overrides for the simulation. - bytes32 firstCallDataHash = _getTransactionHash({safe: safes[0], call: callsChain[0]}); - Simulation.StateOverride[] memory overrides = _overrides({safes: safes, firstCallDataHash: firstCallDataHash}); - - // Build the `execTransaction` calls chain for all the safe-to-safe approvals followed by the final script call. - Call[] memory execTransactionCalls = _buildExecTransactionCalls({safes: safes, callsChain: callsChain}); - bytes memory txData = abi.encodeCall(CBMulticall.aggregate3, (_toCall3s(execTransactionCalls))); - console.logBytes(txData); - - console.log("---\nSimulation link:"); - Simulation.logSimulationLink({to: CB_MULTICALL, data: txData, from: msg.sender, overrides: overrides}); - - // Forge simulation of the data logged in the link. If the simulation fails we revert to make it explicit that the simulation failed. - Simulation.Payload memory simPayload = - Simulation.Payload({to: CB_MULTICALL, data: txData, from: msg.sender, stateOverrides: overrides}); - Vm.AccountAccess[] memory accesses = Simulation.simulateFromSimPayload({simPayload: simPayload}); - return (accesses, simPayload); - } - - /// @notice Wraps each of the given calls in a `execTransaction` call. - /// - /// @param safes The list of safes to execute the calls from. - /// @param callsChain The list of calls to wrap in a `execTransaction` call. - /// - /// @return execTransactionCalls The list of `execTransaction` calls. - function _buildExecTransactionCalls(address[] memory safes, Call[] memory callsChain) - internal - view - returns (Call[] memory execTransactionCalls) - { - require( - safes.length == callsChain.length, - "MultisigScript::_buildExecTransactionCalls: Safes and callsChain must have the same length" - ); - - execTransactionCalls = new Call[](safes.length); - for (uint256 i; i < safes.length; i++) { - address signer = i == 0 ? msg.sender : safes[i - 1]; - - execTransactionCalls[i] = _buildExecTransactionCall({ - safe: safes[i], call: callsChain[i], signatures: Signatures.genPrevalidatedSignature(signer) - }); - } - } - - // The state change simulation can set the threshold, owner address and/or nonce. - // This allows simulation of the final transaction by overriding the threshold to 1. - // State changes reflected in the simulation as a result of these overrides will - // not be reflected in the prod execution. - function _overrides(address[] memory safes, bytes32 firstCallDataHash) - internal - view - returns (Simulation.StateOverride[] memory) - { - Simulation.StateOverride[] memory simOverrides = _simulationOverrides(); - Simulation.StateOverride[] memory overrides = new Simulation.StateOverride[](safes.length + simOverrides.length); - - uint256 nonce = _getNonce({safe: safes[0]}); - overrides[0] = Simulation.overrideSafeThresholdApprovalAndNonce({ - safe: safes[0], nonce: nonce, owner: msg.sender, dataHash: firstCallDataHash - }); - - for (uint256 i = 1; i < safes.length; i++) { - overrides[i] = - Simulation.overrideSafeThresholdAndNonce({safe: safes[i], nonce: _getNonce({safe: safes[i]})}); - } - - for (uint256 i; i < simOverrides.length; i++) { - overrides[i + safes.length] = simOverrides[i]; - } - - return overrides; - } - - // Get the nonce to use for the given safe, for signing and simulations. - // - // If you override it, ensure that the behavior is correct for all contexts. - // As an example, if you are pre-signing a message that needs safe.nonce+1 (before - // safe.nonce is executed), you should explicitly set the nonce value with an env var. - // Overriding this method with safe.nonce+1 will cause issues upon execution because - // the transaction hash will differ from the one signed. - // - // The process for determining a nonce override is as follows: - // 1. We look for an env var of the name SAFE_NONCE_{UPPERCASE_SAFE_ADDRESS}. For example, - // SAFE_NONCE_0X6DF4742A3C28790E63FE933F7D108FE9FCE51EA4. - // 2. If it exists, we use it as the nonce override for the safe. - // 3. If it does not exist, we do the same for the SAFE_NONCE env var. - // 4. Otherwise we fallback to the safe's current nonce (no override). - function _getNonce(address safe) internal view virtual returns (uint256 nonce) { - uint256 safeNonce = IGnosisSafe(safe).nonce(); - nonce = safeNonce; - - // first try SAFE_NONCE - try vm.envUint({name: "SAFE_NONCE"}) { - nonce = vm.envUint({name: "SAFE_NONCE"}); - } catch {} - - // then try SAFE_NONCE_{UPPERCASE_SAFE_ADDRESS} - string memory envVarName = string.concat("SAFE_NONCE_", vm.toUppercase({input: vm.toString({value: safe})})); - try vm.envUint({name: envVarName}) { - nonce = vm.envUint({name: envVarName}); - } catch {} - - // print if any override - if (nonce != safeNonce) { - console.log("Overriding nonce for safe %s: %d -> %d", safe, safeNonce, nonce); - } - } - - /// @notice Returns the result of `encodeTransactionData` function from the given safe for the given call. - /// - /// @param safe The address of the safe that will execute the transaction. - /// @param call The call to get the encoded transaction data for. - /// - /// @return The result of `encodeTransactionData` function from the given safe for the given call. - function _encodeTransactionData(address safe, Call memory call) internal view returns (bytes memory) { - return IGnosisSafe(safe) - .encodeTransactionData({ - to: call.target, - value: call.value, - data: call.data, - operation: call.operation, - safeTxGas: 0, - baseGas: 0, - gasPrice: 0, - gasToken: address(0), - refundReceiver: address(0), - _nonce: _getNonce(safe) - }); - } - - /// @notice Checks the signatures for the given safe and call. - /// - /// @param safe The address of the safe to check the signatures for. - /// @param call The call to check the signatures for. - /// @param signatures The signatures to check. - function _checkSignatures(address safe, Call memory call, bytes memory signatures) internal view { - bytes32 hash = _getTransactionHash({safe: safe, call: call}); - signatures = Signatures.prepareSignatures({safe: safe, hash: hash, signatures: signatures}); - - IGnosisSafe(safe) - .checkSignatures({ - dataHash: hash, - data: _encodeTransactionData({safe: safe, call: call}), // NOTE: This field is the data preimage but not strictly required as `checkSignatures` ignores it. - signatures: signatures - }); - } - - /// @notice Gets the transaction hash for the given safe and call. - /// - /// @param safe The address of the safe that will execute the transaction. - /// @param call The call to get the transaction hash for. - /// - /// @return The transaction hash for the given safe and call. - function _getTransactionHash(address safe, Call memory call) internal view returns (bytes32) { - return keccak256(_encodeTransactionData({safe: safe, call: call})); - } - - /// @notice Wrapps the given `call` in a `execTransaction` call. - /// - /// @param safe The address of the safe to execute the transaction from. - /// @param call The call to execute. - /// @param signatures The signatures to use for the transaction. - /// - /// @return The execTransaction call. - function _buildExecTransactionCall(address safe, Call memory call, bytes memory signatures) - internal - pure - returns (Call memory) - { - return Call({ - operation: Enum.Operation.Call, - target: safe, - data: abi.encodeCall( - IGnosisSafe(safe).execTransaction, - ( - call.target, // to - call.value, // value - call.data, // data - call.operation, // operation - 0, // safeTxGas - 0, // baseGas - 0, // gasPrice - address(0), // gasToken - payable(address(0)), // refundReceiver - signatures // signatures - ) - ), - value: 0 - }); - } - - /// @notice Executes the given call from the given safe. - /// - /// @param safe The address of the safe to execute the call from. - /// @param call The call to execute. - /// @param signatures The signatures to use for the transaction. - /// @param broadcast Whether to broadcast the transaction. - /// - /// @return The result of the transaction. - function _execTransaction(address safe, Call memory call, bytes memory signatures, bool broadcast) - internal - returns (bool) - { - if (broadcast) { - vm.broadcast(); - } - - return IGnosisSafe(safe) - .execTransaction({ - to: call.target, - value: call.value, - data: call.data, - operation: call.operation, - safeTxGas: 0, - baseGas: 0, - gasPrice: 0, - gasToken: address(0), - refundReceiver: payable(address(0)), - signatures: signatures - }); - } - - /// @notice Gets the type for the given call. - /// - /// @param call The call to get the type for. - /// - /// @return The type for the given call. - function _getCall3Type(Call memory call) internal pure returns (Call3Type) { - if (call.operation == Enum.Operation.DelegateCall) { - return Call3Type.DELEGATE_CALL; - } - - if (call.value == 0) { - return Call3Type.CALL; - } - - return Call3Type.CALL_VALUE; - } - - /// @notice Converts the given call to the format expected by the `CBMulticall.aggregate3` function. - /// - /// @param call The call to convert to the format expected by the `CBMulticall.aggregate3` function. - /// - /// @return The call in the format expected by the `CBMulticall.aggregate3` function. - function _toCall3(Call memory call) internal pure returns (CBMulticall.Call3 memory) { - require(call.operation == Enum.Operation.Call, "MultisigScript::_toCall3: Operation must be Call"); - require(call.value == 0, "MultisigScript::_toCall3: Value must be 0"); - - return CBMulticall.Call3({target: call.target, allowFailure: false, callData: call.data}); - } - - /// @notice Converts the given call to the format expected by the `CBMulticall.aggregate3Value` function. - /// - /// @param call The call to convert to the format expected by the `CBMulticall.aggregate3Value` function. - /// - /// @return The call in the format expected by the `CBMulticall.aggregate3Value` function. - function _toCall3Value(Call memory call) internal pure returns (CBMulticall.Call3Value memory) { - require(call.operation == Enum.Operation.Call, "MultisigScript::_toCall3Value: Operation must be Call"); - require(call.value > 0, "MultisigScript::_toCall3Value: Value must be greater than 0"); - - return - CBMulticall.Call3Value({target: call.target, allowFailure: false, value: call.value, callData: call.data}); - } - - /// @notice Converts the given call to the format expected by the `CBMulticall.aggregateDelegateCalls` function. - /// - /// @param call The call to convert to the format expected by the `CBMulticall.aggregateDelegateCalls` function. - /// - /// @return The call in the format expected by the `CBMulticall.aggregateDelegateCalls` function. - function _toDelegateCall3(Call memory call) internal pure returns (CBMulticall.Call3 memory) { - require( - call.operation == Enum.Operation.DelegateCall, - "MultisigScript::_toDelegateCall3: Operation must be DelegateCall" - ); - require(call.value == 0, "MultisigScript::_toDelegateCall3: Value must be 0"); - - return CBMulticall.Call3({target: call.target, allowFailure: false, callData: call.data}); - } - - /// @notice Converts the given calls to the format expected by the `aggregate3` function. - /// - /// @param calls The calls to get the call3 values for. - /// - /// @return The calls in the format expected by the `aggregate3` function. - function _toCall3s(Call[] memory calls) internal pure returns (CBMulticall.Call3[] memory) { - CBMulticall.Call3[] memory call3s = new CBMulticall.Call3[](calls.length); - for (uint256 i; i < calls.length; i++) { - call3s[i] = _toCall3(calls[i]); - } - - return call3s; - } - - /// @notice Converts the given calls to the format expected by the `aggregate3Value` function. - /// - /// @param calls The calls to get the call3 values for. - /// - /// @return The calls in the format expected by the `aggregate3` function. - function _toCall3Values(Call[] memory calls) internal pure returns (CBMulticall.Call3Value[] memory) { - CBMulticall.Call3Value[] memory call3Values = new CBMulticall.Call3Value[](calls.length); - for (uint256 i; i < calls.length; i++) { - call3Values[i] = _toCall3Value(calls[i]); - } - - return call3Values; - } - - /// @notice Converts the given calls to the format expected by the `aggregateDelegateCalls` function. - /// - /// @param calls The calls to get the call3 values for. - /// - /// @return The calls in the format expected by the `aggregateDelegateCalls` function. - function _toDelegateCall3s(Call[] memory calls) internal pure returns (CBMulticall.Call3[] memory) { - CBMulticall.Call3[] memory delegateCall3s = new CBMulticall.Call3[](calls.length); - for (uint256 i; i < calls.length; i++) { - delegateCall3s[i] = _toDelegateCall3(calls[i]); - } - - return delegateCall3s; - } - - /// @notice Wraps the given address in an array of one address. - /// - /// @param addr The address to wrap. - /// - /// @return The address wrapped in an array of one address. - function _toArray(address addr) internal pure returns (address[] memory) { - address[] memory array = new address[](1); - array[0] = addr; - return array; - } -} diff --git a/script/universal/NestedMultisigBuilder.sol b/script/universal/NestedMultisigBuilder.sol deleted file mode 100644 index 8a88ae4..0000000 --- a/script/universal/NestedMultisigBuilder.sol +++ /dev/null @@ -1,33 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.15; - -import {MultisigScript} from "./MultisigScript.sol"; - -/// @title NestedMultisigBuilder -/// @custom:deprecated Use `MultisigScript` instead. -abstract contract NestedMultisigBuilder is MultisigScript { - /// @custom:deprecated Use `sign(address[] memory safes)` instead. - function sign(address signerSafe) external { - sign({safes: _toArray(signerSafe)}); - } - - /// @custom:deprecated Use `approve(address[] memory safes, bytes memory signatures)` instead. - function approve(address signerSafe, bytes memory signatures) public { - approve({safes: _toArray(signerSafe), signatures: signatures}); - } - - /// @custom:deprecated Use `simulate(bytes memory signatures)` instead, with empty `signatures`. - function simulate() public { - simulate({signatures: ""}); - } - - /// @custom:deprecated Use `run(bytes memory signatures)` instead, with empty `signatures`. - function run() public { - run({signatures: ""}); - } - - /// @custom:deprecated Use `verify(address[] memory safes, bytes memory signatures)` instead. - function verify(address signerSafe, bytes memory signatures) public view { - verify({safes: _toArray(signerSafe), signatures: signatures}); - } -} diff --git a/test/universal/DoubleNestedMultisigBuilder.t.sol b/test/universal/DoubleNestedMultisigBuilder.t.sol deleted file mode 100644 index a8fdb42..0000000 --- a/test/universal/DoubleNestedMultisigBuilder.t.sol +++ /dev/null @@ -1,141 +0,0 @@ -// 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 {DoubleNestedMultisigBuilder} from "script/universal/DoubleNestedMultisigBuilder.sol"; -import {Simulation} from "script/universal/Simulation.sol"; -import {IGnosisSafe} from "script/universal/IGnosisSafe.sol"; - -import {Counter} from "test/universal/Counter.sol"; - -contract DoubleNestedMultisigBuilderTest is Test, DoubleNestedMultisigBuilder { - 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 { - // Check that the counter has been incremented - 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(); - bytes memory txData = abi.encodeCall(DoubleNestedMultisigBuilder.sign, (safe1, safe3)); - vm.prank(wallet1.addr); - (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(); - bytes memory txData = abi.encodeCall(DoubleNestedMultisigBuilder.sign, (safe2, safe3)); - vm.prank(wallet2.addr); - (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)); - approveOnBehalfOfSignerSafe(safe1, safe3, abi.encodePacked(r, s, v)); - } - - function test_approveInit_double_nested_safe2() external { - (uint8 v, bytes32 r, bytes32 s) = vm.sign(wallet2, keccak256(dataToSign2)); - approveOnBehalfOfSignerSafe(safe2, safe3, abi.encodePacked(r, s, v)); - } - - function test_approveInit_double_nested_notOwner() external { - (uint8 v, bytes32 r, bytes32 s) = vm.sign(wallet1, keccak256(dataToSign1)); - bytes memory data = abi.encodeCall(this.approveOnBehalfOfSignerSafe, (safe2, safe3, 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)); - approveOnBehalfOfSignerSafe(safe1, safe3, abi.encodePacked(r1, s1, v1)); - approveOnBehalfOfSignerSafe(safe2, safe3, abi.encodePacked(r2, s2, v2)); - approveOnBehalfOfIntermediateSafe(safe3); - } - - function test_runInit_double_nested_notApproved() external { - (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, keccak256(dataToSign1)); - approveOnBehalfOfSignerSafe(safe1, safe3, abi.encodePacked(r1, s1, v1)); - bytes memory data = abi.encodeCall(this.approveOnBehalfOfIntermediateSafe, (safe3)); - (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)); - approveOnBehalfOfSignerSafe(safe1, safe3, abi.encodePacked(r1, s1, v1)); - approveOnBehalfOfSignerSafe(safe2, safe3, abi.encodePacked(r2, s2, v2)); - approveOnBehalfOfIntermediateSafe(safe3); - - run(); - } -} diff --git a/test/universal/MultisigBuilder.t.sol b/test/universal/MultisigBuilder.t.sol deleted file mode 100644 index 252828d..0000000 --- a/test/universal/MultisigBuilder.t.sol +++ /dev/null @@ -1,159 +0,0 @@ -// 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 {MultisigBuilder} from "script/universal/MultisigBuilder.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 MultisigBuilderTest is Test, MultisigBuilder { - 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 dataToSignNoValue = - // solhint-disable-next-line max-line-length - hex"1901d4bb33110137810c444c1d9617abe97df097d587ecde64e6fcb38d7f49e1280cd0722aa57d06d71497c199147817c38ae160e5b355d3fb5ccbe34c3dbadeae6d"; - - bytes internal dataToSignWithValue = - // solhint-disable-next-line max-line-length - hex"1901d4bb33110137810c444c1d9617abe97df097d587ecde64e6fcb38d7f49e1280cd150dbb03d4bb38e5325a914ff3861da880437fd5856c0f7e39054e64e05aed0"; - - 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 { - // Check that the counter has been incremented - 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 test_sign_no_value() external { - buildCallsInternal = _buildCallsNoValue; - - vm.recordLogs(); - bytes memory txData = abi.encodeCall(MultisigBuilder.sign, ()); - vm.prank(wallet1.addr); - (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(dataToSignNoValue))); - } - - function test_sign_with_value() external { - buildCallsInternal = _buildCallsWithValue; - - vm.recordLogs(); - bytes memory txData = abi.encodeCall(MultisigBuilder.sign, ()); - vm.prank(wallet1.addr); - (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(dataToSignWithValue))); - } - - function test_run() external { - buildCallsInternal = _buildCallsNoValue; - (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, keccak256(dataToSignNoValue)); - (uint8 v2, bytes32 r2, bytes32 s2) = vm.sign(wallet2, keccak256(dataToSignNoValue)); - bytes memory signatures = abi.encodePacked(r1, s1, v1, r2, s2, v2); - run(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); - - // Sign with ALL 3 wallets (more than threshold of 2) - (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); - - // Provide all 3 signatures (more than threshold) - 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/MultisigScript.t.sol b/test/universal/MultisigScript.t.sol index adc8171..82407a3 100644 --- a/test/universal/MultisigScript.t.sol +++ b/test/universal/MultisigScript.t.sol @@ -1,7 +1,6 @@ // 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"; @@ -9,10 +8,11 @@ import {Preinstalls} from "lib/optimism/packages/contracts-bedrock/src/libraries 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"; +import {CBMulticall} from "src/utils/CBMulticall.sol"; + contract MultisigScriptTest is Test, MultisigScript { Vm.Wallet internal wallet1 = vm.createWallet("1"); Vm.Wallet internal wallet2 = vm.createWallet("2"); @@ -21,78 +21,135 @@ contract MultisigScriptTest is Test, MultisigScript { 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)); + deployCodeTo("CBMulticall.sol", "", CB_MULTICALL); vm.deal(safe, 10 ether); - address[] memory owners = new address[](2); + // Multisig ownership tree: + // + // ┌───────┐ ┌───────┐ ┌───────┐ + // │wallet1│ │wallet2│ │wallet3│ + // └───┬───┘ └───┬───┘ └───┬───┘ + // └────────┼────────┘ + // ┌────▽────┐ + // │ safe │ (threshold: 2/3) + // └────┬────┘ + // ┌────▽────┐ + // │ counter │ + // └─────────┘ + + address[] memory owners = new address[](3); owners[0] = wallet1.addr; owners[1] = wallet2.addr; + owners[2] = wallet3.addr; IGnosisSafe(safe).setup(owners, 2, address(0), "", address(0), address(0), 0, address(0)); } + /// @inheritdoc MultisigScript + /// + /// @dev Verifies counter was incremented 6 times and received 3 ether function _postCheck(Vm.AccountAccess[] memory, Simulation.Payload memory) internal view override { uint256 counterValue = counter.count(); - require(counterValue == 1, "Counter value is not 1"); + assertEq(counterValue, 6, "Counter value is not 6"); + + uint256 counterBalance = address(counter).balance; + assertEq(counterBalance, 3 ether, "Counter balance is not 1 ether"); } - function _buildCalls() internal view override returns (IMulticall3.Call3Value[] memory) { - return buildCallsInternal(); + /// @inheritdoc MultisigScript + /// + /// @dev Builds a mix of calls to test different operation types: + /// - 1 regular increment call + /// - 1 delegatecall with 2 increments via multicall + /// - 1 payable increment call (1 ether) + /// - 1 delegatecall with 2 payable increments via multicall (2 ether) + /// Total: 6 increments, 3 ether sent + function _buildCalls() internal view override returns (Call[] memory) { + Call memory counterIncrementCall = Call({ + operation: Enum.Operation.Call, + target: address(counter), + data: abi.encodeCall(Counter.increment, ()), + value: 0 + }); + + Call memory counterIncrementCallPayable = Call({ + operation: Enum.Operation.Call, + target: address(counter), + data: abi.encodeCall(Counter.incrementPayable, ()), + value: 1 ether + }); + + Call[] memory counterIncrementCalls = new Call[](2); + counterIncrementCalls[0] = counterIncrementCall; + counterIncrementCalls[1] = counterIncrementCall; + + Call[] memory counterIncrementCallsPayable = new Call[](2); + counterIncrementCallsPayable[0] = counterIncrementCallPayable; + counterIncrementCallsPayable[1] = counterIncrementCallPayable; + + Call[] memory calls = new Call[](4); + + calls[0] = Call({ + operation: Enum.Operation.Call, + target: address(counter), + data: abi.encodeCall(Counter.increment, ()), + value: 0 + }); + + // Use multicall to test the delegatecall use case + calls[1] = Call({ + operation: Enum.Operation.DelegateCall, + target: CB_MULTICALL, + data: abi.encodeCall(CBMulticall.aggregate3, (_toCall3s(counterIncrementCalls))), + value: 0 + }); + + calls[2] = Call({ + operation: Enum.Operation.Call, + target: address(counter), + data: abi.encodeCall(Counter.incrementPayable, ()), + value: 1 ether + }); + + calls[3] = Call({ + operation: Enum.Operation.DelegateCall, + target: CB_MULTICALL, + data: abi.encodeCall(CBMulticall.aggregate3Value, (_toCall3Values(counterIncrementCallsPayable))), + value: 0 + }); + + return calls; } + /// @inheritdoc MultisigScript function _ownerSafe() internal view override returns (address) { return address(safe); } + /// @notice Helper to compute the expected transaction data for signing + /// + /// @return The encoded transaction data that signers need to sign 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); + return _encodeTransactionData(_ownerSafe(), _buildAggregatedScriptCall({scriptCalls: _buildCalls()})); } - function test_sign_no_value() external { - buildCallsInternal = _buildCallsNoValue; - + /// @notice Tests that sign() emits the correct data to sign + function test_sign() external { 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); + this.sign(new address[](0)); + 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)); } + /// @notice Tests that verify() accepts valid 2-of-2 signatures 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); @@ -101,8 +158,8 @@ contract MultisigScriptTest is Test, MultisigScript { verify(new address[](0), signatures); } + /// @notice Tests that verify() reverts when given an invalid signature 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); @@ -113,8 +170,8 @@ contract MultisigScriptTest is Test, MultisigScript { assertTrue(ret.length > 0); } + /// @notice Tests that simulate() executes the transaction without broadcasting 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); @@ -124,66 +181,17 @@ contract MultisigScriptTest is Test, MultisigScript { simulate(signatures); } + /// @notice Tests that a Safe can execute with more signatures than the threshold requires + /// + /// @dev Safe is 2/3, but we provide all 3 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 - }); + // Sign with all 3 owners (threshold is 2, but we provide 3) + bytes32 digest = keccak256(_expectedTxDataForCurrentBuildCalls()); + (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, digest); + (uint8 v2, bytes32 r2, bytes32 s2) = vm.sign(wallet2, digest); + (uint8 v3, bytes32 r3, bytes32 s3) = vm.sign(wallet3, digest); - return calls; + bytes memory signatures = abi.encodePacked(r1, s1, v1, r2, s2, v2, r3, s3, v3); + run(signatures); } } diff --git a/test/universal/MultisigScriptDelegateCall.t.sol b/test/universal/MultisigScriptDelegateCall.t.sol deleted file mode 100644 index 57c0644..0000000 --- a/test/universal/MultisigScriptDelegateCall.t.sol +++ /dev/null @@ -1,110 +0,0 @@ -// 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 index 3bb0145..aa0a279 100644 --- a/test/universal/MultisigScriptDoubleNested.t.sol +++ b/test/universal/MultisigScriptDoubleNested.t.sol @@ -1,14 +1,13 @@ // 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 {IGnosisSafe, Enum} from "script/universal/IGnosisSafe.sol"; import {Counter} from "test/universal/Counter.sol"; @@ -22,19 +21,25 @@ contract MultisigScriptDoubleNestedTest is Test, MultisigScript { 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); + deployCodeTo("CBMulticall.sol", "", CB_MULTICALL); 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)); + + // Multisig ownership tree: + // + // safe4 (threshold: 1/1) + // | + // safe3 (threshold: 2/2) + // / \ + // / \ + // safe1 safe2 + // (1/1) (1/1) + // | | + // wallet1 wallet2 address[] memory owners1 = new address[](1); owners1[0] = wallet1.addr; @@ -54,100 +59,136 @@ contract MultisigScriptDoubleNestedTest is Test, MultisigScript { IGnosisSafe(safe4).setup(owners4, 1, address(0), "", address(0), address(0), 0, address(0)); } + /// @inheritdoc MultisigScript + /// + /// @dev Verifies counter was incremented once 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 + /// @inheritdoc MultisigScript + function _buildCalls() internal view override returns (Call[] memory) { + Call[] memory calls = new Call[](1); + calls[0] = Call({ + target: address(counter), + operation: Enum.Operation.Call, + data: abi.encodeCall(Counter.increment, ()), + value: 0 }); return calls; } + /// @inheritdoc MultisigScript function _ownerSafe() internal view override returns (address) { return safe4; } + /// @notice Gets the safes array and data to sign for a given signer safe + /// + /// @param signerSafe The address of the signer's Safe (safe1 or safe2) + /// + /// @return safes The array of safes to pass to approve() + /// @return dataToSign The data that needs to be signed + function _getSignerData(address signerSafe) + internal + view + returns (address[] memory safes, bytes memory dataToSign) + { + safes = new address[](2); + safes[0] = signerSafe; + safes[1] = safe3; + + Call[] memory callsChain = _buildCallsChain({safes: _appendOwnerSafe(safes)}); + dataToSign = _encodeTransactionData({safe: signerSafe, call: callsChain[0]}); + } + + /// @notice Tests that sign() emits the correct data to sign for safe1 function test_sign_double_nested_safe1() external { vm.recordLogs(); - address[] memory safes = new address[](2); - safes[0] = safe1; - safes[1] = safe3; + (address[] memory safes, bytes memory dataToSign) = _getSignerData(safe1); + vm.prank(wallet1.addr); bytes memory txData = abi.encodeWithSelector(this.sign.selector, safes); (bool success,) = address(this).call(txData); - vm.assertTrue(success); + assertTrue(success); Vm.Log[] memory logs = vm.getRecordedLogs(); - assertEq(keccak256(logs[logs.length - 1].data), keccak256(abi.encode(dataToSign1))); + assertEq(keccak256(logs[logs.length - 1].data), keccak256(abi.encode(dataToSign))); } + /// @notice Tests that sign() emits the correct data to sign for safe2 function test_sign_double_nested_safe2() external { vm.recordLogs(); - address[] memory safes = new address[](2); - safes[0] = safe2; - safes[1] = safe3; + (address[] memory safes, bytes memory dataToSign) = _getSignerData(safe2); + vm.prank(wallet2.addr); bytes memory txData = abi.encodeWithSelector(this.sign.selector, safes); (bool success,) = address(this).call(txData); - vm.assertTrue(success); + assertTrue(success); Vm.Log[] memory logs = vm.getRecordedLogs(); - assertEq(keccak256(logs[logs.length - 1].data), keccak256(abi.encode(dataToSign2))); + assertEq(keccak256(logs[logs.length - 1].data), keccak256(abi.encode(dataToSign))); } + /// @notice Tests that approve() succeeds with valid signature from safe1 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; + (address[] memory safes, bytes memory dataToSign) = _getSignerData(safe1); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(wallet1, keccak256(dataToSign)); approve(safes, abi.encodePacked(r, s, v)); } + /// @notice Tests that approve() succeeds with valid signature from safe2 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; + (address[] memory safes, bytes memory dataToSign) = _getSignerData(safe2); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(wallet2, keccak256(dataToSign)); approve(safes, abi.encodePacked(r, s, v)); } + /// @notice Tests that approve() fails when signature doesn't match the safe 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))); + // Sign with wallet1 for safe1 + (, bytes memory dataToSign) = _getSignerData(safe1); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(wallet1, keccak256(dataToSign)); + + // But try to approve for safe2 (should fail) + (address[] memory safes2,) = _getSignerData(safe2); + + bytes memory data = abi.encodeCall(this.approve, (safes2, 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")); } + /// @notice Tests the approval flow through all nested levels function test_runInit_double_nested() external { + // Prepare and sign for wallet1/safe1 + (address[] memory sA, bytes memory dataToSign1) = _getSignerData(safe1); (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, keccak256(dataToSign1)); + + // Prepare and sign for wallet2/safe2 + (address[] memory sB, bytes memory dataToSign2) = _getSignerData(safe2); (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 for safe1 and safe2 approve(sA, abi.encodePacked(r1, s1, v1)); approve(sB, abi.encodePacked(r2, s2, v2)); + + // Approve for safe3 (intermediate level) address[] memory mid = new address[](1); mid[0] = safe3; approve(mid, ""); } + /// @notice Tests that intermediate approve fails when not all leaf safes have approved 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; + // Prepare and sign for wallet1/safe1 + (address[] memory sA, bytes memory dataToSign) = _getSignerData(safe1); + (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, keccak256(dataToSign)); + + // Approve only for safe1 approve(sA, abi.encodePacked(r1, s1, v1)); + + // Try to approve for safe3 without having approved safe2 (should fail) address[] memory mid = new address[](1); mid[0] = safe3; bytes memory data = abi.encodeCall(this.approve, (mid, "")); @@ -156,22 +197,26 @@ contract MultisigScriptDoubleNestedTest is Test, MultisigScript { assertEq(result, abi.encodeWithSignature("Error(string)", "not enough signatures")); } + /// @notice Tests the full flow: approve from all nested safes, then run function test_run_double_nested() external { + // Prepare and sign for wallet1/safe1 + (address[] memory sA, bytes memory dataToSign1) = _getSignerData(safe1); (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, keccak256(dataToSign1)); + + // Prepare and sign for wallet2/safe2 + (address[] memory sB, bytes memory dataToSign2) = _getSignerData(safe2); (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 for safe1 and safe2 approve(sA, abi.encodePacked(r1, s1, v1)); approve(sB, abi.encodePacked(r2, s2, v2)); + + // Approve for safe3 (intermediate level) address[] memory mid = new address[](1); mid[0] = safe3; approve(mid, ""); + // Execute the final transaction run(""); } } - diff --git a/test/universal/MultisigScriptNested.t.sol b/test/universal/MultisigScriptNested.t.sol index 8b481e1..faff8b9 100644 --- a/test/universal/MultisigScriptNested.t.sol +++ b/test/universal/MultisigScriptNested.t.sol @@ -1,14 +1,13 @@ // 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 {IGnosisSafe, Enum} from "script/universal/IGnosisSafe.sol"; import {Counter} from "test/universal/Counter.sol"; contract MultisigScriptNestedTest is Test, MultisigScript { @@ -20,18 +19,28 @@ contract MultisigScriptNestedTest is Test, MultisigScript { 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); + deployCodeTo("CBMulticall.sol", "", CB_MULTICALL); vm.etch(safe1, safeCode); vm.etch(safe2, safeCode); vm.etch(safe3, safeCode); - vm.etch(Preinstalls.MultiCall3, Preinstalls.getDeployedCode(Preinstalls.MultiCall3, block.chainid)); + + // Multisig ownership tree: + // + // ┌───────┐ ┌───────┐ + // │wallet1│ │wallet2│ + // └───┬───┘ └───┬───┘ + // ┌───▽───┐ ┌───▽───┐ + // │ safe1 │ │ safe2 │ (threshold: 1/1 each) + // └───┬───┘ └───┬───┘ + // └────┬────┘ + // ┌────▽────┐ + // │ safe3 │ (threshold: 2/2) + // └────┬────┘ + // ┌────▽────┐ + // │ counter │ + // └─────────┘ address[] memory owners1 = new address[](1); owners1[0] = wallet1.addr; @@ -47,94 +56,137 @@ contract MultisigScriptNestedTest is Test, MultisigScript { IGnosisSafe(safe3).setup(owners3, 2, address(0), "", address(0), address(0), 0, address(0)); } + /// @inheritdoc MultisigScript + /// + /// @dev Verifies counter was incremented once 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); + /// @inheritdoc MultisigScript + function _buildCalls() internal view override returns (Call[] memory) { + Call[] memory calls = new Call[](1); - calls[0] = IMulticall3.Call3Value({ - target: address(counter), allowFailure: false, callData: abi.encodeCall(Counter.increment, ()), value: 0 + calls[0] = Call({ + target: address(counter), + operation: Enum.Operation.Call, + data: abi.encodeCall(Counter.increment, ()), + value: 0 }); return calls; } + /// @inheritdoc MultisigScript function _ownerSafe() internal view override returns (address) { return address(safe3); } + /// @notice Gets the safes array and data to sign for a given signer safe + /// + /// @param signerSafe The address of the signer's Safe (safe1 or safe2) + /// + /// @return safes The array of safes to pass to approve() + /// @return dataToSign The data that needs to be signed + function _getSignerData(address signerSafe) + internal + view + returns (address[] memory safes, bytes memory dataToSign) + { + safes = new address[](1); + safes[0] = signerSafe; + + Call[] memory callsChain = _buildCallsChain({safes: _appendOwnerSafe(safes)}); + dataToSign = _encodeTransactionData({safe: signerSafe, call: callsChain[0]}); + } + + /// @notice Tests that sign() emits the correct data to sign for safe1 function test_sign_safe1() external { vm.recordLogs(); - address[] memory safes = new address[](1); - safes[0] = safe1; + (address[] memory safes, bytes memory dataToSign) = _getSignerData(safe1); + vm.prank(wallet1.addr); bytes memory txData = abi.encodeWithSelector(this.sign.selector, safes); (bool success,) = address(this).call(txData); - vm.assertTrue(success); + assertTrue(success); Vm.Log[] memory logs = vm.getRecordedLogs(); - assertEq(keccak256(logs[logs.length - 1].data), keccak256(abi.encode(dataToSign1))); + assertEq(keccak256(logs[logs.length - 1].data), keccak256(abi.encode(dataToSign))); } + /// @notice Tests that sign() emits the correct data to sign for safe2 function test_sign_safe2() external { vm.recordLogs(); - address[] memory safes = new address[](1); - safes[0] = safe2; + (address[] memory safes, bytes memory dataToSign) = _getSignerData(safe2); + vm.prank(wallet2.addr); bytes memory txData = abi.encodeWithSelector(this.sign.selector, safes); (bool success,) = address(this).call(txData); - vm.assertTrue(success); + assertTrue(success); Vm.Log[] memory logs = vm.getRecordedLogs(); - assertEq(keccak256(logs[logs.length - 1].data), keccak256(abi.encode(dataToSign2))); + assertEq(keccak256(logs[logs.length - 1].data), keccak256(abi.encode(dataToSign))); } + /// @notice Tests that approve() succeeds with valid signature from safe1 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; + (address[] memory safes, bytes memory dataToSign) = _getSignerData(safe1); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(wallet1, keccak256(dataToSign)); approve(safes, abi.encodePacked(r, s, v)); } + /// @notice Tests that approve() succeeds with valid signature from safe2 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; + (address[] memory safes, bytes memory dataToSign) = _getSignerData(safe2); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(wallet2, keccak256(dataToSign)); approve(safes, abi.encodePacked(r, s, v)); } + /// @notice Tests that approve() fails when signature doesn't match the safe 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))); + // Sign with wallet1 for safe1 + (, bytes memory dataToSign) = _getSignerData(safe1); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(wallet1, keccak256(dataToSign)); + + // But try to approve for safe2 (should fail) + (address[] memory safes2,) = _getSignerData(safe2); + + bytes memory data = abi.encodeCall(this.approve, (safes2, 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")); } + /// @notice Tests the full flow: approve from both safes, then run function test_run() external { + // Prepare and sign for wallet1/safe1 + (address[] memory safes1, bytes memory dataToSign1) = _getSignerData(safe1); (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, keccak256(dataToSign1)); + + // Prepare and sign for wallet2/safe2 + (address[] memory safes2, bytes memory dataToSign2) = _getSignerData(safe2); (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)); + + // Approve for safe1 and safe2 + approve(safes1, abi.encodePacked(r1, s1, v1)); + approve(safes2, abi.encodePacked(r2, s2, v2)); + + // Execute the final transaction run(""); } + /// @notice Tests that run() fails when not all nested safes have approved 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)); + // Prepare and sign for wallet1/safe1 only + (address[] memory safes1, bytes memory dataToSign) = _getSignerData(safe1); + (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, keccak256(dataToSign)); + + // Approve only for safe1 + approve(safes1, abi.encodePacked(r1, s1, v1)); + + // Try to run without safe2 approval (should fail) 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/universal/MultisigScriptV2.t.sol b/test/universal/MultisigScriptV2.t.sol deleted file mode 100644 index 4a5c378..0000000 --- a/test/universal/MultisigScriptV2.t.sol +++ /dev/null @@ -1,195 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.15; - -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 {MultisigScriptV2} from "script/universal/MultisigScriptV2.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"; - -import {CBMulticall} from "src/utils/CBMulticall.sol"; - -contract MultisigScriptV2Test is Test, MultisigScriptV2 { - 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)); - - 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)); - deployCodeTo("CBMulticall.sol", "", CB_MULTICALL); - 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(); - assertEq(counterValue, 6, "Counter value is not 6"); - - uint256 counterBalance = address(counter).balance; - assertEq(counterBalance, 3 ether, "Counter balance is not 1 ether"); - } - - function _buildCalls() internal view override returns (Call[] memory) { - Call memory counterIncrementCall = Call({ - operation: Enum.Operation.Call, - target: address(counter), - data: abi.encodeCall(Counter.increment, ()), - value: 0 - }); - - Call memory counterIncrementCallPayable = Call({ - operation: Enum.Operation.Call, - target: address(counter), - data: abi.encodeCall(Counter.incrementPayable, ()), - value: 1 ether - }); - - Call[] memory counterIncrementCalls = new Call[](2); - counterIncrementCalls[0] = counterIncrementCall; - counterIncrementCalls[1] = counterIncrementCall; - - Call[] memory counterIncrementCallsPayable = new Call[](2); - counterIncrementCallsPayable[0] = counterIncrementCallPayable; - counterIncrementCallsPayable[1] = counterIncrementCallPayable; - - Call[] memory calls = new Call[](4); - - calls[0] = Call({ - operation: Enum.Operation.Call, - target: address(counter), - data: abi.encodeCall(Counter.increment, ()), - value: 0 - }); - - // Use multicall to test the delegatecall use case - calls[1] = Call({ - operation: Enum.Operation.DelegateCall, - target: CB_MULTICALL, - data: abi.encodeCall(CBMulticall.aggregate3, (_toCall3s(counterIncrementCalls))), - value: 0 - }); - - calls[2] = Call({ - operation: Enum.Operation.Call, - target: address(counter), - data: abi.encodeCall(Counter.incrementPayable, ()), - value: 1 ether - }); - - calls[3] = Call({ - operation: Enum.Operation.DelegateCall, - target: CB_MULTICALL, - data: abi.encodeCall(CBMulticall.aggregate3Value, (_toCall3Values(counterIncrementCallsPayable))), - value: 0 - }); - - return calls; - } - - function _ownerSafe() internal view override returns (address) { - return address(safe); - } - - function _expectedTxDataForCurrentBuildCalls() internal view returns (bytes memory) { - return _encodeTransactionData(_ownerSafe(), _buildAggregatedScriptCall({scriptCalls: _buildCalls()})); - } - - function test_sign() external { - vm.recordLogs(); - - vm.prank(wallet1.addr); - this.sign(new address[](0)); - - 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 { - // 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 { - // 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 { - 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"); - } -} diff --git a/test/universal/NestedMultisigBuilder.t.sol b/test/universal/NestedMultisigBuilder.t.sol deleted file mode 100644 index 3330285..0000000 --- a/test/universal/NestedMultisigBuilder.t.sol +++ /dev/null @@ -1,125 +0,0 @@ -// 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 {NestedMultisigBuilder} from "script/universal/NestedMultisigBuilder.sol"; -import {Simulation} from "script/universal/Simulation.sol"; -import {IGnosisSafe} from "script/universal/IGnosisSafe.sol"; -import {Counter} from "test/universal/Counter.sol"; - -contract NestedMultisigBuilderTest is Test, NestedMultisigBuilder { - 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 { - // Check that the counter has been incremented - 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(); - bytes memory txData = abi.encodeCall(NestedMultisigBuilder.sign, (safe1)); - vm.prank(wallet1.addr); - (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(); - bytes memory txData = abi.encodeCall(NestedMultisigBuilder.sign, (safe2)); - vm.prank(wallet2.addr); - (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)); - approve(safe1, abi.encodePacked(r, s, v)); - } - - function test_approve_safe2() external { - (uint8 v, bytes32 r, bytes32 s) = vm.sign(wallet2, keccak256(dataToSign2)); - approve(safe2, abi.encodePacked(r, s, v)); - } - - function test_approve_notOwner() external { - (uint8 v, bytes32 r, bytes32 s) = vm.sign(wallet1, keccak256(dataToSign1)); - bytes memory data = - abi.encodeWithSelector(bytes4(keccak256("approve(address,bytes)")), safe2, 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)); - approve(safe1, abi.encodePacked(r1, s1, v1)); - approve(safe2, abi.encodePacked(r2, s2, v2)); - run(); - } - - function test_run_notApproved() external { - (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, keccak256(dataToSign1)); - approve(safe1, abi.encodePacked(r1, s1, v1)); - bytes memory data = abi.encodeWithSelector(bytes4(keccak256("run()"))); - (bool success, bytes memory result) = address(this).call(data); - assertFalse(success); - assertEq(result, abi.encodeWithSignature("Error(string)", "not enough signatures")); - } -} From ee681ab1c7be732a511467fc84588631430bb913 Mon Sep 17 00:00:00 2001 From: Baptiste Oueriagli Date: Wed, 3 Dec 2025 11:29:44 +0000 Subject: [PATCH 7/9] add support for signing EIP-712 typed data --- script/universal/MultisigDeploy.sol | 10 ++--- script/universal/MultisigScript.sol | 62 ++++++++++++++++++++++++++++- test/universal/MultisigScript.t.sol | 34 ++++++++++++++++ 3 files changed, 97 insertions(+), 9 deletions(-) diff --git a/script/universal/MultisigDeploy.sol b/script/universal/MultisigDeploy.sol index fe86359..5148453 100644 --- a/script/universal/MultisigDeploy.sol +++ b/script/universal/MultisigDeploy.sol @@ -133,7 +133,7 @@ contract MultisigDeployScript is Script { console.log(" Salt Nonce:", saltNonce); // Resolve owner addresses (combine direct owners + referenced safe addresses) - address[] memory resolvedOwners = _resolveOwnerAddresses({config: config, safeWallets: safes}); + address[] memory resolvedOwners = _resolveOwnerAddresses({config: config}); console.log(" Total Owners:", resolvedOwners.length); console.log(" Direct Owners:", config.owners.length); @@ -185,11 +185,7 @@ contract MultisigDeployScript is Script { } } - function _resolveOwnerAddresses(SafeWallet memory config, SafeWallet[] memory safeWallets) - internal - view - returns (address[] memory) - { + function _resolveOwnerAddresses(SafeWallet memory config) internal view returns (address[] memory) { uint256 totalOwners = config.owners.length + config.ownerRefIndices.length; address[] memory resolved = new address[](totalOwners); @@ -201,7 +197,7 @@ contract MultisigDeployScript is Script { // Add referenced safe addresses (they must already be deployed due to array order) for (uint256 i; i < config.ownerRefIndices.length; i++) { uint256 refIndex = config.ownerRefIndices[i]; - string memory refLabel = safeWallets[refIndex].label; + string memory refLabel = safes[refIndex].label; address refAddr = deployedSafes[refLabel]; require(refAddr != address(0), string(abi.encodePacked("Reference not deployed: ", refLabel))); resolved[config.owners.length + i] = refAddr; diff --git a/script/universal/MultisigScript.sol b/script/universal/MultisigScript.sol index f29eaea..7019bab 100644 --- a/script/universal/MultisigScript.sol +++ b/script/universal/MultisigScript.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.15; // solhint-disable no-console import {console} from "lib/forge-std/src/console.sol"; import {Script} from "lib/forge-std/src/Script.sol"; +import {stdJson} from "lib/forge-std/src/StdJson.sol"; import {Vm} from "lib/forge-std/src/Vm.sol"; import {CBMulticall} from "../../src/utils/CBMulticall.sol"; @@ -201,6 +202,14 @@ 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 Controls whether the safe tx is printed as hashes or structured EIP-712 data. + /// + /// @dev Override and return `true` to print hashed data (domain + message hash) instead of + /// the typed EIP-712 JSON structure. By default, returns `false` to use EIP-712 JSON. + function _printDataHashes() internal view virtual returns (bool) { + return true; + } + ////////////////////////////////////////////////////////////////////////////////////// /// Public Functions /// ////////////////////////////////////////////////////////////////////////////////////// @@ -529,14 +538,22 @@ abstract contract MultisigScript is Script { /// @notice Prints the data to sign for the given safe and call. /// + /// @dev Uses `_printDataHashes()` to determine the output format: + /// - `true`: prints raw transaction data (hashes) + /// - `false` (default): prints EIP-712 JSON structure for hardware wallets + /// /// @param safe The address of the safe to print the data to sign for. /// @param call The call to print the data to sign for. function _printDataToSign(address safe, Call memory call) internal { - bytes memory txData = _encodeTransactionData({safe: safe, call: call}); + bytes memory txData = _printDataHashes() + ? _encodeTransactionData({safe: safe, call: call}) + : _encodeEIP712Json({safe: safe, call: call}); + emit DataToSign({data: txData}); console.log("---\nIf submitting onchain, call Safe.approveHash on %s with the following hash:", safe); - console.logBytes32(_getTransactionHash({safe: safe, call: call})); + bytes32 hash = _getTransactionHash({safe: safe, call: call}); + console.logBytes32(hash); console.log("---\nData to sign:"); console.log("vvvvvvvv"); @@ -725,6 +742,47 @@ abstract contract MultisigScript is Script { }); } + /// @notice Encodes the transaction as EIP-712 structured JSON for hardware wallet signing. + /// + /// @param safe The address of the safe that will execute the transaction. + /// @param call The call to encode. + /// + /// @return The EIP-712 JSON structure as bytes. + function _encodeEIP712Json(address safe, Call memory call) internal returns (bytes memory) { + // EIP-712 type definitions for Safe transaction + string memory types = '{"EIP712Domain":[' '{"name":"chainId","type":"uint256"},' + '{"name":"verifyingContract","type":"address"}],' '"SafeTx":[' '{"name":"to","type":"address"},' + '{"name":"value","type":"uint256"},' '{"name":"data","type":"bytes"},' + '{"name":"operation","type":"uint8"},' '{"name":"safeTxGas","type":"uint256"},' + '{"name":"baseGas","type":"uint256"},' '{"name":"gasPrice","type":"uint256"},' + '{"name":"gasToken","type":"address"},' '{"name":"refundReceiver","type":"address"},' + '{"name":"nonce","type":"uint256"}]}'; + + // Build domain object + string memory domain = stdJson.serialize("domain", "chainId", uint256(block.chainid)); + domain = stdJson.serialize("domain", "verifyingContract", safe); + + // Build message object with transaction details + string memory message = stdJson.serialize("message", "to", call.target); + message = stdJson.serialize("message", "value", call.value); + message = stdJson.serialize("message", "data", call.data); + message = stdJson.serialize("message", "operation", uint256(call.operation)); + message = stdJson.serialize("message", "safeTxGas", uint256(0)); + message = stdJson.serialize("message", "baseGas", uint256(0)); + message = stdJson.serialize("message", "gasPrice", uint256(0)); + message = stdJson.serialize("message", "gasToken", address(0)); + message = stdJson.serialize("message", "refundReceiver", address(0)); + message = stdJson.serialize("message", "nonce", _getNonce(safe)); + + // Combine into final JSON structure + string memory json = stdJson.serialize("", "primaryType", string("SafeTx")); + json = stdJson.serialize("", "types", types); + json = stdJson.serialize("", "domain", domain); + json = stdJson.serialize("", "message", message); + + return abi.encodePacked(json); + } + /// @notice Checks the signatures for the given safe and call. /// /// @param safe The address of the safe to check the signatures for. diff --git a/test/universal/MultisigScript.t.sol b/test/universal/MultisigScript.t.sol index 82407a3..11b5fba 100644 --- a/test/universal/MultisigScript.t.sol +++ b/test/universal/MultisigScript.t.sol @@ -10,6 +10,7 @@ import {Simulation} from "script/universal/Simulation.sol"; import {IGnosisSafe, Enum} from "script/universal/IGnosisSafe.sol"; import {Counter} from "test/universal/Counter.sol"; +import {LibString} from "lib/solady/src/utils/LibString.sol"; import {CBMulticall} from "src/utils/CBMulticall.sol"; @@ -21,6 +22,9 @@ contract MultisigScriptTest is Test, MultisigScript { address internal safe = address(1001); Counter internal counter = new Counter(address(safe)); + /// @dev Controls whether to use hash-based or EIP-712 JSON output. True by default. + bool internal _useDataHashes = true; + function setUp() public { vm.etch(safe, Preinstalls.getDeployedCode(Preinstalls.Safe_v130, block.chainid)); deployCodeTo("CBMulticall.sol", "", CB_MULTICALL); @@ -127,6 +131,13 @@ contract MultisigScriptTest is Test, MultisigScript { return address(safe); } + /// @inheritdoc MultisigScript + /// + /// @dev Returns `_useDataHashes` which is true by default (hash-based signing). + function _printDataHashes() internal view override returns (bool) { + return _useDataHashes; + } + /// @notice Helper to compute the expected transaction data for signing /// /// @return The encoded transaction data that signers need to sign @@ -194,4 +205,27 @@ contract MultisigScriptTest is Test, MultisigScript { bytes memory signatures = abi.encodePacked(r1, s1, v1, r2, s2, v2, r3, s3, v3); run(signatures); } + + /// @notice Tests that sign() emits EIP-712 JSON formatted data + /// + /// @dev Verifies the output contains expected EIP-712 structure fields + function test_sign_eip712() external { + _useDataHashes = false; + + vm.recordLogs(); + + vm.prank(wallet1.addr); + this.sign(new address[](0)); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes memory logged = abi.decode(logs[logs.length - 1].data, (bytes)); + + // Verify the logged data contains EIP-712 JSON structure markers + string memory loggedStr = string(logged); + assertTrue(LibString.contains(loggedStr, "EIP712Domain"), "EIP-712 output should contain EIP712Domain"); + assertTrue(LibString.contains(loggedStr, "SafeTx"), "EIP-712 output should contain SafeTx type"); + assertTrue(LibString.contains(loggedStr, "primaryType"), "EIP-712 output should contain primaryType"); + assertTrue(LibString.contains(loggedStr, "domain"), "EIP-712 output should contain domain"); + assertTrue(LibString.contains(loggedStr, "message"), "EIP-712 output should contain message"); + } } From 9e7fb744012f824dc3ed7aa1b9f7111ae12bb29d Mon Sep 17 00:00:00 2001 From: Baptiste Oueriagli Date: Thu, 4 Dec 2025 08:54:56 +0000 Subject: [PATCH 8/9] skip lint on build (foundry/issues/11668) --- foundry.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/foundry.toml b/foundry.toml index 32a7cad..8058a1c 100644 --- a/foundry.toml +++ b/foundry.toml @@ -6,4 +6,7 @@ optimizer_runs = 999999 solc_version = "0.8.15" extra_output_files = ["abi"] -# See more config options https://github.com/foundry-rs/foundry/tree/master/config \ No newline at end of file +[lint] +lint_on_build = false + +# See more config options https://github.com/foundry-rs/foundry/tree/master/config From 36b00a232a65a997c83a19764bedf8940c5f7bcc Mon Sep 17 00:00:00 2001 From: Baptiste Oueriagli Date: Mon, 5 Jan 2026 13:57:33 +0000 Subject: [PATCH 9/9] address review comments --- script/universal/MultisigScript.sol | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/script/universal/MultisigScript.sol b/script/universal/MultisigScript.sol index 7019bab..519f510 100644 --- a/script/universal/MultisigScript.sol +++ b/script/universal/MultisigScript.sol @@ -237,10 +237,13 @@ abstract contract MultisigScript is Script { } Call[] memory callsChain = _buildCallsChain({safes: safes}); + + vm.startMappingRecording(); (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = _simulateForSigner({safes: safes, callsChain: callsChain}); (StateDiff.MappingParent[] memory parents, string memory json) = StateDiff.collectStateDiff(StateDiff.CollectStateDiffOpts({accesses: accesses, simPayload: simPayload})); + vm.stopMappingRecording(); _postSign({accesses: accesses, simPayload: simPayload}); _postCheck({accesses: accesses, simPayload: simPayload}); @@ -513,7 +516,7 @@ abstract contract MultisigScript is Script { } // Return the number of root calls appended. - rootCallsCount = rootCallsIndex - rootCallsIndexSaved; + return rootCallsIndex - rootCallsIndexSaved; } /// @notice Builds the approve call (`approveHash`) for the given safe and call. @@ -547,7 +550,7 @@ abstract contract MultisigScript is Script { function _printDataToSign(address safe, Call memory call) internal { bytes memory txData = _printDataHashes() ? _encodeTransactionData({safe: safe, call: call}) - : _encodeEIP712Json({safe: safe, call: call}); + : _encodeEip712Json({safe: safe, call: call}); emit DataToSign({data: txData}); @@ -748,7 +751,7 @@ abstract contract MultisigScript is Script { /// @param call The call to encode. /// /// @return The EIP-712 JSON structure as bytes. - function _encodeEIP712Json(address safe, Call memory call) internal returns (bytes memory) { + function _encodeEip712Json(address safe, Call memory call) internal returns (bytes memory) { // EIP-712 type definitions for Safe transaction string memory types = '{"EIP712Domain":[' '{"name":"chainId","type":"uint256"},' '{"name":"verifyingContract","type":"address"}],' '"SafeTx":[' '{"name":"to","type":"address"},'