From 776d78c13d4409a0643ec24a1515204b5f7a9864 Mon Sep 17 00:00:00 2001 From: Jack Chuma Date: Mon, 10 Nov 2025 19:29:17 -0500 Subject: [PATCH 01/14] add OPCMCaller script --- script/universal/OPCMCaller.sol | 589 ++++++++++++++++++++++++++++++++ 1 file changed, 589 insertions(+) create mode 100644 script/universal/OPCMCaller.sol diff --git a/script/universal/OPCMCaller.sol b/script/universal/OPCMCaller.sol new file mode 100644 index 0000000..6c6aed9 --- /dev/null +++ b/script/universal/OPCMCaller.sol @@ -0,0 +1,589 @@ +// 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 {IGnosisSafe, Enum} from "./IGnosisSafe.sol"; +import {Signatures} from "./Signatures.sol"; +import {Simulation} from "./Simulation.sol"; +import {StateDiff} from "./StateDiff.sol"; + +interface IOPCM { + struct OpChainConfig { + address systemConfigProxy; + address proxyAdmin; + bytes32 absolutePrestate; + } + + function upgrade(OpChainConfig[] memory _opChainConfigs) external; +} + +/// @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 OPCMCaller is Script { + bytes32 internal constant SAFE_NONCE_SLOT = bytes32(uint256(5)); + + /// @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 Returns the address of the OPCM contract to call + function _opcmAddress() internal view virtual returns (address); + + /// @notice Creates the calldata for signatures (`sign`), approvals (`approve`), and execution (`run`) + function _buildCall() internal view virtual returns (IMulticall3.Call3 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]}); + } + + bytes[] memory datas = _transactionDatas({safes: safes}); + + vm.startMappingRecording(); + (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = + _simulateForSigner({safes: safes, datas: datas}); + (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}); + + // 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], data: datas[0]}); + StateDiff.recordStateDiff({json: json, parents: parents, txData: txData, targetSafe: _ownerSafe()}); + + _printDataToSign({safe: safes[0], data: datas[0], txData: txData}); + } + + /// 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}); + (bytes[] memory datas) = _transactionDatas({safes: safes}); + _checkSignatures({safe: safes[0], data: datas[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}); + bytes[] memory datas = _transactionDatas({safes: safes}); + (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = _executeTransaction({ + safe: safes[0], to: MULTICALL3_ADDRESS, data: datas[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(); + bytes[] memory datas = _transactionDatas({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, to: _opcmAddress(), data: datas[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(); + bytes[] memory datas = _transactionDatas({safes: _toArray(ownerSafe)}); + + (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = _executeTransaction({ + safe: ownerSafe, to: _opcmAddress(), data: datas[0], signatures: signatures, broadcast: true + }); + + _postRun({accesses: accesses, simPayload: simPayload}); + _postCheck({accesses: accesses, simPayload: simPayload}); + } + + ////////////////////////////////////////////////////////////////////////////////////// + /// Internal Functions /// + ////////////////////////////////////////////////////////////////////////////////////// + + 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; + } + + function _transactionDatas(address[] memory safes) private view returns (bytes[] memory datas) { + // Build the calls and sum the values + IMulticall3.Call3 memory call = _buildCall(); + + // The very last call is the actual (aggregated) call to execute + datas = new bytes[](safes.length); + datas[datas.length - 1] = call.callData; + + // The first n-1 calls are the nested approval calls + for (uint256 i = safes.length - 1; i > 0; i--) { + address targetSafe = safes[i]; + bytes memory callToApprove = datas[i]; + + IMulticall3.Call3[] memory approvalCall = new IMulticall3.Call3[](1); + approvalCall[0] = _generateApproveCall({safe: targetSafe, data: callToApprove}); + datas[i - 1] = abi.encodeCall(IMulticall3.aggregate3, (approvalCall)); + } + } + + function _generateApproveCall(address safe, bytes memory data) internal view returns (IMulticall3.Call3 memory) { + bytes32 hash = _getTransactionHash({safe: safe, data: data}); + + 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)) + }); + } + + function _printDataToSign(address safe, bytes memory data, bytes memory txData) internal { + bytes32 hash = _getTransactionHash({safe: safe, data: data}); + + emit DataToSign({data: txData}); + + console.log("---\nIf submitting onchain, call Safe.approveHash on %s with the following hash:", safe); + console.logBytes32(hash); + + 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("###############################"); + } + + function _executeTransaction(address safe, address to, bytes memory data, bytes memory signatures, bool broadcast) + internal + returns (Vm.AccountAccess[] memory, Simulation.Payload memory) + { + bytes32 hash = _getTransactionHash({safe: safe, data: data}); + signatures = Signatures.prepareSignatures({safe: safe, hash: hash, signatures: signatures}); + + bytes memory simData = _execTransactionCalldata({safe: safe, data: data, signatures: signatures}); + Simulation.logSimulationLink({to: safe, data: simData, from: msg.sender}); + + vm.startStateDiffRecording(); + bool success = _execTransaction({safe: safe, to: to, data: data, 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"); + + // 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) + }); + return (accesses, simPayload); + } + + function _simulateForSigner(address[] memory safes, bytes[] memory datas) + internal + returns (Vm.AccountAccess[] memory, Simulation.Payload memory) + { + IMulticall3.Call3[] memory calls = _simulateForSignerCalls({safes: safes, datas: datas}); + + bytes32 firstCallDataHash = _getTransactionHash({safe: safes[0], data: datas[0]}); + + // Now define the state overrides for the simulation. + Simulation.StateOverride[] memory overrides = _overrides({safes: safes, firstCallDataHash: firstCallDataHash}); + + bytes memory txData = abi.encodeCall(IMulticall3.aggregate3, (calls)); + console.log("---\nSimulation link:"); + // solhint-disable max-line-length + Simulation.logSimulationLink({to: _opcmAddress(), 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: _opcmAddress(), 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) + private + view + returns (IMulticall3.Call3[] memory) + { + IMulticall3.Call3[] memory calls = new IMulticall3.Call3[](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], signatures: Signatures.genPrevalidatedSignature(signer) + }) + }); + } + + return calls; + } + + // 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); + } + } + + function _checkSignatures(address safe, bytes memory data, bytes memory signatures) internal view { + bytes32 hash = _getTransactionHash({safe: safe, data: data}); + 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) internal view returns (bytes32) { + return keccak256(_encodeTransactionData({safe: safe, data: data})); + } + + function _encodeTransactionData(address safe, bytes memory data) internal view returns (bytes memory) { + return IGnosisSafe(safe) + .encodeTransactionData({ + to: _opcmAddress(), + value: 0, + data: data, + operation: Enum.Operation.DelegateCall, + safeTxGas: 0, + baseGas: 0, + gasPrice: 0, + gasToken: address(0), + refundReceiver: address(0), + _nonce: _getNonce(safe) + }); + } + + function _execTransactionCalldata(address safe, bytes memory data, bytes memory signatures) + internal + view + returns (bytes memory) + { + return abi.encodeCall( + IGnosisSafe(safe).execTransaction, + (_opcmAddress(), 0, data, Enum.Operation.DelegateCall, 0, 0, 0, address(0), payable(address(0)), signatures) + ); + } + + function _execTransaction(address safe, address to, bytes memory data, bytes memory signatures, bool broadcast) + internal + returns (bool) + { + if (broadcast) { + vm.broadcast(); + } + return IGnosisSafe(safe) + .execTransaction({ + to: to, + value: 0, + data: data, + operation: Enum.Operation.DelegateCall, + safeTxGas: 0, + baseGas: 0, + gasPrice: 0, + gasToken: address(0), + refundReceiver: payable(address(0)), + signatures: signatures + }); + } + + function _toArray(address addr) internal pure returns (address[] memory) { + address[] memory array = new address[](1); + array[0] = addr; + return array; + } + + 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; + } +} From 5b3c812c46645edb89b2cffb13a30de87f518a2c Mon Sep 17 00:00:00 2001 From: Jack Chuma Date: Tue, 11 Nov 2025 09:03:35 -0500 Subject: [PATCH 02/14] modify MultisigScript for OPCM calls --- script/universal/MultisigScript.sol | 140 ++++++++++++++++++---------- 1 file changed, 90 insertions(+), 50 deletions(-) diff --git a/script/universal/MultisigScript.sol b/script/universal/MultisigScript.sol index 4337264..3f573bd 100644 --- a/script/universal/MultisigScript.sol +++ b/script/universal/MultisigScript.sol @@ -147,6 +147,13 @@ import {StateDiff} from "./StateDiff.sol"; /// │ │ │ │ │ │ │ run() │ /// │ │ │ │ │ │ │─────────────────────────────>│ abstract contract MultisigScript is Script { + struct SafeTx { + address safe; + address to; + bytes data; + uint256 value; + } + bytes32 internal constant SAFE_NONCE_SLOT = bytes32(uint256(5)); address internal constant CB_MULTICALL = 0x8BDE8F549F56D405f07e1aA15Df9e1FC69839881; @@ -183,6 +190,13 @@ 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 call is a multicall. Most tasks should leave this as-is. + /// For special cases, i.e. tasks that invoke OPCM and need to be a delegate call, we set this to false + /// @dev If set to false, the task must configure only a single call + function _useMulticall() internal pure virtual returns (bool) { + return true; + } + constructor() { bool useCbMulticall; try vm.envBool("USE_CB_MULTICALL") { @@ -217,11 +231,11 @@ abstract contract MultisigScript is Script { originalNonces[i] = _getNonce({safe: safes[i]}); } - (bytes[] memory datas, uint256 value) = _transactionDatas({safes: safes}); + (bytes[] memory datas, uint256 value, address target) = _transactionDatas({safes: safes}); vm.startMappingRecording(); (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = - _simulateForSigner({safes: safes, datas: datas, value: value}); + _simulateForSigner({safes: safes, to: target, datas: datas, value: value}); (StateDiff.MappingParent[] memory parents, string memory json) = StateDiff.collectStateDiff(StateDiff.CollectStateDiffOpts({accesses: accesses, simPayload: simPayload})); vm.stopMappingRecording(); @@ -234,10 +248,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(SafeTx({safe: safes[0], to: target, data: datas[0], value: value})); 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], to: target, data: datas[0], value: value, txData: txData}); } /// Step 1.1 (optional) @@ -249,8 +263,8 @@ 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}); + (bytes[] memory datas, uint256 value, address target) = _transactionDatas({safes: safes}); + _checkSignatures({safe: safes[0], to: target, data: datas[0], value: value, signatures: signatures}); } /// Step 2 (optional for non-nested setups) @@ -267,9 +281,10 @@ 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}); - (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = - _executeTransaction({safe: safes[0], data: datas[0], value: value, signatures: signatures, broadcast: true}); + (bytes[] memory datas, uint256 value,) = _transactionDatas({safes: safes}); + (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = _executeTransaction({ + safe: safes[0], to: multicallAddress, data: datas[0], value: value, signatures: signatures, broadcast: true + }); _postApprove({accesses: accesses, simPayload: simPayload}); } @@ -284,12 +299,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)}); + (bytes[] memory datas, uint256 value, address target) = _transactionDatas({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 + safe: ownerSafe, to: target, data: datas[0], value: value, signatures: signatures, broadcast: false }); _postRun({accesses: accesses, simPayload: simPayload}); @@ -305,10 +320,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)}); + (bytes[] memory datas, uint256 value, address target) = _transactionDatas({safes: _toArray(ownerSafe)}); (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = _executeTransaction({ - safe: ownerSafe, data: datas[0], value: value, signatures: signatures, broadcast: true + safe: ownerSafe, to: target, data: datas[0], value: value, signatures: signatures, broadcast: true }); _postRun({accesses: accesses, simPayload: simPayload}); @@ -328,7 +343,11 @@ abstract contract MultisigScript is Script { return extendedSafes; } - function _transactionDatas(address[] memory safes) private view returns (bytes[] memory datas, uint256 value) { + function _transactionDatas(address[] memory safes) + private + view + returns (bytes[] memory datas, uint256 value, address target) + { // Build the calls and sum the values IMulticall3.Call3Value[] memory calls = _buildCalls(); for (uint256 i; i < calls.length; i++) { @@ -352,6 +371,12 @@ abstract contract MultisigScript is Script { valueForCallToApprove = 0; } + + target = multicallAddress; + if (!_useMulticall()) { + require(calls.length == 1, "MultisigScript::_transactionDatas must use a single call if not multicall"); + target = calls[0].target; + } } function _generateApproveCall(address safe, bytes memory data, uint256 value) @@ -359,7 +384,7 @@ abstract contract MultisigScript is Script { view returns (IMulticall3.Call3 memory) { - bytes32 hash = _getTransactionHash({safe: safe, data: data, value: value}); + bytes32 hash = _getTransactionHash({safe: safe, to: multicallAddress, data: data, value: value}); console.log("---\nNested hash for safe %s:", safe); console.logBytes32(hash); @@ -369,8 +394,10 @@ abstract contract MultisigScript is Script { }); } - function _printDataToSign(address safe, bytes memory data, uint256 value, bytes memory txData) internal { - bytes32 hash = _getTransactionHash({safe: safe, data: data, value: value}); + function _printDataToSign(address safe, address to, bytes memory data, uint256 value, bytes memory txData) + internal + { + bytes32 hash = _getTransactionHash({safe: safe, to: to, data: data, value: value}); emit DataToSign({data: txData}); @@ -393,20 +420,23 @@ abstract contract MultisigScript is Script { function _executeTransaction( address safe, + address to, 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}); + bytes32 hash = _getTransactionHash({safe: safe, to: to, data: data, value: value}); signatures = Signatures.prepareSignatures({safe: safe, hash: hash, signatures: signatures}); - bytes memory simData = _execTransactionCalldata({safe: safe, data: data, value: value, signatures: signatures}); + bytes memory simData = + _execTransactionCalldata({safe: safe, to: to, data: data, value: value, signatures: signatures}); Simulation.logSimulationLink({to: safe, data: simData, from: msg.sender}); vm.startStateDiffRecording(); - bool success = - _execTransaction({safe: safe, data: data, value: value, signatures: signatures, broadcast: broadcast}); + bool success = _execTransaction({ + safe: safe, to: to, data: data, value: value, 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"); @@ -419,13 +449,13 @@ abstract contract MultisigScript is Script { return (accesses, simPayload); } - function _simulateForSigner(address[] memory safes, bytes[] memory datas, uint256 value) + function _simulateForSigner(address[] memory safes, address to, bytes[] memory datas, uint256 value) internal returns (Vm.AccountAccess[] memory, Simulation.Payload memory) { - IMulticall3.Call3[] memory calls = _simulateForSignerCalls({safes: safes, datas: datas, value: value}); + IMulticall3.Call3[] memory calls = _simulateForSignerCalls({safes: safes, to: to, datas: datas, value: value}); - bytes32 firstCallDataHash = _getTransactionHash({safe: safes[0], data: datas[0], value: value}); + bytes32 firstCallDataHash = _getTransactionHash({safe: safes[0], to: to, data: datas[0], value: value}); // Now define the state overrides for the simulation. Simulation.StateOverride[] memory overrides = _overrides({safes: safes, firstCallDataHash: firstCallDataHash}); @@ -443,7 +473,7 @@ abstract contract MultisigScript is Script { return (accesses, simPayload); } - function _simulateForSignerCalls(address[] memory safes, bytes[] memory datas, uint256 value) + function _simulateForSignerCalls(address[] memory safes, address to, bytes[] memory datas, uint256 value) private view returns (IMulticall3.Call3[] memory) @@ -457,6 +487,7 @@ abstract contract MultisigScript is Script { allowFailure: false, callData: _execTransactionCalldata({ safe: safes[i], + to: to, data: datas[i], value: value, signatures: Signatures.genPrevalidatedSignature(signer) @@ -531,57 +562,66 @@ 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}); + function _checkSignatures(address safe, address to, bytes memory data, uint256 value, bytes memory signatures) + internal + view + { + bytes32 hash = _getTransactionHash({safe: safe, to: to, 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) + function _getTransactionHash(address safe, address to, bytes memory data, uint256 value) internal view - returns (bytes memory) + returns (bytes32) { - return IGnosisSafe(safe) + return keccak256(_encodeTransactionData(SafeTx({safe: safe, to: to, data: data, value: value}))); + } + + function _encodeTransactionData(SafeTx memory t) internal view returns (bytes memory) { + return IGnosisSafe(t.safe) .encodeTransactionData({ - to: multicallAddress, - value: value, - data: data, - operation: _getOperation(value), + to: t.to, + value: t.value, + data: t.data, + operation: _getOperation(t.value), safeTxGas: 0, baseGas: 0, gasPrice: 0, gasToken: address(0), refundReceiver: address(0), - _nonce: _getNonce(safe) + _nonce: _getNonce(t.safe) }); } - function _execTransactionCalldata(address safe, bytes memory data, uint256 value, bytes memory signatures) - internal - view - returns (bytes memory) - { + function _execTransactionCalldata( + address safe, + address to, + bytes memory data, + uint256 value, + bytes memory signatures + ) internal view returns (bytes memory) { return abi.encodeCall( IGnosisSafe(safe).execTransaction, - (multicallAddress, value, data, _getOperation(value), 0, 0, 0, address(0), payable(address(0)), signatures) + (to, value, data, _getOperation(value), 0, 0, 0, address(0), payable(address(0)), signatures) ); } - function _execTransaction(address safe, bytes memory data, uint256 value, bytes memory signatures, bool broadcast) - internal - returns (bool) - { + function _execTransaction( + address safe, + address to, + bytes memory data, + uint256 value, + bytes memory signatures, + bool broadcast + ) internal returns (bool) { if (broadcast) { vm.broadcast(); } return IGnosisSafe(safe) .execTransaction({ - to: multicallAddress, + to: to, value: value, data: data, operation: _getOperation(value), From ddfa592eb022a69f7fa03fe1a22f42ebb4e69386 Mon Sep 17 00:00:00 2001 From: Jack Chuma Date: Tue, 11 Nov 2025 09:38:50 -0500 Subject: [PATCH 03/14] fix call encoding --- script/universal/MultisigScript.sol | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/script/universal/MultisigScript.sol b/script/universal/MultisigScript.sol index 3f573bd..4f1cf0f 100644 --- a/script/universal/MultisigScript.sol +++ b/script/universal/MultisigScript.sol @@ -358,6 +358,13 @@ abstract contract MultisigScript is Script { datas = new bytes[](safes.length); datas[datas.length - 1] = abi.encodeCall(IMulticall3.aggregate3Value, (calls)); + target = multicallAddress; + if (!_useMulticall()) { + require(calls.length == 1, "MultisigScript::_transactionDatas must use a single call if not multicall"); + target = calls[0].target; + datas[datas.length - 1] = calls[0].callData; + } + // The first n-1 calls are the nested approval calls uint256 valueForCallToApprove = value; for (uint256 i = safes.length - 1; i > 0; i--) { @@ -371,12 +378,6 @@ abstract contract MultisigScript is Script { valueForCallToApprove = 0; } - - target = multicallAddress; - if (!_useMulticall()) { - require(calls.length == 1, "MultisigScript::_transactionDatas must use a single call if not multicall"); - target = calls[0].target; - } } function _generateApproveCall(address safe, bytes memory data, uint256 value) From f2cb0aed12e6c594da1947c8e7a0270a9d9a3fbd Mon Sep 17 00:00:00 2001 From: Jack Chuma Date: Tue, 11 Nov 2025 09:48:34 -0500 Subject: [PATCH 04/14] fix approve calls --- script/universal/MultisigScript.sol | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/script/universal/MultisigScript.sol b/script/universal/MultisigScript.sol index 4f1cf0f..a4a7e30 100644 --- a/script/universal/MultisigScript.sol +++ b/script/universal/MultisigScript.sol @@ -370,22 +370,26 @@ abstract contract MultisigScript is Script { for (uint256 i = safes.length - 1; i > 0; i--) { address targetSafe = safes[i]; bytes memory callToApprove = datas[i]; + address to = target; + if (i < safes.length - 1) { + to = multicallAddress; + } IMulticall3.Call3[] memory approvalCall = new IMulticall3.Call3[](1); approvalCall[0] = - _generateApproveCall({safe: targetSafe, data: callToApprove, value: valueForCallToApprove}); + _generateApproveCall({safe: targetSafe, to: to, data: callToApprove, value: valueForCallToApprove}); datas[i - 1] = abi.encodeCall(IMulticall3.aggregate3, (approvalCall)); valueForCallToApprove = 0; } } - function _generateApproveCall(address safe, bytes memory data, uint256 value) + function _generateApproveCall(address safe, address to, bytes memory data, uint256 value) internal view returns (IMulticall3.Call3 memory) { - bytes32 hash = _getTransactionHash({safe: safe, to: multicallAddress, data: data, value: value}); + bytes32 hash = _getTransactionHash({safe: safe, to: to, data: data, value: value}); console.log("---\nNested hash for safe %s:", safe); console.logBytes32(hash); From f59b4cbfe92110feed5bd3374766b8de86b4a420 Mon Sep 17 00:00:00 2001 From: Jack Chuma Date: Tue, 11 Nov 2025 10:00:32 -0500 Subject: [PATCH 05/14] fix simulate for signer --- script/universal/MultisigScript.sol | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/script/universal/MultisigScript.sol b/script/universal/MultisigScript.sol index a4a7e30..cefea6c 100644 --- a/script/universal/MultisigScript.sol +++ b/script/universal/MultisigScript.sol @@ -460,7 +460,9 @@ abstract contract MultisigScript is Script { { IMulticall3.Call3[] memory calls = _simulateForSignerCalls({safes: safes, to: to, datas: datas, value: value}); - bytes32 firstCallDataHash = _getTransactionHash({safe: safes[0], to: to, data: datas[0], value: value}); + bytes32 firstCallDataHash = _getTransactionHash({ + safe: safes[0], to: safes.length > 1 ? multicallAddress : to, data: datas[0], value: value + }); // Now define the state overrides for the simulation. Simulation.StateOverride[] memory overrides = _overrides({safes: safes, firstCallDataHash: firstCallDataHash}); @@ -468,12 +470,12 @@ abstract contract MultisigScript is Script { bytes memory txData = abi.encodeCall(IMulticall3.aggregate3, (calls)); console.log("---\nSimulation link:"); // solhint-disable max-line-length - Simulation.logSimulationLink({to: multicallAddress, data: txData, from: msg.sender, overrides: overrides}); + Simulation.logSimulationLink({to: to, 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: multicallAddress, data: txData, from: msg.sender, stateOverrides: overrides}); + Simulation.Payload({to: to, data: txData, from: msg.sender, stateOverrides: overrides}); Vm.AccountAccess[] memory accesses = Simulation.simulateFromSimPayload({simPayload: simPayload}); return (accesses, simPayload); } @@ -492,7 +494,7 @@ abstract contract MultisigScript is Script { allowFailure: false, callData: _execTransactionCalldata({ safe: safes[i], - to: to, + to: i == safes.length - 1 ? to : multicallAddress, data: datas[i], value: value, signatures: Signatures.genPrevalidatedSignature(signer) From 3f83155447511e25b7631aa2cdd9b773aab2e086 Mon Sep 17 00:00:00 2001 From: Jack Chuma Date: Tue, 11 Nov 2025 10:15:06 -0500 Subject: [PATCH 06/14] fix simulate for signer --- script/universal/MultisigScript.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/script/universal/MultisigScript.sol b/script/universal/MultisigScript.sol index cefea6c..f7270b6 100644 --- a/script/universal/MultisigScript.sol +++ b/script/universal/MultisigScript.sol @@ -470,12 +470,12 @@ abstract contract MultisigScript is Script { bytes memory txData = abi.encodeCall(IMulticall3.aggregate3, (calls)); console.log("---\nSimulation link:"); // solhint-disable max-line-length - Simulation.logSimulationLink({to: to, data: txData, from: msg.sender, overrides: overrides}); + Simulation.logSimulationLink({to: multicallAddress, 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: to, data: txData, from: msg.sender, stateOverrides: overrides}); + Simulation.Payload({to: multicallAddress, data: txData, from: msg.sender, stateOverrides: overrides}); Vm.AccountAccess[] memory accesses = Simulation.simulateFromSimPayload({simPayload: simPayload}); return (accesses, simPayload); } From 62a9050531d29c3771d4b195e94891cb94e78723 Mon Sep 17 00:00:00 2001 From: Jack Chuma Date: Tue, 11 Nov 2025 11:53:17 -0500 Subject: [PATCH 07/14] remove caller script --- script/universal/OPCMCaller.sol | 589 -------------------------------- 1 file changed, 589 deletions(-) delete mode 100644 script/universal/OPCMCaller.sol diff --git a/script/universal/OPCMCaller.sol b/script/universal/OPCMCaller.sol deleted file mode 100644 index 6c6aed9..0000000 --- a/script/universal/OPCMCaller.sol +++ /dev/null @@ -1,589 +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 {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 {IGnosisSafe, Enum} from "./IGnosisSafe.sol"; -import {Signatures} from "./Signatures.sol"; -import {Simulation} from "./Simulation.sol"; -import {StateDiff} from "./StateDiff.sol"; - -interface IOPCM { - struct OpChainConfig { - address systemConfigProxy; - address proxyAdmin; - bytes32 absolutePrestate; - } - - function upgrade(OpChainConfig[] memory _opChainConfigs) external; -} - -/// @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 OPCMCaller is Script { - bytes32 internal constant SAFE_NONCE_SLOT = bytes32(uint256(5)); - - /// @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 Returns the address of the OPCM contract to call - function _opcmAddress() internal view virtual returns (address); - - /// @notice Creates the calldata for signatures (`sign`), approvals (`approve`), and execution (`run`) - function _buildCall() internal view virtual returns (IMulticall3.Call3 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]}); - } - - bytes[] memory datas = _transactionDatas({safes: safes}); - - vm.startMappingRecording(); - (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = - _simulateForSigner({safes: safes, datas: datas}); - (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}); - - // 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], data: datas[0]}); - StateDiff.recordStateDiff({json: json, parents: parents, txData: txData, targetSafe: _ownerSafe()}); - - _printDataToSign({safe: safes[0], data: datas[0], txData: txData}); - } - - /// 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}); - (bytes[] memory datas) = _transactionDatas({safes: safes}); - _checkSignatures({safe: safes[0], data: datas[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}); - bytes[] memory datas = _transactionDatas({safes: safes}); - (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = _executeTransaction({ - safe: safes[0], to: MULTICALL3_ADDRESS, data: datas[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(); - bytes[] memory datas = _transactionDatas({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, to: _opcmAddress(), data: datas[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(); - bytes[] memory datas = _transactionDatas({safes: _toArray(ownerSafe)}); - - (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = _executeTransaction({ - safe: ownerSafe, to: _opcmAddress(), data: datas[0], signatures: signatures, broadcast: true - }); - - _postRun({accesses: accesses, simPayload: simPayload}); - _postCheck({accesses: accesses, simPayload: simPayload}); - } - - ////////////////////////////////////////////////////////////////////////////////////// - /// Internal Functions /// - ////////////////////////////////////////////////////////////////////////////////////// - - 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; - } - - function _transactionDatas(address[] memory safes) private view returns (bytes[] memory datas) { - // Build the calls and sum the values - IMulticall3.Call3 memory call = _buildCall(); - - // The very last call is the actual (aggregated) call to execute - datas = new bytes[](safes.length); - datas[datas.length - 1] = call.callData; - - // The first n-1 calls are the nested approval calls - for (uint256 i = safes.length - 1; i > 0; i--) { - address targetSafe = safes[i]; - bytes memory callToApprove = datas[i]; - - IMulticall3.Call3[] memory approvalCall = new IMulticall3.Call3[](1); - approvalCall[0] = _generateApproveCall({safe: targetSafe, data: callToApprove}); - datas[i - 1] = abi.encodeCall(IMulticall3.aggregate3, (approvalCall)); - } - } - - function _generateApproveCall(address safe, bytes memory data) internal view returns (IMulticall3.Call3 memory) { - bytes32 hash = _getTransactionHash({safe: safe, data: data}); - - 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)) - }); - } - - function _printDataToSign(address safe, bytes memory data, bytes memory txData) internal { - bytes32 hash = _getTransactionHash({safe: safe, data: data}); - - emit DataToSign({data: txData}); - - console.log("---\nIf submitting onchain, call Safe.approveHash on %s with the following hash:", safe); - console.logBytes32(hash); - - 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("###############################"); - } - - function _executeTransaction(address safe, address to, bytes memory data, bytes memory signatures, bool broadcast) - internal - returns (Vm.AccountAccess[] memory, Simulation.Payload memory) - { - bytes32 hash = _getTransactionHash({safe: safe, data: data}); - signatures = Signatures.prepareSignatures({safe: safe, hash: hash, signatures: signatures}); - - bytes memory simData = _execTransactionCalldata({safe: safe, data: data, signatures: signatures}); - Simulation.logSimulationLink({to: safe, data: simData, from: msg.sender}); - - vm.startStateDiffRecording(); - bool success = _execTransaction({safe: safe, to: to, data: data, 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"); - - // 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) - }); - return (accesses, simPayload); - } - - function _simulateForSigner(address[] memory safes, bytes[] memory datas) - internal - returns (Vm.AccountAccess[] memory, Simulation.Payload memory) - { - IMulticall3.Call3[] memory calls = _simulateForSignerCalls({safes: safes, datas: datas}); - - bytes32 firstCallDataHash = _getTransactionHash({safe: safes[0], data: datas[0]}); - - // Now define the state overrides for the simulation. - Simulation.StateOverride[] memory overrides = _overrides({safes: safes, firstCallDataHash: firstCallDataHash}); - - bytes memory txData = abi.encodeCall(IMulticall3.aggregate3, (calls)); - console.log("---\nSimulation link:"); - // solhint-disable max-line-length - Simulation.logSimulationLink({to: _opcmAddress(), 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: _opcmAddress(), 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) - private - view - returns (IMulticall3.Call3[] memory) - { - IMulticall3.Call3[] memory calls = new IMulticall3.Call3[](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], signatures: Signatures.genPrevalidatedSignature(signer) - }) - }); - } - - return calls; - } - - // 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); - } - } - - function _checkSignatures(address safe, bytes memory data, bytes memory signatures) internal view { - bytes32 hash = _getTransactionHash({safe: safe, data: data}); - 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) internal view returns (bytes32) { - return keccak256(_encodeTransactionData({safe: safe, data: data})); - } - - function _encodeTransactionData(address safe, bytes memory data) internal view returns (bytes memory) { - return IGnosisSafe(safe) - .encodeTransactionData({ - to: _opcmAddress(), - value: 0, - data: data, - operation: Enum.Operation.DelegateCall, - safeTxGas: 0, - baseGas: 0, - gasPrice: 0, - gasToken: address(0), - refundReceiver: address(0), - _nonce: _getNonce(safe) - }); - } - - function _execTransactionCalldata(address safe, bytes memory data, bytes memory signatures) - internal - view - returns (bytes memory) - { - return abi.encodeCall( - IGnosisSafe(safe).execTransaction, - (_opcmAddress(), 0, data, Enum.Operation.DelegateCall, 0, 0, 0, address(0), payable(address(0)), signatures) - ); - } - - function _execTransaction(address safe, address to, bytes memory data, bytes memory signatures, bool broadcast) - internal - returns (bool) - { - if (broadcast) { - vm.broadcast(); - } - return IGnosisSafe(safe) - .execTransaction({ - to: to, - value: 0, - data: data, - operation: Enum.Operation.DelegateCall, - safeTxGas: 0, - baseGas: 0, - gasPrice: 0, - gasToken: address(0), - refundReceiver: payable(address(0)), - signatures: signatures - }); - } - - function _toArray(address addr) internal pure returns (address[] memory) { - address[] memory array = new address[](1); - array[0] = addr; - return array; - } - - 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; - } -} From 6548540e89e736ff9d8a5a3a1ab3c5cafb66afc8 Mon Sep 17 00:00:00 2001 From: Jack Chuma Date: Tue, 11 Nov 2025 12:09:07 -0500 Subject: [PATCH 08/14] add initial target getter --- script/universal/MultisigScript.sol | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/script/universal/MultisigScript.sol b/script/universal/MultisigScript.sol index f7270b6..19f5172 100644 --- a/script/universal/MultisigScript.sol +++ b/script/universal/MultisigScript.sol @@ -248,10 +248,12 @@ abstract contract MultisigScript is Script { vm.store({target: safes[i], slot: SAFE_NONCE_SLOT, value: bytes32(originalNonces[i])}); } - bytes memory txData = _encodeTransactionData(SafeTx({safe: safes[0], to: target, data: datas[0], value: value})); + address initialTarget = _getInitialTarget(safes, target); + bytes memory txData = + _encodeTransactionData(SafeTx({safe: safes[0], to: initialTarget, data: datas[0], value: value})); StateDiff.recordStateDiff({json: json, parents: parents, txData: txData, targetSafe: _ownerSafe()}); - _printDataToSign({safe: safes[0], to: target, data: datas[0], value: value, txData: txData}); + _printDataToSign({safe: safes[0], to: initialTarget, data: datas[0], value: value, txData: txData}); } /// Step 1.1 (optional) @@ -460,9 +462,8 @@ abstract contract MultisigScript is Script { { IMulticall3.Call3[] memory calls = _simulateForSignerCalls({safes: safes, to: to, datas: datas, value: value}); - bytes32 firstCallDataHash = _getTransactionHash({ - safe: safes[0], to: safes.length > 1 ? multicallAddress : to, data: datas[0], value: value - }); + bytes32 firstCallDataHash = + _getTransactionHash({safe: safes[0], to: _getInitialTarget(safes, to), data: datas[0], value: value}); // Now define the state overrides for the simulation. Simulation.StateOverride[] memory overrides = _overrides({safes: safes, firstCallDataHash: firstCallDataHash}); @@ -661,4 +662,8 @@ abstract contract MultisigScript is Script { return Enum.Operation.Call; } + + function _getInitialTarget(address[] memory safes, address target) private view returns (address) { + return safes.length > 1 ? multicallAddress : target; + } } From 0186fb24978f66471fc0d40d1fc59f4156f46c91 Mon Sep 17 00:00:00 2001 From: Jack Chuma Date: Tue, 11 Nov 2025 12:23:51 -0500 Subject: [PATCH 09/14] use initial target in verify function --- script/universal/MultisigScript.sol | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/script/universal/MultisigScript.sol b/script/universal/MultisigScript.sol index 19f5172..8b12ecc 100644 --- a/script/universal/MultisigScript.sol +++ b/script/universal/MultisigScript.sol @@ -266,7 +266,9 @@ abstract contract MultisigScript is Script { function verify(address[] memory safes, bytes memory signatures) public view { safes = _appendOwnerSafe({safes: safes}); (bytes[] memory datas, uint256 value, address target) = _transactionDatas({safes: safes}); - _checkSignatures({safe: safes[0], to: target, data: datas[0], value: value, signatures: signatures}); + _checkSignatures({ + safe: safes[0], to: _getInitialTarget(safes, target), data: datas[0], value: value, signatures: signatures + }); } /// Step 2 (optional for non-nested setups) From c03ec5a6825ecf434c0affe64c3abfc8be4acbc8 Mon Sep 17 00:00:00 2001 From: Jack Chuma Date: Tue, 11 Nov 2025 12:39:22 -0500 Subject: [PATCH 10/14] add testing --- test/universal/MultisigScript.t.sol | 181 ++++++++++++++++++ .../MultisigScriptDoubleNested.t.sol | 178 +++++++++++++++++ test/universal/MultisigScriptNested.t.sol | 141 ++++++++++++++ 3 files changed, 500 insertions(+) create mode 100644 test/universal/MultisigScript.t.sol create mode 100644 test/universal/MultisigScriptDoubleNested.t.sol create mode 100644 test/universal/MultisigScriptNested.t.sol diff --git a/test/universal/MultisigScript.t.sol b/test/universal/MultisigScript.t.sol new file mode 100644 index 0000000..a344d62 --- /dev/null +++ b/test/universal/MultisigScript.t.sol @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +import {IMulticall3} from "forge-std/interfaces/IMulticall3.sol"; +import {Test} from "forge-std/Test.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {Preinstalls} from "lib/optimism/packages/contracts-bedrock/src/libraries/Preinstalls.sol"; + +import {MultisigScript} from "script/universal/MultisigScript.sol"; +import {Simulation} from "script/universal/Simulation.sol"; +import {IGnosisSafe, Enum} from "script/universal/IGnosisSafe.sol"; +import {Signatures} from "script/universal/Signatures.sol"; + +import {Counter} from "test/universal/Counter.sol"; + +contract MultisigScriptTest is Test, MultisigScript { + Vm.Wallet internal wallet1 = vm.createWallet("1"); + Vm.Wallet internal wallet2 = vm.createWallet("2"); + Vm.Wallet internal wallet3 = vm.createWallet("3"); + + address internal safe = address(1001); + Counter internal counter = new Counter(address(safe)); + + function() internal view returns (IMulticall3.Call3Value[] memory) buildCallsInternal; + + bytes internal 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 { + 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.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(); + 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.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(); + assertEq(keccak256(logs[logs.length - 1].data), keccak256(abi.encode(dataToSignWithValue))); + } + + function test_verify_valid_signatures() external { + buildCallsInternal = _buildCallsNoValue; + // Two-of-two signatures over the known payload should verify + (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); + verify(new address[](0), signatures); + } + + function test_verify_reverts_with_invalid_signature() external { + buildCallsInternal = _buildCallsNoValue; + // One valid, one invalid should revert + (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, keccak256(dataToSignNoValue)); + bytes memory signatures = abi.encodePacked(r1, s1, v1, bytes32(0), bytes32(0), uint8(27)); + bytes memory callData = + abi.encodeCall(this.verify, (new address[](0), signatures)); + (bool success, bytes memory ret) = address(this).call(callData); + assertFalse(success); + assertTrue(ret.length > 0); + } + + function test_simulate_only() external { + buildCallsInternal = _buildCallsNoValue; + (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); + + // Simulate should execute successfully and satisfy _postCheck + simulate(signatures); + } + + function test_run_with_more_signatures_than_threshold() external { + // Create a safe with 3 owners but threshold of 2 + address safe3of2 = address(1002); + vm.etch(safe3of2, Preinstalls.getDeployedCode(Preinstalls.Safe_v130, block.chainid)); + vm.deal(safe3of2, 10 ether); + + address[] memory owners = new address[](3); + owners[0] = wallet1.addr; + owners[1] = wallet2.addr; + owners[2] = wallet3.addr; + IGnosisSafe(safe3of2).setup(owners, 2, address(0), "", address(0), address(0), 0, address(0)); + + Counter counter3of2 = new Counter(safe3of2); + bytes32 hash = keccak256(dataToSign3of2); + + (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, hash); + (uint8 v2, bytes32 r2, bytes32 s2) = vm.sign(wallet2, hash); + (uint8 v3, bytes32 r3, bytes32 s3) = vm.sign(wallet3, hash); + + bytes memory sigs = abi.encodePacked(r1, s1, v1, r2, s2, v2, r3, s3, v3); + sigs = Signatures.prepareSignatures({safe: safe3of2, hash: hash, signatures: sigs}); + + bool success = IGnosisSafe(safe3of2) + .execTransaction({ + to: address(counter3of2), + value: 0, + data: abi.encodeCall(Counter.increment, ()), + operation: Enum.Operation.Call, + safeTxGas: 0, + baseGas: 0, + gasPrice: 0, + gasToken: address(0), + refundReceiver: payable(address(0)), + signatures: sigs + }); + + assertTrue(success, "Should succeed with extra signatures"); + assertEq(counter3of2.count(), 1, "Counter should be incremented"); + } + + function _buildCallsNoValue() internal view returns (IMulticall3.Call3Value[] memory) { + IMulticall3.Call3Value[] memory calls = new IMulticall3.Call3Value[](1); + + calls[0] = IMulticall3.Call3Value({ + target: address(counter), allowFailure: false, callData: abi.encodeCall(Counter.increment, ()), value: 0 + }); + + return calls; + } + + function _buildCallsWithValue() internal view returns (IMulticall3.Call3Value[] memory) { + IMulticall3.Call3Value[] memory calls = new IMulticall3.Call3Value[](1); + + calls[0] = IMulticall3.Call3Value({ + target: address(counter), + allowFailure: false, + callData: abi.encodeCall(Counter.incrementPayable, ()), + value: 1 ether + }); + + return calls; + } +} + + diff --git a/test/universal/MultisigScriptDoubleNested.t.sol b/test/universal/MultisigScriptDoubleNested.t.sol new file mode 100644 index 0000000..0c3a920 --- /dev/null +++ b/test/universal/MultisigScriptDoubleNested.t.sol @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +import {IMulticall3} from "forge-std/interfaces/IMulticall3.sol"; +import {Test} from "forge-std/Test.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {Preinstalls} from "lib/optimism/packages/contracts-bedrock/src/libraries/Preinstalls.sol"; + +import {MultisigScript} from "script/universal/MultisigScript.sol"; +import {Simulation} from "script/universal/Simulation.sol"; +import {IGnosisSafe} from "script/universal/IGnosisSafe.sol"; + +import {Counter} from "test/universal/Counter.sol"; + +contract MultisigScriptDoubleNestedTest is Test, MultisigScript { + Vm.Wallet internal wallet1 = vm.createWallet("1"); + Vm.Wallet internal wallet2 = vm.createWallet("2"); + + address internal safe1 = address(1001); + address internal safe2 = address(1002); + address internal safe3 = address(1003); + address internal safe4 = address(1004); + Counter internal counter = new Counter(address(safe4)); + + bytes internal dataToSign1 = + // solhint-disable max-line-length + hex"1901d4bb33110137810c444c1d9617abe97df097d587ecde64e6fcb38d7f49e1280c79f9c7295573dc135fa98d1fc9f5a01ae7e7caad046143376e34f9945288b7a0"; + bytes internal dataToSign2 = + hex"190132640243d7aade8c72f3d90d2dbf359e9897feba5fce1453bc8d9e7ba10d171579f9c7295573dc135fa98d1fc9f5a01ae7e7caad046143376e34f9945288b7a0"; + + function setUp() public { + bytes memory safeCode = Preinstalls.getDeployedCode(Preinstalls.Safe_v130, block.chainid); + vm.etch(safe1, safeCode); + vm.etch(safe2, safeCode); + vm.etch(safe3, safeCode); + vm.etch(safe4, safeCode); + vm.etch(Preinstalls.MultiCall3, Preinstalls.getDeployedCode(Preinstalls.MultiCall3, block.chainid)); + + address[] memory owners1 = new address[](1); + owners1[0] = wallet1.addr; + IGnosisSafe(safe1).setup(owners1, 1, address(0), "", address(0), address(0), 0, address(0)); + + address[] memory owners2 = new address[](1); + owners2[0] = wallet2.addr; + IGnosisSafe(safe2).setup(owners2, 1, address(0), "", address(0), address(0), 0, address(0)); + + address[] memory owners3 = new address[](2); + owners3[0] = safe1; + owners3[1] = safe2; + IGnosisSafe(safe3).setup(owners3, 2, address(0), "", address(0), address(0), 0, address(0)); + + address[] memory owners4 = new address[](1); + owners4[0] = safe3; + IGnosisSafe(safe4).setup(owners4, 1, address(0), "", address(0), address(0), 0, address(0)); + } + + function _postCheck(Vm.AccountAccess[] memory, Simulation.Payload memory) internal view override { + uint256 counterValue = counter.count(); + require(counterValue == 1, "Counter value is not 1"); + } + + function _buildCalls() internal view override returns (IMulticall3.Call3Value[] memory) { + IMulticall3.Call3Value[] memory calls = new IMulticall3.Call3Value[](1); + + calls[0] = IMulticall3.Call3Value({ + target: address(counter), allowFailure: false, callData: abi.encodeCall(Counter.increment, ()), value: 0 + }); + + return calls; + } + + function _ownerSafe() internal view override returns (address) { + return safe4; + } + + function test_sign_double_nested_safe1() external { + vm.recordLogs(); + address[] memory safes = new address[](2); + safes[0] = safe1; + safes[1] = safe3; + vm.prank(wallet1.addr); + bytes memory txData = abi.encodeWithSelector(this.sign.selector, safes); + (bool success,) = address(this).call(txData); + vm.assertTrue(success); + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(keccak256(logs[logs.length - 1].data), keccak256(abi.encode(dataToSign1))); + } + + function test_sign_double_nested_safe2() external { + vm.recordLogs(); + address[] memory safes = new address[](2); + safes[0] = safe2; + safes[1] = safe3; + vm.prank(wallet2.addr); + bytes memory txData = abi.encodeWithSelector(this.sign.selector, safes); + (bool success,) = address(this).call(txData); + vm.assertTrue(success); + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(keccak256(logs[logs.length - 1].data), keccak256(abi.encode(dataToSign2))); + } + + function test_approveInit_double_nested_safe1() external { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(wallet1, keccak256(dataToSign1)); + address[] memory safes = new address[](2); + safes[0] = safe1; + safes[1] = safe3; + approve(safes, abi.encodePacked(r, s, v)); + } + + function test_approveInit_double_nested_safe2() external { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(wallet2, keccak256(dataToSign2)); + address[] memory safes = new address[](2); + safes[0] = safe2; + safes[1] = safe3; + approve(safes, abi.encodePacked(r, s, v)); + } + + function test_approveInit_double_nested_notOwner() external { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(wallet1, keccak256(dataToSign1)); + address[] memory safes = new address[](2); + safes[0] = safe2; + safes[1] = safe3; + bytes memory data = abi.encodeCall(this.approve, (safes, abi.encodePacked(r, s, v))); + (bool success, bytes memory result) = address(this).call(data); + assertFalse(success); + assertEq(result, abi.encodeWithSignature("Error(string)", "not enough signatures")); + } + + function test_runInit_double_nested() external { + (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, keccak256(dataToSign1)); + (uint8 v2, bytes32 r2, bytes32 s2) = vm.sign(wallet2, keccak256(dataToSign2)); + address[] memory sA = new address[](2); + sA[0] = safe1; + sA[1] = safe3; + address[] memory sB = new address[](2); + sB[0] = safe2; + sB[1] = safe3; + approve(sA, abi.encodePacked(r1, s1, v1)); + approve(sB, abi.encodePacked(r2, s2, v2)); + address[] memory mid = new address[](1); + mid[0] = safe3; + approve(mid, ""); + } + + function test_runInit_double_nested_notApproved() external { + (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, keccak256(dataToSign1)); + address[] memory sA = new address[](2); + sA[0] = safe1; + sA[1] = safe3; + approve(sA, abi.encodePacked(r1, s1, v1)); + address[] memory mid = new address[](1); + mid[0] = safe3; + bytes memory data = abi.encodeCall(this.approve, (mid, "")); + (bool success, bytes memory result) = address(this).call(data); + assertFalse(success); + assertEq(result, abi.encodeWithSignature("Error(string)", "not enough signatures")); + } + + function test_run_double_nested() external { + (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, keccak256(dataToSign1)); + (uint8 v2, bytes32 r2, bytes32 s2) = vm.sign(wallet2, keccak256(dataToSign2)); + address[] memory sA = new address[](2); + sA[0] = safe1; + sA[1] = safe3; + address[] memory sB = new address[](2); + sB[0] = safe2; + sB[1] = safe3; + approve(sA, abi.encodePacked(r1, s1, v1)); + approve(sB, abi.encodePacked(r2, s2, v2)); + address[] memory mid = new address[](1); + mid[0] = safe3; + approve(mid, ""); + + run(""); + } +} + + diff --git a/test/universal/MultisigScriptNested.t.sol b/test/universal/MultisigScriptNested.t.sol new file mode 100644 index 0000000..a2508c3 --- /dev/null +++ b/test/universal/MultisigScriptNested.t.sol @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +import {IMulticall3} from "forge-std/interfaces/IMulticall3.sol"; +import {Test} from "forge-std/Test.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {Preinstalls} from "lib/optimism/packages/contracts-bedrock/src/libraries/Preinstalls.sol"; + +import {MultisigScript} from "script/universal/MultisigScript.sol"; +import {Simulation} from "script/universal/Simulation.sol"; +import {IGnosisSafe} from "script/universal/IGnosisSafe.sol"; +import {Counter} from "test/universal/Counter.sol"; + +contract MultisigScriptNestedTest is Test, MultisigScript { + Vm.Wallet internal wallet1 = vm.createWallet("1"); + Vm.Wallet internal wallet2 = vm.createWallet("2"); + + address internal safe1 = address(1001); + address internal safe2 = address(1002); + address internal safe3 = address(1003); + Counter internal counter = new Counter(address(safe3)); + + bytes internal dataToSign1 = + // solhint-disable max-line-length + hex"1901d4bb33110137810c444c1d9617abe97df097d587ecde64e6fcb38d7f49e1280c5f51d24161b7d5dfddfd10cad9118e4e37e6fde740a81d2d84dc35a401b0f74c"; + bytes internal dataToSign2 = + hex"190132640243d7aade8c72f3d90d2dbf359e9897feba5fce1453bc8d9e7ba10d17155f51d24161b7d5dfddfd10cad9118e4e37e6fde740a81d2d84dc35a401b0f74c"; + + function setUp() public { + bytes memory safeCode = Preinstalls.getDeployedCode(Preinstalls.Safe_v130, block.chainid); + vm.etch(safe1, safeCode); + vm.etch(safe2, safeCode); + vm.etch(safe3, safeCode); + vm.etch(Preinstalls.MultiCall3, Preinstalls.getDeployedCode(Preinstalls.MultiCall3, block.chainid)); + + address[] memory owners1 = new address[](1); + owners1[0] = wallet1.addr; + IGnosisSafe(safe1).setup(owners1, 1, address(0), "", address(0), address(0), 0, address(0)); + + address[] memory owners2 = new address[](1); + owners2[0] = wallet2.addr; + IGnosisSafe(safe2).setup(owners2, 1, address(0), "", address(0), address(0), 0, address(0)); + + address[] memory owners3 = new address[](2); + owners3[0] = safe1; + owners3[1] = safe2; + IGnosisSafe(safe3).setup(owners3, 2, address(0), "", address(0), address(0), 0, address(0)); + } + + function _postCheck(Vm.AccountAccess[] memory, Simulation.Payload memory) internal view override { + uint256 counterValue = counter.count(); + require(counterValue == 1, "Counter value is not 1"); + } + + function _buildCalls() internal view override returns (IMulticall3.Call3Value[] memory) { + IMulticall3.Call3Value[] memory calls = new IMulticall3.Call3Value[](1); + + calls[0] = IMulticall3.Call3Value({ + target: address(counter), allowFailure: false, callData: abi.encodeCall(Counter.increment, ()), value: 0 + }); + + return calls; + } + + function _ownerSafe() internal view override returns (address) { + return address(safe3); + } + + function test_sign_safe1() external { + vm.recordLogs(); + address[] memory safes = new address[](1); + safes[0] = safe1; + vm.prank(wallet1.addr); + bytes memory txData = abi.encodeWithSelector(this.sign.selector, safes); + (bool success,) = address(this).call(txData); + vm.assertTrue(success); + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(keccak256(logs[logs.length - 1].data), keccak256(abi.encode(dataToSign1))); + } + + function test_sign_safe2() external { + vm.recordLogs(); + address[] memory safes = new address[](1); + safes[0] = safe2; + vm.prank(wallet2.addr); + bytes memory txData = abi.encodeWithSelector(this.sign.selector, safes); + (bool success,) = address(this).call(txData); + vm.assertTrue(success); + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(keccak256(logs[logs.length - 1].data), keccak256(abi.encode(dataToSign2))); + } + + function test_approve_safe1() external { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(wallet1, keccak256(dataToSign1)); + address[] memory safes = new address[](1); + safes[0] = safe1; + approve(safes, abi.encodePacked(r, s, v)); + } + + function test_approve_safe2() external { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(wallet2, keccak256(dataToSign2)); + address[] memory safes = new address[](1); + safes[0] = safe2; + approve(safes, abi.encodePacked(r, s, v)); + } + + function test_approve_notOwner() external { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(wallet1, keccak256(dataToSign1)); + address[] memory safes = new address[](1); + safes[0] = safe2; + bytes memory data = abi.encodeCall(this.approve, (safes, abi.encodePacked(r, s, v))); + (bool success, bytes memory result) = address(this).call(data); + assertFalse(success); + assertEq(result, abi.encodeWithSignature("Error(string)", "not enough signatures")); + } + + function test_run() external { + (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, keccak256(dataToSign1)); + (uint8 v2, bytes32 r2, bytes32 s2) = vm.sign(wallet2, keccak256(dataToSign2)); + address[] memory arr1 = new address[](1); + arr1[0] = safe1; + address[] memory arr2 = new address[](1); + arr2[0] = safe2; + approve(arr1, abi.encodePacked(r1, s1, v1)); + approve(arr2, abi.encodePacked(r2, s2, v2)); + run(""); + } + + function test_run_notApproved() external { + (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, keccak256(dataToSign1)); + address[] memory arr1 = new address[](1); + arr1[0] = safe1; + approve(arr1, abi.encodePacked(r1, s1, v1)); + bytes memory data = abi.encodeCall(this.run, ("")); + (bool success, bytes memory result) = address(this).call(data); + assertFalse(success); + assertEq(result, abi.encodeWithSignature("Error(string)", "not enough signatures")); + } +} + + From de19b64b38784d62606c48891d886289f2ce2e67 Mon Sep 17 00:00:00 2001 From: Jack Chuma Date: Tue, 11 Nov 2025 12:42:19 -0500 Subject: [PATCH 11/14] forge fmt --- test/universal/MultisigScript.t.sol | 4 +--- test/universal/MultisigScriptDoubleNested.t.sol | 1 - test/universal/MultisigScriptNested.t.sol | 1 - 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/test/universal/MultisigScript.t.sol b/test/universal/MultisigScript.t.sol index a344d62..f4dcbb0 100644 --- a/test/universal/MultisigScript.t.sol +++ b/test/universal/MultisigScript.t.sol @@ -97,8 +97,7 @@ contract MultisigScriptTest is Test, MultisigScript { // One valid, one invalid should revert (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, keccak256(dataToSignNoValue)); 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)); + 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); @@ -178,4 +177,3 @@ contract MultisigScriptTest is Test, MultisigScript { } } - diff --git a/test/universal/MultisigScriptDoubleNested.t.sol b/test/universal/MultisigScriptDoubleNested.t.sol index 0c3a920..3bb0145 100644 --- a/test/universal/MultisigScriptDoubleNested.t.sol +++ b/test/universal/MultisigScriptDoubleNested.t.sol @@ -175,4 +175,3 @@ contract MultisigScriptDoubleNestedTest is Test, MultisigScript { } } - diff --git a/test/universal/MultisigScriptNested.t.sol b/test/universal/MultisigScriptNested.t.sol index a2508c3..8b481e1 100644 --- a/test/universal/MultisigScriptNested.t.sol +++ b/test/universal/MultisigScriptNested.t.sol @@ -138,4 +138,3 @@ contract MultisigScriptNestedTest is Test, MultisigScript { } } - From d12ce350ac1e8d15e6ac1038576fa33d93a38688 Mon Sep 17 00:00:00 2001 From: Jack Chuma Date: Mon, 17 Nov 2025 16:48:10 -0500 Subject: [PATCH 12/14] update cb multicall and integrate to MultisigScript --- script/universal/MultisigScript.sol | 174 ++++++++++++---------------- src/utils/CBMulticall.sol | 53 ++++++++- test/universal/MultisigScript.t.sol | 44 ++++--- test/utils/CBMulticall.t.sol | 65 +++++++++++ 4 files changed, 213 insertions(+), 123 deletions(-) diff --git a/script/universal/MultisigScript.sol b/script/universal/MultisigScript.sol index 8b12ecc..de8c25e 100644 --- a/script/universal/MultisigScript.sol +++ b/script/universal/MultisigScript.sol @@ -11,6 +11,7 @@ import {IGnosisSafe, Enum} from "./IGnosisSafe.sol"; import {Signatures} from "./Signatures.sol"; import {Simulation} from "./Simulation.sol"; import {StateDiff} from "./StateDiff.sol"; +import {CBMulticall} from "../../src/utils/CBMulticall.sol"; /// @title MultisigScript /// @notice Script builder for Forge scripts that require signatures from Safes. Supports both non-nested @@ -147,13 +148,6 @@ import {StateDiff} from "./StateDiff.sol"; /// │ │ │ │ │ │ │ run() │ /// │ │ │ │ │ │ │─────────────────────────────>│ abstract contract MultisigScript is Script { - struct SafeTx { - address safe; - address to; - bytes data; - uint256 value; - } - bytes32 internal constant SAFE_NONCE_SLOT = bytes32(uint256(5)); address internal constant CB_MULTICALL = 0x8BDE8F549F56D405f07e1aA15Df9e1FC69839881; @@ -190,11 +184,11 @@ 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 call is a multicall. Most tasks should leave this as-is. - /// For special cases, i.e. tasks that invoke OPCM and need to be a delegate call, we set this to false - /// @dev If set to false, the task must configure only a single call - function _useMulticall() internal pure virtual returns (bool) { - return true; + /// @notice If set to true, the executed call runs through our custom CBMulticall contract as a DELEGATECALL for each call + /// This results in the multisig inheriting the multicall logic as well as the multicall call target logic + /// This should be used for tasks that use Optimism's OPCM, for example + function _useDelegateCall() internal pure virtual returns (bool) { + return false; } constructor() { @@ -202,7 +196,7 @@ abstract contract MultisigScript is Script { try vm.envBool("USE_CB_MULTICALL") { useCbMulticall = vm.envBool("USE_CB_MULTICALL"); } catch {} - multicallAddress = useCbMulticall ? CB_MULTICALL : MULTICALL3_ADDRESS; + multicallAddress = (useCbMulticall || _useDelegateCall()) ? CB_MULTICALL : MULTICALL3_ADDRESS; } ////////////////////////////////////////////////////////////////////////////////////// @@ -231,11 +225,11 @@ abstract contract MultisigScript is Script { originalNonces[i] = _getNonce({safe: safes[i]}); } - (bytes[] memory datas, uint256 value, address target) = _transactionDatas({safes: safes}); + (bytes[] memory datas, uint256 value) = _transactionDatas({safes: safes}); vm.startMappingRecording(); (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = - _simulateForSigner({safes: safes, to: target, datas: datas, value: value}); + _simulateForSigner({safes: safes, datas: datas, value: value}); (StateDiff.MappingParent[] memory parents, string memory json) = StateDiff.collectStateDiff(StateDiff.CollectStateDiffOpts({accesses: accesses, simPayload: simPayload})); vm.stopMappingRecording(); @@ -248,12 +242,10 @@ abstract contract MultisigScript is Script { vm.store({target: safes[i], slot: SAFE_NONCE_SLOT, value: bytes32(originalNonces[i])}); } - address initialTarget = _getInitialTarget(safes, target); - bytes memory txData = - _encodeTransactionData(SafeTx({safe: safes[0], to: initialTarget, data: datas[0], value: value})); + bytes memory txData = _encodeTransactionData({safe: safes[0], data: datas[0], value: value}); StateDiff.recordStateDiff({json: json, parents: parents, txData: txData, targetSafe: _ownerSafe()}); - _printDataToSign({safe: safes[0], to: initialTarget, data: datas[0], value: value, txData: txData}); + _printDataToSign({safe: safes[0], data: datas[0], value: value, txData: txData}); } /// Step 1.1 (optional) @@ -265,10 +257,8 @@ 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, address target) = _transactionDatas({safes: safes}); - _checkSignatures({ - safe: safes[0], to: _getInitialTarget(safes, target), data: datas[0], value: value, signatures: signatures - }); + (bytes[] memory datas, uint256 value) = _transactionDatas({safes: safes}); + _checkSignatures({safe: safes[0], data: datas[0], value: value, signatures: signatures}); } /// Step 2 (optional for non-nested setups) @@ -285,10 +275,9 @@ 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}); - (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = _executeTransaction({ - safe: safes[0], to: multicallAddress, data: datas[0], value: value, signatures: signatures, broadcast: true - }); + (bytes[] memory datas, uint256 value) = _transactionDatas({safes: safes}); + (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = + _executeTransaction({safe: safes[0], data: datas[0], value: value, signatures: signatures, broadcast: true}); _postApprove({accesses: accesses, simPayload: simPayload}); } @@ -303,12 +292,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, address target) = _transactionDatas({safes: _toArray(ownerSafe)}); + (bytes[] memory datas, uint256 value) = _transactionDatas({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, to: target, data: datas[0], value: value, signatures: signatures, broadcast: false + safe: ownerSafe, data: datas[0], value: value, signatures: signatures, broadcast: false }); _postRun({accesses: accesses, simPayload: simPayload}); @@ -324,10 +313,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, address target) = _transactionDatas({safes: _toArray(ownerSafe)}); + (bytes[] memory datas, uint256 value) = _transactionDatas({safes: _toArray(ownerSafe)}); (Vm.AccountAccess[] memory accesses, Simulation.Payload memory simPayload) = _executeTransaction({ - safe: ownerSafe, to: target, data: datas[0], value: value, signatures: signatures, broadcast: true + safe: ownerSafe, data: datas[0], value: value, signatures: signatures, broadcast: true }); _postRun({accesses: accesses, simPayload: simPayload}); @@ -347,11 +336,7 @@ abstract contract MultisigScript is Script { return extendedSafes; } - function _transactionDatas(address[] memory safes) - private - view - returns (bytes[] memory datas, uint256 value, address target) - { + 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++) { @@ -362,11 +347,8 @@ abstract contract MultisigScript is Script { datas = new bytes[](safes.length); datas[datas.length - 1] = abi.encodeCall(IMulticall3.aggregate3Value, (calls)); - target = multicallAddress; - if (!_useMulticall()) { - require(calls.length == 1, "MultisigScript::_transactionDatas must use a single call if not multicall"); - target = calls[0].target; - datas[datas.length - 1] = calls[0].callData; + if (_useDelegateCall()) { + datas[datas.length - 1] = abi.encodeCall(CBMulticall.aggregateDelegateCalls, (_toCall3Array(calls))); } // The first n-1 calls are the nested approval calls @@ -374,26 +356,32 @@ abstract contract MultisigScript is Script { for (uint256 i = safes.length - 1; i > 0; i--) { address targetSafe = safes[i]; bytes memory callToApprove = datas[i]; - address to = target; - if (i < safes.length - 1) { - to = multicallAddress; - } IMulticall3.Call3[] memory approvalCall = new IMulticall3.Call3[](1); approvalCall[0] = - _generateApproveCall({safe: targetSafe, to: to, data: callToApprove, value: valueForCallToApprove}); + _generateApproveCall({safe: targetSafe, data: callToApprove, value: valueForCallToApprove}); datas[i - 1] = abi.encodeCall(IMulticall3.aggregate3, (approvalCall)); valueForCallToApprove = 0; } } - function _generateApproveCall(address safe, address to, bytes memory data, uint256 value) + 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++) { + dCalls[i] = CBMulticall.Call3({ + target: calls[i].target, allowFailure: calls[i].allowFailure, callData: calls[i].callData + }); + } + return dCalls; + } + + function _generateApproveCall(address safe, bytes memory data, uint256 value) internal view returns (IMulticall3.Call3 memory) { - bytes32 hash = _getTransactionHash({safe: safe, to: to, data: data, value: value}); + bytes32 hash = _getTransactionHash({safe: safe, data: data, value: value}); console.log("---\nNested hash for safe %s:", safe); console.logBytes32(hash); @@ -403,10 +391,8 @@ abstract contract MultisigScript is Script { }); } - function _printDataToSign(address safe, address to, bytes memory data, uint256 value, bytes memory txData) - internal - { - bytes32 hash = _getTransactionHash({safe: safe, to: to, data: data, value: value}); + function _printDataToSign(address safe, bytes memory data, uint256 value, bytes memory txData) internal { + bytes32 hash = _getTransactionHash({safe: safe, data: data, value: value}); emit DataToSign({data: txData}); @@ -429,23 +415,20 @@ abstract contract MultisigScript is Script { function _executeTransaction( address safe, - address to, bytes memory data, uint256 value, bytes memory signatures, bool broadcast ) internal returns (Vm.AccountAccess[] memory, Simulation.Payload memory) { - bytes32 hash = _getTransactionHash({safe: safe, to: to, data: data, value: value}); + bytes32 hash = _getTransactionHash({safe: safe, data: data, value: value}); signatures = Signatures.prepareSignatures({safe: safe, hash: hash, signatures: signatures}); - bytes memory simData = - _execTransactionCalldata({safe: safe, to: to, data: data, value: value, signatures: signatures}); + bytes memory simData = _execTransactionCalldata({safe: safe, data: data, value: value, signatures: signatures}); Simulation.logSimulationLink({to: safe, data: simData, from: msg.sender}); vm.startStateDiffRecording(); - bool success = _execTransaction({ - safe: safe, to: to, data: data, value: value, signatures: signatures, broadcast: broadcast - }); + bool success = + _execTransaction({safe: safe, data: data, value: value, 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"); @@ -458,14 +441,13 @@ abstract contract MultisigScript is Script { return (accesses, simPayload); } - function _simulateForSigner(address[] memory safes, address to, bytes[] memory datas, uint256 value) + function _simulateForSigner(address[] memory safes, bytes[] memory datas, uint256 value) internal returns (Vm.AccountAccess[] memory, Simulation.Payload memory) { - IMulticall3.Call3[] memory calls = _simulateForSignerCalls({safes: safes, to: to, datas: datas, value: value}); + IMulticall3.Call3[] memory calls = _simulateForSignerCalls({safes: safes, datas: datas, value: value}); - bytes32 firstCallDataHash = - _getTransactionHash({safe: safes[0], to: _getInitialTarget(safes, to), data: datas[0], value: value}); + bytes32 firstCallDataHash = _getTransactionHash({safe: safes[0], data: datas[0], value: value}); // Now define the state overrides for the simulation. Simulation.StateOverride[] memory overrides = _overrides({safes: safes, firstCallDataHash: firstCallDataHash}); @@ -483,7 +465,7 @@ abstract contract MultisigScript is Script { return (accesses, simPayload); } - function _simulateForSignerCalls(address[] memory safes, address to, bytes[] memory datas, uint256 value) + function _simulateForSignerCalls(address[] memory safes, bytes[] memory datas, uint256 value) private view returns (IMulticall3.Call3[] memory) @@ -497,7 +479,6 @@ abstract contract MultisigScript is Script { allowFailure: false, callData: _execTransactionCalldata({ safe: safes[i], - to: i == safes.length - 1 ? to : multicallAddress, data: datas[i], value: value, signatures: Signatures.genPrevalidatedSignature(signer) @@ -572,66 +553,57 @@ abstract contract MultisigScript is Script { } } - function _checkSignatures(address safe, address to, bytes memory data, uint256 value, bytes memory signatures) - internal - view - { - bytes32 hash = _getTransactionHash({safe: safe, to: to, data: data, value: value}); + 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, address to, bytes memory data, uint256 value) + 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 (bytes32) + returns (bytes memory) { - return keccak256(_encodeTransactionData(SafeTx({safe: safe, to: to, data: data, value: value}))); - } - - function _encodeTransactionData(SafeTx memory t) internal view returns (bytes memory) { - return IGnosisSafe(t.safe) + return IGnosisSafe(safe) .encodeTransactionData({ - to: t.to, - value: t.value, - data: t.data, - operation: _getOperation(t.value), + to: multicallAddress, + value: value, + data: data, + operation: _getOperation(value), safeTxGas: 0, baseGas: 0, gasPrice: 0, gasToken: address(0), refundReceiver: address(0), - _nonce: _getNonce(t.safe) + _nonce: _getNonce(safe) }); } - function _execTransactionCalldata( - address safe, - address to, - bytes memory data, - uint256 value, - bytes memory signatures - ) internal view returns (bytes memory) { + function _execTransactionCalldata(address safe, bytes memory data, uint256 value, bytes memory signatures) + internal + view + returns (bytes memory) + { return abi.encodeCall( IGnosisSafe(safe).execTransaction, - (to, value, data, _getOperation(value), 0, 0, 0, address(0), payable(address(0)), signatures) + (multicallAddress, value, data, _getOperation(value), 0, 0, 0, address(0), payable(address(0)), signatures) ); } - function _execTransaction( - address safe, - address to, - bytes memory data, - uint256 value, - bytes memory signatures, - bool broadcast - ) internal returns (bool) { + function _execTransaction(address safe, bytes memory data, uint256 value, bytes memory signatures, bool broadcast) + internal + returns (bool) + { if (broadcast) { vm.broadcast(); } return IGnosisSafe(safe) .execTransaction({ - to: to, + to: multicallAddress, value: value, data: data, operation: _getOperation(value), @@ -664,8 +636,4 @@ abstract contract MultisigScript is Script { return Enum.Operation.Call; } - - function _getInitialTarget(address[] memory safes, address target) private view returns (address) { - return safes.length > 1 ? multicallAddress : target; - } } diff --git a/src/utils/CBMulticall.sol b/src/utils/CBMulticall.sol index ceb07b4..1fa0edc 100644 --- a/src/utils/CBMulticall.sol +++ b/src/utils/CBMulticall.sol @@ -34,6 +34,14 @@ contract CBMulticall { bytes returnData; } + address private immutable THIS_CB_MULTICALL; + + error MustDelegateCall(); + + constructor() { + THIS_CB_MULTICALL = address(this); + } + /// @notice Backwards-compatible call aggregation with Multicall /// @param calls An array of Call structs /// @return blockNumber The block number where the calls were executed @@ -68,10 +76,11 @@ contract CBMulticall { returnData = new Result[](length); Call calldata call; for (uint256 i = 0; i < length;) { - Result memory result = returnData[i]; + Result memory result; call = calls[i]; (result.success, result.returnData) = call.target.call(call.callData); if (requireSuccess) require(result.success, "Multicall3: call failed"); + returnData[i] = result; unchecked { ++i; } @@ -116,7 +125,7 @@ contract CBMulticall { returnData = new Result[](length); Call3 calldata calli; for (uint256 i = 0; i < length;) { - Result memory result = returnData[i]; + Result memory result; calli = calls[i]; (result.success, result.returnData) = calli.target.call(calli.callData); assembly { @@ -134,6 +143,43 @@ contract CBMulticall { revert(0x00, 0x64) } } + returnData[i] = result; + unchecked { + ++i; + } + } + } + + /// @notice Aggregate calls, ensuring each returns success if required + /// @param calls An array of Call3 structs + /// @return returnData An array of Result structs + function aggregateDelegateCalls(Call3[] calldata calls) public payable returns (Result[] memory returnData) { + if (address(this) == THIS_CB_MULTICALL) { + revert MustDelegateCall(); + } + uint256 length = calls.length; + returnData = new Result[](length); + Call3 calldata calli; + for (uint256 i; i < length;) { + Result memory result; + calli = calls[i]; + (result.success, result.returnData) = calli.target.delegatecall(calli.callData); + assembly { + // Revert if the call fails and failure is not allowed + // `allowFailure := calldataload(add(calli, 0x20))` and `success := mload(result)` + if iszero(or(calldataload(add(calli, 0x20)), mload(result))) { + // set "Error(string)" signature: bytes32(bytes4(keccak256("Error(string)"))) + mstore(0x00, 0x08c379a000000000000000000000000000000000000000000000000000000000) + // set data offset + mstore(0x04, 0x0000000000000000000000000000000000000000000000000000000000000020) + // set length of revert string + mstore(0x24, 0x0000000000000000000000000000000000000000000000000000000000000017) + // set revert string: bytes32(abi.encodePacked("Multicall3: call failed")) + mstore(0x44, 0x4d756c746963616c6c333a2063616c6c206661696c6564000000000000000000) + revert(0x00, 0x64) + } + } + returnData[i] = result; unchecked { ++i; } @@ -148,7 +194,7 @@ contract CBMulticall { returnData = new Result[](length); Call3Value calldata calli; for (uint256 i = 0; i < length;) { - Result memory result = returnData[i]; + Result memory result; calli = calls[i]; uint256 val = calli.value; (result.success, result.returnData) = calli.target.call{value: val}(calli.callData); @@ -167,6 +213,7 @@ contract CBMulticall { revert(0x00, 0x64) } } + returnData[i] = result; unchecked { ++i; } diff --git a/test/universal/MultisigScript.t.sol b/test/universal/MultisigScript.t.sol index f4dcbb0..adc8171 100644 --- a/test/universal/MultisigScript.t.sol +++ b/test/universal/MultisigScript.t.sol @@ -23,14 +23,6 @@ contract MultisigScriptTest is Test, MultisigScript { 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"; @@ -59,6 +51,18 @@ contract MultisigScriptTest is Test, MultisigScript { return address(safe); } + function _expectedTxDataForCurrentBuildCalls() internal view returns (bytes memory) { + IMulticall3.Call3Value[] memory calls = _buildCalls(); + uint256 value; + for (uint256 i; i < calls.length; i++) { + value += calls[i].value; + } + + // Non-nested case: single owner safe, last call is the aggregate call. + bytes memory data = abi.encodeCall(IMulticall3.aggregate3Value, (calls)); + return _encodeTransactionData(_ownerSafe(), data, value); + } + function test_sign_no_value() external { buildCallsInternal = _buildCallsNoValue; @@ -68,7 +72,9 @@ contract MultisigScriptTest is Test, MultisigScript { (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))); + 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 { @@ -80,14 +86,17 @@ contract MultisigScriptTest is Test, MultisigScript { (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))); + bytes memory logged = abi.decode(logs[logs.length - 1].data, (bytes)); + bytes memory expected = _expectedTxDataForCurrentBuildCalls(); + assertEq(keccak256(logged), keccak256(expected)); } function test_verify_valid_signatures() external { buildCallsInternal = _buildCallsNoValue; - // Two-of-two signatures over the known payload should verify - (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, keccak256(dataToSignNoValue)); - (uint8 v2, bytes32 r2, bytes32 s2) = vm.sign(wallet2, keccak256(dataToSignNoValue)); + // 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); } @@ -95,7 +104,8 @@ contract MultisigScriptTest is Test, MultisigScript { function test_verify_reverts_with_invalid_signature() external { buildCallsInternal = _buildCallsNoValue; // One valid, one invalid should revert - (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, keccak256(dataToSignNoValue)); + 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); @@ -105,8 +115,9 @@ contract MultisigScriptTest is Test, MultisigScript { function test_simulate_only() 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)); + 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 @@ -176,4 +187,3 @@ contract MultisigScriptTest is Test, MultisigScript { return calls; } } - diff --git a/test/utils/CBMulticall.t.sol b/test/utils/CBMulticall.t.sol index 606db33..617956f 100644 --- a/test/utils/CBMulticall.t.sol +++ b/test/utils/CBMulticall.t.sol @@ -5,14 +5,33 @@ import {CBMulticall} from "src/utils/CBMulticall.sol"; import {CommonTest} from "test/CommonTest.t.sol"; import {MockReceiver} from "test/mocks/MockReceiver.sol"; +/// @dev Helper contract used to invoke `aggregateDelegateCalls` via `delegatecall`. +/// This simulates the intended multisig usage pattern where the multicall +/// logic is executed in the context of another contract. +contract CBMulticallDelegateCaller { + CBMulticall public mc; + + constructor(CBMulticall _mc) { + mc = _mc; + } + + function aggregateDelegateCalls(CBMulticall.Call3[] calldata calls) external returns (CBMulticall.Result[] memory) { + (, bytes memory data) = + address(mc).delegatecall(abi.encodeWithSelector(CBMulticall.aggregateDelegateCalls.selector, calls)); + return abi.decode(data, (CBMulticall.Result[])); + } +} + contract CBMulticallTest is CommonTest { CBMulticall mc; MockReceiver target; + CBMulticallDelegateCaller delegateCaller; function setUp() public override { super.setUp(); mc = new CBMulticall(); target = new MockReceiver(); + delegateCaller = new CBMulticallDelegateCaller(mc); } function test_aggregate_returnsBlockNumberAndData() external { @@ -160,6 +179,52 @@ contract CBMulticallTest is CommonTest { mc.aggregate3(calls3); } + function test_aggregateDelegateCalls_success() external { + CBMulticall.Call3[] memory calls3 = new CBMulticall.Call3[](1); + calls3[0] = CBMulticall.Call3({ + target: address(target), + allowFailure: false, + callData: abi.encodeWithSelector(MockReceiver.bump.selector, 4) + }); + CBMulticall.Result[] memory ret3 = delegateCaller.aggregateDelegateCalls(calls3); + assertTrue(ret3[0].success); + assertEq(abi.decode(ret3[0].returnData, (uint256)), 5); + } + + function test_aggregateDelegateCalls_allowedFailure_returnsFalse() external { + CBMulticall.Call3[] memory calls3 = new CBMulticall.Call3[](1); + calls3[0] = CBMulticall.Call3({ + target: address(target), + allowFailure: true, + callData: abi.encodeWithSelector(MockReceiver.willRevert.selector) + }); + CBMulticall.Result[] memory ret3 = delegateCaller.aggregateDelegateCalls(calls3); + assertFalse(ret3[0].success); + } + + function test_aggregateDelegateCalls_revertsOnNonAllowedFailure() external { + CBMulticall.Call3[] memory calls3 = new CBMulticall.Call3[](1); + calls3[0] = CBMulticall.Call3({ + target: address(target), + allowFailure: false, + callData: abi.encodeWithSelector(MockReceiver.willRevert.selector) + }); + vm.expectRevert(); + delegateCaller.aggregateDelegateCalls(calls3); + } + + function test_aggregateDelegateCalls_directCall_revertsWithMustDelegateCall() external { + CBMulticall.Call3[] memory calls3 = new CBMulticall.Call3[](1); + calls3[0] = CBMulticall.Call3({ + target: address(target), + allowFailure: false, + callData: abi.encodeWithSelector(MockReceiver.bump.selector, 1) + }); + + vm.expectRevert(CBMulticall.MustDelegateCall.selector); + mc.aggregateDelegateCalls(calls3); + } + function test_aggregate3Value_success_usesContractBalance() external { vm.deal(address(mc), 1 ether); CBMulticall.Call3Value[] memory callsV = new CBMulticall.Call3Value[](1); From 5c4b065966b5f67b44fd97230c31a75ec6ea5af9 Mon Sep 17 00:00:00 2001 From: Jack Chuma Date: Mon, 17 Nov 2025 17:07:13 -0500 Subject: [PATCH 13/14] polish MultisigScript --- script/universal/MultisigScript.sol | 18 ++- src/utils/CBMulticall.sol | 4 + .../MultisigScriptDelegateCall.t.sol | 111 ++++++++++++++++++ 3 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 test/universal/MultisigScriptDelegateCall.t.sol diff --git a/script/universal/MultisigScript.sol b/script/universal/MultisigScript.sol index de8c25e..fa945e1 100644 --- a/script/universal/MultisigScript.sol +++ b/script/universal/MultisigScript.sol @@ -184,10 +184,15 @@ 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 call runs through our custom CBMulticall contract as a DELEGATECALL for each call - /// This results in the multisig inheriting the multicall logic as well as the multicall call target logic - /// This should be used for tasks that use Optimism's OPCM, for example - function _useDelegateCall() internal pure virtual returns (bool) { + /// @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; } @@ -366,9 +371,14 @@ abstract contract MultisigScript is Script { } } + /// @dev Converts `IMulticall3.Call3Value` calls into `CBMulticall.Call3` calls for delegatecall mode. + /// All `value` fields must be zero; delegatecall mode does not support per-call value routing. function _toCall3Array(IMulticall3.Call3Value[] memory calls) private pure returns (CBMulticall.Call3[] memory) { CBMulticall.Call3[] memory dCalls = new CBMulticall.Call3[](calls.length); for (uint256 i; i < calls.length; i++) { + // Delegatecall mode relies on the Safe's `msg.value` handling rather than per-call value routing. + // Enforce that no per-call value is specified when using delegatecall mode. + require(calls[i].value == 0, "MultisigScript: delegatecall mode does not support call value"); dCalls[i] = CBMulticall.Call3({ target: calls[i].target, allowFailure: calls[i].allowFailure, callData: calls[i].callData }); diff --git a/src/utils/CBMulticall.sol b/src/utils/CBMulticall.sol index 1fa0edc..7718ad8 100644 --- a/src/utils/CBMulticall.sol +++ b/src/utils/CBMulticall.sol @@ -131,6 +131,8 @@ contract CBMulticall { assembly { // Revert if the call fails and failure is not allowed // `allowFailure := calldataload(add(calli, 0x20))` and `success := mload(result)` + // NOTE: We intentionally preserve the original Multicall3 error string + // ("Multicall3: call failed") for compatibility with existing tooling. if iszero(or(calldataload(add(calli, 0x20)), mload(result))) { // set "Error(string)" signature: bytes32(bytes4(keccak256("Error(string)"))) mstore(0x00, 0x08c379a000000000000000000000000000000000000000000000000000000000) @@ -167,6 +169,8 @@ contract CBMulticall { assembly { // Revert if the call fails and failure is not allowed // `allowFailure := calldataload(add(calli, 0x20))` and `success := mload(result)` + // NOTE: We intentionally preserve the original Multicall3 error string + // ("Multicall3: call failed") for compatibility with existing tooling. if iszero(or(calldataload(add(calli, 0x20)), mload(result))) { // set "Error(string)" signature: bytes32(bytes4(keccak256("Error(string)"))) mstore(0x00, 0x08c379a000000000000000000000000000000000000000000000000000000000) diff --git a/test/universal/MultisigScriptDelegateCall.t.sol b/test/universal/MultisigScriptDelegateCall.t.sol new file mode 100644 index 0000000..9ded510 --- /dev/null +++ b/test/universal/MultisigScriptDelegateCall.t.sol @@ -0,0 +1,111 @@ +// 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; + } +} + + From 42add7637ce29045e4390a86fa74043503cc3734 Mon Sep 17 00:00:00 2001 From: Jack Chuma Date: Mon, 17 Nov 2025 17:08:49 -0500 Subject: [PATCH 14/14] forge fmt --- test/universal/MultisigScriptDelegateCall.t.sol | 1 - 1 file changed, 1 deletion(-) diff --git a/test/universal/MultisigScriptDelegateCall.t.sol b/test/universal/MultisigScriptDelegateCall.t.sol index 9ded510..57c0644 100644 --- a/test/universal/MultisigScriptDelegateCall.t.sol +++ b/test/universal/MultisigScriptDelegateCall.t.sol @@ -108,4 +108,3 @@ contract MultisigScriptDelegateCallTest is Test, MultisigScript { } } -