From 69fe681e37b6b9f1ea57472ba0cae2092b773150 Mon Sep 17 00:00:00 2001 From: chasebrownn Date: Tue, 12 May 2026 14:28:59 -0700 Subject: [PATCH 01/10] feat: add simulation mode and SafeScriptBase Adds simulation support to safe-utils, allowing Safe transactions to be tested against a local fork without hardware wallet signing or Safe API interaction. Safe.sol additions: - isBroadcastMode() / isSimulationMode(): detect --broadcast flag via vm.isContext, with SAFE_BROADCAST env var override - simulateTransaction(): executes a pre-built tx via vm.prank + execTransaction - simulateTransactionNoSign() / simulateTransactionsNoSign(): no-HW-wallet simulation using vm.store to mark approvedHashes[sender][txHash] = 1 - simulateTransactionMultiSigNoSign() / simulateTransactionsMultiSigNoSign(): multi-sig simulation; signers are sorted ascending as Safe requires - proposeTransactionWithSignature(..., uint256 nonce): additive overload for sequential proposals in one script run (on-chain nonce not yet advanced) - proposeTransactionsWithSignature(..., uint256 nonce): same for batch SafeScriptBase.sol (new file): - Abstract base for forge scripts; auto-detects mode and routes to simulate or propose. Tracks currentNonce across sequential transactions. - _proposeTransaction / _proposeTransactions / _proposeTransactionWithVerification - Single-sig and multi-sig init via SIGNER_ADDRESS / SIGNER_ADDRESS_0..N --- src/Safe.sol | 194 ++++++++++++++++++++++++++++++++++++++++- src/SafeScriptBase.sol | 170 ++++++++++++++++++++++++++++++++++++ 2 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 src/SafeScriptBase.sol diff --git a/src/Safe.sol b/src/Safe.sol index 3d0dd64..25a24b7 100644 --- a/src/Safe.sol +++ b/src/Safe.sol @@ -1,7 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.13; -import {Vm} from "forge-std/Vm.sol"; +import {Vm, VmSafe} from "forge-std/Vm.sol"; +import {console} from "forge-std/console.sol"; import {HTTP} from "solidity-http/HTTP.sol"; import {MultiSendCallOnly} from "safe-smart-account/libraries/MultiSendCallOnly.sol"; import {Enum} from "safe-smart-account/common/Enum.sol"; @@ -29,6 +30,7 @@ library Safe { error MultiSendCallOnlyNotFound(uint256 chainId); error ArrayLengthsMismatch(uint256 a, uint256 b); error ProposeTransactionFailed(uint256 statusCode, string response); + error SimulationFailed(); struct Instance { address safe; @@ -163,6 +165,20 @@ library Safe { return ISafeSmartAccount(instance(self).safe).nonce(); } + /// @notice Returns true when the script is running with --broadcast + function isBroadcastMode() internal view returns (bool) { + try vm.isContext(VmSafe.ForgeContext.ScriptBroadcast) returns (bool isBroadcast) { + return isBroadcast; + } catch { + return vm.envOr("SAFE_BROADCAST", false); + } + } + + /// @notice Returns true when the script is running without --broadcast (dry-run / simulation) + function isSimulationMode() internal view returns (bool) { + return !isBroadcastMode(); + } + function getSafeTxHash( Client storage self, address to, @@ -175,6 +191,147 @@ library Safe { .getTransactionHash(to, value, data, operation, 0, 0, 0, address(0), address(0), nonce); } + // ========================================================================= + // Simulation + // ========================================================================= + + /// @notice Execute a pre-built transaction against the Safe on a local fork + /// @dev Caller is responsible for setting params.signature before calling. + /// Use simulateTransactionNoSign for the common case where no real + /// signature is needed. + function simulateTransaction(Client storage self, ExecTransactionParams memory params) internal returns (bool) { + address safeAddress = instance(self).safe; + console.log("[safe-utils] simulating to %s (nonce %d)", params.to, params.nonce); + /// forge-lint: disable-next-line(unsafe-cheatcode) + vm.prank(params.sender); + try ISafeSmartAccount(safeAddress).execTransaction( + params.to, params.value, params.data, params.operation, + 0, 0, 0, address(0), payable(0), params.signature + ) returns (bool ok) { + if (ok) { console.log("[safe-utils] simulation succeeded"); return true; } + console.log("[safe-utils] simulation failed: execTransaction returned false"); + return false; + } catch (bytes memory revertData) { + console.log("[safe-utils] simulation reverted"); + if (revertData.length > 0) console.logBytes(revertData); + return false; + } + } + + /// @notice Simulate a single Call transaction without a hardware wallet + /// @dev Writes 1 to approvedHashes[sender][txHash] via vm.store so the Safe + /// accepts the synthetic approved-hash signature (r=sender, s=0, v=1). + function simulateTransactionNoSign(Client storage self, address to, bytes memory data, address sender) + internal + returns (bool) + { + return simulateTransactionNoSign(self, to, data, Enum.Operation.Call, sender); + } + + /// @notice Simulate a transaction without a hardware wallet, with explicit operation type + function simulateTransactionNoSign( + Client storage self, + address to, + bytes memory data, + Enum.Operation operation, + address sender + ) internal returns (bool) { + uint256 nonce = getNonce(self); + address safeAddress = instance(self).safe; + bytes32 txHash = getSafeTxHash(self, to, 0, data, operation, nonce); + // Safe's approvedHashes mapping is at storage slot 8. + // approvedHashes[owner][txHash] slot = keccak256(txHash || keccak256(owner || 8)) + bytes32 ownerSlot = keccak256(abi.encode(sender, uint256(8))); + bytes32 approvalSlot = keccak256(abi.encode(txHash, ownerSlot)); + /// forge-lint: disable-next-line(unsafe-cheatcode) + vm.store(safeAddress, approvalSlot, bytes32(uint256(1))); + // Approved-hash signature format: r=owner (padded), s=0, v=1 + bytes memory signature = abi.encodePacked(bytes32(uint256(uint160(sender))), bytes32(0), uint8(1)); + return simulateTransaction(self, ExecTransactionParams({ + to: to, value: 0, data: data, operation: operation, + sender: sender, signature: signature, nonce: nonce + })); + } + + /// @notice Simulate a batch of transactions via MultiSend without a hardware wallet + function simulateTransactionsNoSign(Client storage self, address[] memory targets, bytes[] memory datas, address sender) + internal + returns (bool) + { + (address to, bytes memory data) = getProposeTransactionsTargetAndData(self, targets, datas); + uint256 nonce = getNonce(self); + address safeAddress = instance(self).safe; + bytes32 txHash = getSafeTxHash(self, to, 0, data, Enum.Operation.DelegateCall, nonce); + bytes32 ownerSlot = keccak256(abi.encode(sender, uint256(8))); + bytes32 approvalSlot = keccak256(abi.encode(txHash, ownerSlot)); + /// forge-lint: disable-next-line(unsafe-cheatcode) + vm.store(safeAddress, approvalSlot, bytes32(uint256(1))); + bytes memory signature = abi.encodePacked(bytes32(uint256(uint160(sender))), bytes32(0), uint8(1)); + return simulateTransaction(self, ExecTransactionParams({ + to: to, value: 0, data: data, operation: Enum.Operation.DelegateCall, + sender: sender, signature: signature, nonce: nonce + })); + } + + /// @notice Simulate a single transaction with a multi-sig Safe (threshold > 1) without hardware wallets + /// @dev Signers are sorted ascending as required by Safe's checkNSignatures. + /// Provide at least `threshold` addresses — extras are ignored by the Safe. + function simulateTransactionMultiSigNoSign( + Client storage self, + address to, + bytes memory data, + address[] memory signers + ) internal returns (bool) { + return _simulateMultiSig(self, to, data, Enum.Operation.Call, signers); + } + + /// @notice Simulate a batch of transactions with a multi-sig Safe without hardware wallets + function simulateTransactionsMultiSigNoSign( + Client storage self, + address[] memory targets, + bytes[] memory datas, + address[] memory signers + ) internal returns (bool) { + (address to, bytes memory data) = getProposeTransactionsTargetAndData(self, targets, datas); + return _simulateMultiSig(self, to, data, Enum.Operation.DelegateCall, signers); + } + + function _simulateMultiSig( + Client storage self, + address to, + bytes memory data, + Enum.Operation operation, + address[] memory signers + ) private returns (bool) { + uint256 nonce = getNonce(self); + address safeAddress = instance(self).safe; + bytes32 txHash = getSafeTxHash(self, to, 0, data, operation, nonce); + address[] memory sorted = _sortAddresses(signers); + bytes memory signatures; + for (uint256 i = 0; i < sorted.length; i++) { + bytes32 ownerSlot = keccak256(abi.encode(sorted[i], uint256(8))); + bytes32 approvalSlot = keccak256(abi.encode(txHash, ownerSlot)); + /// forge-lint: disable-next-line(unsafe-cheatcode) + vm.store(safeAddress, approvalSlot, bytes32(uint256(1))); + signatures = abi.encodePacked(signatures, bytes32(uint256(uint160(sorted[i]))), bytes32(0), uint8(1)); + } + return simulateTransaction(self, ExecTransactionParams({ + to: to, value: 0, data: data, operation: operation, + sender: sorted[0], signature: signatures, nonce: nonce + })); + } + + /// @dev Bubble-sort signers ascending. Safe requires signatures ordered by signer address. + function _sortAddresses(address[] memory arr) private pure returns (address[] memory) { + uint256 n = arr.length; + for (uint256 i = 0; i < n; i++) { + for (uint256 j = i + 1; j < n; j++) { + if (uint160(arr[i]) > uint160(arr[j])) (arr[i], arr[j]) = (arr[j], arr[i]); + } + } + return arr; + } + // https://github.com/safe-global/safe-core-sdk/blob/r60/packages/api-kit/src/SafeApiKit.ts#L574 function proposeTransaction(Client storage self, ExecTransactionParams memory params) internal returns (bytes32) { bytes32 safeTxHash = getSafeTxHash(self, params.to, params.value, params.data, params.operation, params.nonce); @@ -272,6 +429,24 @@ library Safe { return txHash; } + /// @notice Propose a transaction with a precomputed signature and an explicit nonce + /// @dev Use this when proposing multiple transactions in one script run to avoid + /// nonce collisions — the Safe's on-chain nonce only advances on execution, + /// so sequential proposes must supply an incrementing nonce manually. + function proposeTransactionWithSignature( + Client storage self, + address to, + bytes memory data, + address sender, + bytes memory signature, + uint256 nonce + ) internal returns (bytes32 txHash) { + txHash = proposeTransaction(self, ExecTransactionParams({ + to: to, value: 0, data: data, operation: Enum.Operation.Call, + sender: sender, signature: signature, nonce: nonce + })); + } + function getProposeTransactionsTargetAndData(Client storage self, address[] memory targets, bytes[] memory datas) internal view @@ -353,6 +528,23 @@ library Safe { return txHash; } + /// @notice Propose multiple transactions with a precomputed signature and an explicit nonce + /// @dev Same nonce rationale as proposeTransactionWithSignature(..., nonce). + function proposeTransactionsWithSignature( + Client storage self, + address[] memory targets, + bytes[] memory datas, + address sender, + bytes memory signature, + uint256 nonce + ) internal returns (bytes32 txHash) { + (address to, bytes memory data) = getProposeTransactionsTargetAndData(self, targets, datas); + txHash = proposeTransaction(self, ExecTransactionParams({ + to: to, value: 0, data: data, operation: Enum.Operation.DelegateCall, + sender: sender, signature: signature, nonce: nonce + })); + } + function getExecTransactionData(Client storage self, address to, bytes memory data, address sender) internal returns (bytes memory) diff --git a/src/SafeScriptBase.sol b/src/SafeScriptBase.sol new file mode 100644 index 0000000..6cb9a23 --- /dev/null +++ b/src/SafeScriptBase.sol @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Script, console} from "forge-std/Script.sol"; +import {Enum} from "safe-smart-account/common/Enum.sol"; +import {Safe} from "./Safe.sol"; + +/// @title SafeScriptBase +/// @notice Base contract for Foundry scripts that interact with a Gnosis Safe. +/// Automatically detects simulation vs broadcast mode and routes +/// transactions accordingly. +/// +/// SIMULATION (no --broadcast): +/// forge script MyScript.s.sol --rpc-url $RPC_URL --ffi -vvvv +/// - Executes on a local fork via storage manipulation (no HW wallet needed) +/// - Reveals state changes, reverts, and deployment addresses before broadcasting +/// +/// BROADCAST (with --broadcast): +/// forge script MyScript.s.sol --rpc-url $RPC_URL --broadcast --ffi -vvvv +/// - Signs with Ledger/Trezor (set HARDWARE_WALLET=trezor if needed) +/// - Proposes transactions to the Safe Transaction Service API +/// +/// ENVIRONMENT VARIABLES: +/// DEPLOYER_SAFE_ADDRESS - The Safe address +/// SIGNER_ADDRESS - Owner address for single-signer scripts +/// SIGNER_ADDRESS_0, _1, _2 ... - Owner addresses for multi-sig scripts +/// DERIVATION_PATH - HW wallet path, e.g. "m/44'/60'/0'/0/0" +/// HARDWARE_WALLET - "ledger" (default) or "trezor" +abstract contract SafeScriptBase is Script { + using Safe for *; + + Safe.Client internal safe; + address internal deployerSafeAddress; + + /// @dev Primary signer for single-sig scripts; index-0 signer in multi-sig scripts. + address internal signer; + + /// @dev All signers loaded by _initializeSafeMultiSig(). + address[] internal signers; + + string internal derivationPath; + + /// @dev Tracks the nonce for sequential proposes in one script run. + /// The Safe's on-chain nonce only advances on execution, so we + /// increment this manually after each propose/simulate call. + uint256 internal currentNonce; + + bool internal _isSimulation; + + // ------------------------------------------------------------------------- + // Setup + // ------------------------------------------------------------------------- + + /// @notice Initialize for a single-signer Safe. Call from setUp(). + function _initializeSafe() internal { + deployerSafeAddress = vm.envAddress("DEPLOYER_SAFE_ADDRESS"); + safe.initialize(deployerSafeAddress); + signer = vm.envAddress("SIGNER_ADDRESS"); + derivationPath = vm.envOr("DERIVATION_PATH", string("")); + _isSimulation = Safe.isSimulationMode(); + currentNonce = safe.getNonce(); + console.log("[safe-utils] mode: %s | safe: %s | signer: %s", + _isSimulation ? "simulation" : "broadcast", deployerSafeAddress, signer); + } + + /// @notice Initialize for a multi-sig Safe. Reads SIGNER_ADDRESS_0, _1, _2 … from env. + /// Falls back to SIGNER_ADDRESS if no indexed vars are found. + function _initializeSafeMultiSig() internal { + deployerSafeAddress = vm.envAddress("DEPLOYER_SAFE_ADDRESS"); + safe.initialize(deployerSafeAddress); + uint256 i = 0; + while (true) { + address s = vm.envOr(string.concat("SIGNER_ADDRESS_", vm.toString(i)), address(0)); + if (s == address(0)) break; + signers.push(s); + i++; + } + if (signers.length == 0) { + signer = vm.envAddress("SIGNER_ADDRESS"); + signers.push(signer); + } else { + signer = signers[0]; + } + derivationPath = vm.envOr("DERIVATION_PATH", string("")); + _isSimulation = Safe.isSimulationMode(); + currentNonce = safe.getNonce(); + console.log("[safe-utils] mode: %s | safe: %s | signers: %d", + _isSimulation ? "simulation" : "broadcast", deployerSafeAddress, signers.length); + } + + // ------------------------------------------------------------------------- + // Transaction helpers + // ------------------------------------------------------------------------- + + /// @notice Simulate or propose a single transaction. + /// @param description Human-readable label shown in logs. + function _proposeTransaction(address target, bytes memory data, string memory description) + internal + returns (bytes32) + { + console.log("[safe-utils] %s -> %s", description, target); + if (_isSimulation) { + bool ok = signers.length > 1 + ? safe.simulateTransactionMultiSigNoSign(target, data, signers) + : safe.simulateTransactionNoSign(target, data, signer); + if (!ok) revert(string.concat(description, ": simulation failed")); + currentNonce++; + return bytes32(uint256(1)); + } else { + bytes memory sig = safe.sign(target, data, Enum.Operation.Call, signer, currentNonce, derivationPath); + bytes32 txHash = safe.proposeTransactionWithSignature(target, data, signer, sig, currentNonce); + currentNonce++; + console.log("[safe-utils] proposed safeTxHash: %s", vm.toString(txHash)); + return txHash; + } + } + + /// @notice Simulate or propose a single transaction, verifying a contract was deployed at + /// expectedDeployment afterward. Skips if code is already present (idempotent). + function _proposeTransactionWithVerification( + address target, + bytes memory data, + address expectedDeployment, + string memory description + ) internal returns (bytes32) { + if (expectedDeployment.code.length > 0) { + console.log("[safe-utils] %s: already deployed at %s, skipping", description, expectedDeployment); + return bytes32(uint256(2)); + } + bytes32 result = _proposeTransaction(target, data, description); + if (_isSimulation && expectedDeployment.code.length == 0) { + revert(string.concat(description, ": no code at expected deployment address after simulation")); + } + return result; + } + + /// @notice Simulate or propose a batch of transactions via MultiSend. + function _proposeTransactions(address[] memory targets, bytes[] memory datas, string memory description) + internal + returns (bytes32) + { + console.log("[safe-utils] %s (batch: %d txs)", description, targets.length); + if (_isSimulation) { + bool ok = signers.length > 1 + ? safe.simulateTransactionsMultiSigNoSign(targets, datas, signers) + : safe.simulateTransactionsNoSign(targets, datas, signer); + if (!ok) revert(string.concat(description, ": batch simulation failed")); + currentNonce++; + return bytes32(uint256(1)); + } else { + (address to, bytes memory data) = safe.getProposeTransactionsTargetAndData(targets, datas); + bytes memory sig = safe.sign(to, data, Enum.Operation.DelegateCall, signer, currentNonce, derivationPath); + bytes32 txHash = safe.proposeTransactionsWithSignature(targets, datas, signer, sig, currentNonce); + currentNonce++; + console.log("[safe-utils] proposed safeTxHash: %s", vm.toString(txHash)); + return txHash; + } + } + + // ------------------------------------------------------------------------- + // Utility + // ------------------------------------------------------------------------- + + function isSimulation() internal view returns (bool) { return _isSimulation; } + function getSafeNonce() internal view returns (uint256) { return safe.getNonce(); } + function getSafeAddress() internal view returns (address) { return deployerSafeAddress; } + function getSigners() internal view returns (address[] memory) { return signers; } + function getSignerCount() internal view returns (uint256) { return signers.length; } + function isMultiSig() internal view returns (bool) { return signers.length > 1; } +} From 47c086e270f9beb2f30f9fa63c4d030c4ba430bf Mon Sep 17 00:00:00 2001 From: chasebrownn Date: Tue, 12 May 2026 14:43:14 -0700 Subject: [PATCH 02/10] add simulation tests and fix isBroadcastMode env-var precedence Move SAFE_BROADCAST env-var check to execute before vm.isContext so it can override detection in all contexts (not just catch paths). Add test/SafeSimulation.t.sol with 11 tests covering: mode detection, single-signer simulation, batch simulation, multi-sig simulation, and explicit-nonce propose overloads. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Safe.sol | 5 +- test/SafeSimulation.t.sol | 186 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 test/SafeSimulation.t.sol diff --git a/src/Safe.sol b/src/Safe.sol index 25a24b7..9b1b861 100644 --- a/src/Safe.sol +++ b/src/Safe.sol @@ -166,11 +166,14 @@ library Safe { } /// @notice Returns true when the script is running with --broadcast + /// @dev SAFE_BROADCAST env var takes precedence, useful for testing or + /// environments where vm.isContext is unavailable. function isBroadcastMode() internal view returns (bool) { + if (vm.envOr("SAFE_BROADCAST", false)) return true; try vm.isContext(VmSafe.ForgeContext.ScriptBroadcast) returns (bool isBroadcast) { return isBroadcast; } catch { - return vm.envOr("SAFE_BROADCAST", false); + return false; } } diff --git a/test/SafeSimulation.t.sol b/test/SafeSimulation.t.sol new file mode 100644 index 0000000..b5529ba --- /dev/null +++ b/test/SafeSimulation.t.sol @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Test, console} from "forge-std/Test.sol"; +import {Safe} from "../src/Safe.sol"; +import {IWETH} from "./interfaces/IWETH.sol"; +import {Enum} from "safe-smart-account/common/Enum.sol"; + +interface ISafeOwnerManager { + function changeThreshold(uint256 _threshold) external; + function getOwners() external view returns (address[] memory); + function getThreshold() external view returns (uint256); +} + +/// @notice Tests for Safe.sol simulation functions. +/// +/// The Safe at SAFE_ADDRESS is a test Safe on Base whose owners are the +/// three default Foundry test accounts, so no private keys are needed +/// for signature-based tests and no storage hacks are needed for +/// simulation tests (the signers are already legitimate owners). +contract SafeSimulationTest is Test { + using Safe for *; + + Safe.Client safe; + + address constant SAFE_ADDRESS = 0xF3a292Dda3F524EA20b5faF2EE0A1c4abA665e4F; + address constant WETH = 0x4200000000000000000000000000000000000006; + + // Owners of SAFE_ADDRESS — foundry default accounts (threshold=2 on-chain) + address constant SIGNER_1 = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; + address constant SIGNER_2 = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8; + bytes32 constant SIGNER_1_KEY = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80; + + function setUp() public { + vm.setEnv("SAFE_BROADCAST", "false"); // reset any env bleed from prior tests + vm.createSelectFork("https://mainnet.base.org"); + safe.initialize(SAFE_ADDRESS); + // Lower threshold to 1 so single-signer simulation tests work. + // Each test gets a fresh fork snapshot so this does not affect other suites. + vm.prank(SAFE_ADDRESS); + ISafeOwnerManager(SAFE_ADDRESS).changeThreshold(1); + } + + // ------------------------------------------------------------------------- + // Mode detection + // ------------------------------------------------------------------------- + + function test_Safe_modeDetection() public { + vm.setEnv("SAFE_BROADCAST", "false"); + assertTrue(Safe.isSimulationMode()); + assertFalse(Safe.isBroadcastMode()); + + vm.setEnv("SAFE_BROADCAST", "true"); + assertTrue(Safe.isBroadcastMode()); + assertFalse(Safe.isSimulationMode()); + + vm.setEnv("SAFE_BROADCAST", "false"); + assertFalse(Safe.isBroadcastMode()); + assertTrue(Safe.isSimulationMode()); + } + + // ------------------------------------------------------------------------- + // Single-signer simulation + // ------------------------------------------------------------------------- + + function test_Safe_simulateTransactionNoSign_succeeds() public { + // WETH withdraw(0) is a no-op that always succeeds regardless of balance + bool ok = safe.simulateTransactionNoSign(WETH, abi.encodeCall(IWETH.withdraw, (0)), SIGNER_1); + assertTrue(ok); + } + + function test_Safe_simulateTransactionNoSign_returnsFalseOnRevert() public { + // Safe has no WETH, so withdraw(1) reverts — execTransaction returns false + bool ok = safe.simulateTransactionNoSign(WETH, abi.encodeCall(IWETH.withdraw, (1)), SIGNER_1); + assertFalse(ok); + } + + function test_Safe_simulateTransactionNoSign_nonceAdvances() public { + uint256 nonceBefore = safe.getNonce(); + safe.simulateTransactionNoSign(WETH, abi.encodeCall(IWETH.withdraw, (0)), SIGNER_1); + assertEq(safe.getNonce(), nonceBefore + 1); + } + + // ------------------------------------------------------------------------- + // Batch simulation + // ------------------------------------------------------------------------- + + function test_Safe_simulateTransactionsNoSign_succeeds() public { + address[] memory targets = new address[](2); + bytes[] memory datas = new bytes[](2); + targets[0] = WETH; + datas[0] = abi.encodeCall(IWETH.withdraw, (0)); + targets[1] = WETH; + datas[1] = abi.encodeCall(IWETH.withdraw, (0)); + + bool ok = safe.simulateTransactionsNoSign(targets, datas, SIGNER_1); + assertTrue(ok); + } + + function test_Safe_simulateTransactionsNoSign_returnsFalseOnRevert() public { + address[] memory targets = new address[](2); + bytes[] memory datas = new bytes[](2); + targets[0] = WETH; + datas[0] = abi.encodeCall(IWETH.withdraw, (0)); + targets[1] = WETH; + datas[1] = abi.encodeCall(IWETH.withdraw, (1)); // fails — no WETH balance + + bool ok = safe.simulateTransactionsNoSign(targets, datas, SIGNER_1); + assertFalse(ok); + } + + // ------------------------------------------------------------------------- + // Multi-sig simulation + // ------------------------------------------------------------------------- + + function test_Safe_simulateTransactionMultiSigNoSign_succeeds() public { + vm.prank(SAFE_ADDRESS); + ISafeOwnerManager(SAFE_ADDRESS).changeThreshold(2); + + address[] memory signers = new address[](2); + signers[0] = SIGNER_1; + signers[1] = SIGNER_2; + + bool ok = safe.simulateTransactionMultiSigNoSign(WETH, abi.encodeCall(IWETH.withdraw, (0)), signers); + assertTrue(ok); + } + + function test_Safe_simulateTransactionMultiSigNoSign_returnsFalseWithInsufficientSigners() public { + // threshold=2 but only 1 signer provided — Safe's checkNSignatures should reject + vm.prank(SAFE_ADDRESS); + ISafeOwnerManager(SAFE_ADDRESS).changeThreshold(2); + + address[] memory signers = new address[](1); + signers[0] = SIGNER_1; + + bool ok = safe.simulateTransactionMultiSigNoSign(WETH, abi.encodeCall(IWETH.withdraw, (0)), signers); + assertFalse(ok); + } + + function test_Safe_simulateTransactionsMultiSigNoSign_succeeds() public { + vm.prank(SAFE_ADDRESS); + ISafeOwnerManager(SAFE_ADDRESS).changeThreshold(2); + + address[] memory targets = new address[](2); + bytes[] memory datas = new bytes[](2); + targets[0] = WETH; + datas[0] = abi.encodeCall(IWETH.withdraw, (0)); + targets[1] = WETH; + datas[1] = abi.encodeCall(IWETH.withdraw, (0)); + + address[] memory signers = new address[](2); + signers[0] = SIGNER_1; + signers[1] = SIGNER_2; + + bool ok = safe.simulateTransactionsMultiSigNoSign(targets, datas, signers); + assertTrue(ok); + } + + // ------------------------------------------------------------------------- + // Nonce overloads + // ------------------------------------------------------------------------- + + function test_Safe_proposeTransactionWithSignature_explicitNonce() public { + uint256 nonce = safe.getNonce(); + vm.rememberKey(uint256(SIGNER_1_KEY)); + bytes memory sig = safe.sign(WETH, abi.encodeCall(IWETH.withdraw, (0)), Enum.Operation.Call, SIGNER_1, nonce, ""); + // Should not revert — verifies the explicit-nonce overload exists and builds correct params + safe.proposeTransactionWithSignature(WETH, abi.encodeCall(IWETH.withdraw, (0)), SIGNER_1, sig, nonce); + } + + function test_Safe_proposeTransactionsWithSignature_explicitNonce() public { + uint256 nonce = safe.getNonce(); + vm.rememberKey(uint256(SIGNER_1_KEY)); + + address[] memory targets = new address[](2); + bytes[] memory datas = new bytes[](2); + targets[0] = WETH; + datas[0] = abi.encodeCall(IWETH.withdraw, (0)); + targets[1] = WETH; + datas[1] = abi.encodeCall(IWETH.withdraw, (0)); + + (address to, bytes memory data) = safe.getProposeTransactionsTargetAndData(targets, datas); + bytes memory sig = safe.sign(to, data, Enum.Operation.DelegateCall, SIGNER_1, nonce, ""); + safe.proposeTransactionsWithSignature(targets, datas, SIGNER_1, sig, nonce); + } +} From 7d5871b54b31df8a73787a33b40854df329eaae5 Mon Sep 17 00:00:00 2001 From: chasebrownn Date: Tue, 12 May 2026 15:06:39 -0700 Subject: [PATCH 03/10] remove unused SimulationFailed error; add simulation README section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SimulationFailed was declared but never used — simulate functions return bool by design so callers decide how to handle failures. Add README docs for simulateTransactionNoSign, multi-sig variants, SafeScriptBase, mode detection helpers, and the SAFE_BROADCAST env var. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/Safe.sol | 1 - 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8e9b85b..872a20a 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,72 @@ safe.proposeTransactionsWithSignature(targets, datas, sender, signature); **⚠️ Important**: Batch transactions require `Enum.Operation.DelegateCall` (not `Call`). Using `Call` causes signature validation errors. +#### Simulation (no hardware wallet required) + +Simulate transactions against a local fork before broadcasting. No signing device is needed — the library writes directly to the Safe's `approvedHashes` storage slot. + +```solidity +// Single transaction +bool ok = safe.simulateTransactionNoSign(target, data, signerAddress); + +// Batch +bool ok = safe.simulateTransactionsNoSign(targets, datas, signerAddress); + +// Multi-sig (threshold > 1) — pass all required signers +address[] memory signers = new address[](2); +signers[0] = signer1; +signers[1] = signer2; +bool ok = safe.simulateTransactionMultiSigNoSign(target, data, signers); +bool ok = safe.simulateTransactionsMultiSigNoSign(targets, datas, signers); +``` + +All simulate functions return `true` on success and `false` on revert — they never throw, so you can inspect failures without aborting the script. + +Mode detection helpers let you branch between simulation and broadcast in one script: + +```solidity +if (Safe.isSimulationMode()) { /* fork run */ } +if (Safe.isBroadcastMode()) { /* --broadcast run */ } +``` + +Set the `SAFE_BROADCAST` environment variable to `true` to force broadcast mode regardless of the `--broadcast` flag (useful in CI). + +#### SafeScriptBase + +`SafeScriptBase` is an abstract Foundry script that wires up simulation and broadcast automatically. Extend it instead of writing the routing logic yourself: + +```solidity +import {SafeScriptBase} from "safe-utils/SafeScriptBase.sol"; + +contract MyScript is SafeScriptBase { + function run() external { + _initializeSafe(); // reads DEPLOYER_SAFE_ADDRESS, SIGNER_ADDRESS, DERIVATION_PATH + + // Routes to simulate (no --broadcast) or propose (--broadcast) automatically + _proposeTransaction(target, data, "Description shown in logs"); + + // Batch + _proposeTransactions(targets, datas, "Batch description"); + + // Deployment — skips if code already present, reverts if missing after simulation + _proposeTransactionWithVerification(factory, deployData, expectedAddr, "Deploy Foo"); + } +} +``` + +For multi-sig scripts use `_initializeSafeMultiSig()` instead, which reads `SIGNER_ADDRESS_0`, `SIGNER_ADDRESS_1`, … from the environment. + +**Environment variables for `SafeScriptBase`:** + +| Variable | Description | +| --- | --- | +| `DEPLOYER_SAFE_ADDRESS` | The Safe address | +| `SIGNER_ADDRESS` | Owner address (single-sig) | +| `SIGNER_ADDRESS_0`, `_1`, … | Owner addresses (multi-sig) | +| `DERIVATION_PATH` | HW wallet path, e.g. `m/44'/60'/0'/0/0` | +| `HARDWARE_WALLET` | `ledger` (default) or `trezor` | +| `SAFE_BROADCAST` | Set to `true` to force broadcast mode | + ### Requirements - Foundry with FFI enabled: diff --git a/src/Safe.sol b/src/Safe.sol index 9b1b861..424a696 100644 --- a/src/Safe.sol +++ b/src/Safe.sol @@ -30,7 +30,6 @@ library Safe { error MultiSendCallOnlyNotFound(uint256 chainId); error ArrayLengthsMismatch(uint256 a, uint256 b); error ProposeTransactionFailed(uint256 statusCode, string response); - error SimulationFailed(); struct Instance { address safe; From 98fc29c2bd88117d293eaf7f242cf1841c848a16 Mon Sep 17 00:00:00 2001 From: chasebrownn Date: Thu, 14 May 2026 09:15:36 -0700 Subject: [PATCH 04/10] chore: rebase onto upstream and apply forge fmt --- src/Safe.sol | 112 ++++++++++++++++++++++++++++---------- src/SafeScriptBase.sol | 45 +++++++++++---- test/SafeSimulation.t.sol | 3 +- 3 files changed, 119 insertions(+), 41 deletions(-) diff --git a/src/Safe.sol b/src/Safe.sol index 424a696..ffded28 100644 --- a/src/Safe.sol +++ b/src/Safe.sol @@ -206,16 +206,32 @@ library Safe { console.log("[safe-utils] simulating to %s (nonce %d)", params.to, params.nonce); /// forge-lint: disable-next-line(unsafe-cheatcode) vm.prank(params.sender); - try ISafeSmartAccount(safeAddress).execTransaction( - params.to, params.value, params.data, params.operation, - 0, 0, 0, address(0), payable(0), params.signature - ) returns (bool ok) { - if (ok) { console.log("[safe-utils] simulation succeeded"); return true; } + try ISafeSmartAccount(safeAddress) + .execTransaction( + params.to, + params.value, + params.data, + params.operation, + 0, + 0, + 0, + address(0), + payable(0), + params.signature + ) returns ( + bool ok + ) { + if (ok) { + console.log("[safe-utils] simulation succeeded"); + return true; + } console.log("[safe-utils] simulation failed: execTransaction returned false"); return false; } catch (bytes memory revertData) { console.log("[safe-utils] simulation reverted"); - if (revertData.length > 0) console.logBytes(revertData); + if (revertData.length > 0) { + console.logBytes(revertData); + } return false; } } @@ -249,17 +265,21 @@ library Safe { vm.store(safeAddress, approvalSlot, bytes32(uint256(1))); // Approved-hash signature format: r=owner (padded), s=0, v=1 bytes memory signature = abi.encodePacked(bytes32(uint256(uint160(sender))), bytes32(0), uint8(1)); - return simulateTransaction(self, ExecTransactionParams({ - to: to, value: 0, data: data, operation: operation, - sender: sender, signature: signature, nonce: nonce - })); + return simulateTransaction( + self, + ExecTransactionParams({ + to: to, value: 0, data: data, operation: operation, sender: sender, signature: signature, nonce: nonce + }) + ); } /// @notice Simulate a batch of transactions via MultiSend without a hardware wallet - function simulateTransactionsNoSign(Client storage self, address[] memory targets, bytes[] memory datas, address sender) - internal - returns (bool) - { + function simulateTransactionsNoSign( + Client storage self, + address[] memory targets, + bytes[] memory datas, + address sender + ) internal returns (bool) { (address to, bytes memory data) = getProposeTransactionsTargetAndData(self, targets, datas); uint256 nonce = getNonce(self); address safeAddress = instance(self).safe; @@ -269,10 +289,18 @@ library Safe { /// forge-lint: disable-next-line(unsafe-cheatcode) vm.store(safeAddress, approvalSlot, bytes32(uint256(1))); bytes memory signature = abi.encodePacked(bytes32(uint256(uint160(sender))), bytes32(0), uint8(1)); - return simulateTransaction(self, ExecTransactionParams({ - to: to, value: 0, data: data, operation: Enum.Operation.DelegateCall, - sender: sender, signature: signature, nonce: nonce - })); + return simulateTransaction( + self, + ExecTransactionParams({ + to: to, + value: 0, + data: data, + operation: Enum.Operation.DelegateCall, + sender: sender, + signature: signature, + nonce: nonce + }) + ); } /// @notice Simulate a single transaction with a multi-sig Safe (threshold > 1) without hardware wallets @@ -317,10 +345,18 @@ library Safe { vm.store(safeAddress, approvalSlot, bytes32(uint256(1))); signatures = abi.encodePacked(signatures, bytes32(uint256(uint160(sorted[i]))), bytes32(0), uint8(1)); } - return simulateTransaction(self, ExecTransactionParams({ - to: to, value: 0, data: data, operation: operation, - sender: sorted[0], signature: signatures, nonce: nonce - })); + return simulateTransaction( + self, + ExecTransactionParams({ + to: to, + value: 0, + data: data, + operation: operation, + sender: sorted[0], + signature: signatures, + nonce: nonce + }) + ); } /// @dev Bubble-sort signers ascending. Safe requires signatures ordered by signer address. @@ -443,10 +479,18 @@ library Safe { bytes memory signature, uint256 nonce ) internal returns (bytes32 txHash) { - txHash = proposeTransaction(self, ExecTransactionParams({ - to: to, value: 0, data: data, operation: Enum.Operation.Call, - sender: sender, signature: signature, nonce: nonce - })); + txHash = proposeTransaction( + self, + ExecTransactionParams({ + to: to, + value: 0, + data: data, + operation: Enum.Operation.Call, + sender: sender, + signature: signature, + nonce: nonce + }) + ); } function getProposeTransactionsTargetAndData(Client storage self, address[] memory targets, bytes[] memory datas) @@ -541,10 +585,18 @@ library Safe { uint256 nonce ) internal returns (bytes32 txHash) { (address to, bytes memory data) = getProposeTransactionsTargetAndData(self, targets, datas); - txHash = proposeTransaction(self, ExecTransactionParams({ - to: to, value: 0, data: data, operation: Enum.Operation.DelegateCall, - sender: sender, signature: signature, nonce: nonce - })); + txHash = proposeTransaction( + self, + ExecTransactionParams({ + to: to, + value: 0, + data: data, + operation: Enum.Operation.DelegateCall, + sender: sender, + signature: signature, + nonce: nonce + }) + ); } function getExecTransactionData(Client storage self, address to, bytes memory data, address sender) diff --git a/src/SafeScriptBase.sol b/src/SafeScriptBase.sol index 6cb9a23..5756984 100644 --- a/src/SafeScriptBase.sol +++ b/src/SafeScriptBase.sol @@ -59,8 +59,12 @@ abstract contract SafeScriptBase is Script { derivationPath = vm.envOr("DERIVATION_PATH", string("")); _isSimulation = Safe.isSimulationMode(); currentNonce = safe.getNonce(); - console.log("[safe-utils] mode: %s | safe: %s | signer: %s", - _isSimulation ? "simulation" : "broadcast", deployerSafeAddress, signer); + console.log( + "[safe-utils] mode: %s | safe: %s | signer: %s", + _isSimulation ? "simulation" : "broadcast", + deployerSafeAddress, + signer + ); } /// @notice Initialize for a multi-sig Safe. Reads SIGNER_ADDRESS_0, _1, _2 … from env. @@ -84,8 +88,12 @@ abstract contract SafeScriptBase is Script { derivationPath = vm.envOr("DERIVATION_PATH", string("")); _isSimulation = Safe.isSimulationMode(); currentNonce = safe.getNonce(); - console.log("[safe-utils] mode: %s | safe: %s | signers: %d", - _isSimulation ? "simulation" : "broadcast", deployerSafeAddress, signers.length); + console.log( + "[safe-utils] mode: %s | safe: %s | signers: %d", + _isSimulation ? "simulation" : "broadcast", + deployerSafeAddress, + signers.length + ); } // ------------------------------------------------------------------------- @@ -161,10 +169,27 @@ abstract contract SafeScriptBase is Script { // Utility // ------------------------------------------------------------------------- - function isSimulation() internal view returns (bool) { return _isSimulation; } - function getSafeNonce() internal view returns (uint256) { return safe.getNonce(); } - function getSafeAddress() internal view returns (address) { return deployerSafeAddress; } - function getSigners() internal view returns (address[] memory) { return signers; } - function getSignerCount() internal view returns (uint256) { return signers.length; } - function isMultiSig() internal view returns (bool) { return signers.length > 1; } + function isSimulation() internal view returns (bool) { + return _isSimulation; + } + + function getSafeNonce() internal view returns (uint256) { + return safe.getNonce(); + } + + function getSafeAddress() internal view returns (address) { + return deployerSafeAddress; + } + + function getSigners() internal view returns (address[] memory) { + return signers; + } + + function getSignerCount() internal view returns (uint256) { + return signers.length; + } + + function isMultiSig() internal view returns (bool) { + return signers.length > 1; + } } diff --git a/test/SafeSimulation.t.sol b/test/SafeSimulation.t.sol index b5529ba..e020e48 100644 --- a/test/SafeSimulation.t.sol +++ b/test/SafeSimulation.t.sol @@ -163,7 +163,8 @@ contract SafeSimulationTest is Test { function test_Safe_proposeTransactionWithSignature_explicitNonce() public { uint256 nonce = safe.getNonce(); vm.rememberKey(uint256(SIGNER_1_KEY)); - bytes memory sig = safe.sign(WETH, abi.encodeCall(IWETH.withdraw, (0)), Enum.Operation.Call, SIGNER_1, nonce, ""); + bytes memory sig = + safe.sign(WETH, abi.encodeCall(IWETH.withdraw, (0)), Enum.Operation.Call, SIGNER_1, nonce, ""); // Should not revert — verifies the explicit-nonce overload exists and builds correct params safe.proposeTransactionWithSignature(WETH, abi.encodeCall(IWETH.withdraw, (0)), SIGNER_1, sig, nonce); } From cccda03def044b0b5d5ebaa568223a52baacb1b5 Mon Sep 17 00:00:00 2001 From: chasebrownn Date: Thu, 14 May 2026 09:23:12 -0700 Subject: [PATCH 05/10] signing with differing nonces in tests --- test/SafeSimulation.t.sol | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/test/SafeSimulation.t.sol b/test/SafeSimulation.t.sol index e020e48..c2f1e35 100644 --- a/test/SafeSimulation.t.sol +++ b/test/SafeSimulation.t.sol @@ -160,17 +160,23 @@ contract SafeSimulationTest is Test { // Nonce overloads // ------------------------------------------------------------------------- - function test_Safe_proposeTransactionWithSignature_explicitNonce() public { - uint256 nonce = safe.getNonce(); + function test_Safe_sign_explicitNonce_isUsedInSignature() public { + // Signing the same payload with different nonces must produce different signatures. + // This proves the explicit-nonce overload threads the nonce through to the + // SafeTx hash that gets signed (rather than silently using getNonce()). vm.rememberKey(uint256(SIGNER_1_KEY)); - bytes memory sig = - safe.sign(WETH, abi.encodeCall(IWETH.withdraw, (0)), Enum.Operation.Call, SIGNER_1, nonce, ""); - // Should not revert — verifies the explicit-nonce overload exists and builds correct params - safe.proposeTransactionWithSignature(WETH, abi.encodeCall(IWETH.withdraw, (0)), SIGNER_1, sig, nonce); + bytes memory data = abi.encodeCall(IWETH.withdraw, (0)); + + bytes memory sigA = safe.sign(WETH, data, Enum.Operation.Call, SIGNER_1, 100, ""); + bytes memory sigB = safe.sign(WETH, data, Enum.Operation.Call, SIGNER_1, 101, ""); + + assertGt(sigA.length, 0); + assertGt(sigB.length, 0); + assertTrue(keccak256(sigA) != keccak256(sigB)); } - function test_Safe_proposeTransactionsWithSignature_explicitNonce() public { - uint256 nonce = safe.getNonce(); + function test_Safe_sign_explicitNonce_batchOperation() public { + // Same check for the DelegateCall path used by batch proposes. vm.rememberKey(uint256(SIGNER_1_KEY)); address[] memory targets = new address[](2); @@ -181,7 +187,11 @@ contract SafeSimulationTest is Test { datas[1] = abi.encodeCall(IWETH.withdraw, (0)); (address to, bytes memory data) = safe.getProposeTransactionsTargetAndData(targets, datas); - bytes memory sig = safe.sign(to, data, Enum.Operation.DelegateCall, SIGNER_1, nonce, ""); - safe.proposeTransactionsWithSignature(targets, datas, SIGNER_1, sig, nonce); + bytes memory sigA = safe.sign(to, data, Enum.Operation.DelegateCall, SIGNER_1, 100, ""); + bytes memory sigB = safe.sign(to, data, Enum.Operation.DelegateCall, SIGNER_1, 101, ""); + + assertGt(sigA.length, 0); + assertGt(sigB.length, 0); + assertTrue(keccak256(sigA) != keccak256(sigB)); } } From d62a911496149f5c685e10e06bc9ed37c61e9e37 Mon Sep 17 00:00:00 2001 From: chasebrownn Date: Thu, 14 May 2026 10:04:17 -0700 Subject: [PATCH 06/10] Introduced SAFE_APPROVED_HASHES_SLOT as per comment --- src/Safe.sol | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/Safe.sol b/src/Safe.sol index ffded28..f42325d 100644 --- a/src/Safe.sol +++ b/src/Safe.sol @@ -26,6 +26,9 @@ library Safe { address constant MULTI_SEND_CALL_ONLY_ADDRESS_V141_CANONICAL = 0x9641d764fc13c8B624c04430C7356C1C7C8102e2; address constant MULTI_SEND_CALL_ONLY_ADDRESS_V141_ZKSYNC = 0x0408EF011960d02349d50286D20531229BCef773; + // https://github.com/safe-global/safe-smart-account/blob/release/v1.4.1/contracts/libraries/SafeStorage.sol + uint256 constant SAFE_APPROVED_HASHES_SLOT = 8; + error ApiKitUrlNotFound(uint256 chainId); error MultiSendCallOnlyNotFound(uint256 chainId); error ArrayLengthsMismatch(uint256 a, uint256 b); @@ -257,9 +260,7 @@ library Safe { uint256 nonce = getNonce(self); address safeAddress = instance(self).safe; bytes32 txHash = getSafeTxHash(self, to, 0, data, operation, nonce); - // Safe's approvedHashes mapping is at storage slot 8. - // approvedHashes[owner][txHash] slot = keccak256(txHash || keccak256(owner || 8)) - bytes32 ownerSlot = keccak256(abi.encode(sender, uint256(8))); + bytes32 ownerSlot = keccak256(abi.encode(sender, SAFE_APPROVED_HASHES_SLOT)); bytes32 approvalSlot = keccak256(abi.encode(txHash, ownerSlot)); /// forge-lint: disable-next-line(unsafe-cheatcode) vm.store(safeAddress, approvalSlot, bytes32(uint256(1))); @@ -284,7 +285,7 @@ library Safe { uint256 nonce = getNonce(self); address safeAddress = instance(self).safe; bytes32 txHash = getSafeTxHash(self, to, 0, data, Enum.Operation.DelegateCall, nonce); - bytes32 ownerSlot = keccak256(abi.encode(sender, uint256(8))); + bytes32 ownerSlot = keccak256(abi.encode(sender, SAFE_APPROVED_HASHES_SLOT)); bytes32 approvalSlot = keccak256(abi.encode(txHash, ownerSlot)); /// forge-lint: disable-next-line(unsafe-cheatcode) vm.store(safeAddress, approvalSlot, bytes32(uint256(1))); @@ -338,8 +339,8 @@ library Safe { bytes32 txHash = getSafeTxHash(self, to, 0, data, operation, nonce); address[] memory sorted = _sortAddresses(signers); bytes memory signatures; - for (uint256 i = 0; i < sorted.length; i++) { - bytes32 ownerSlot = keccak256(abi.encode(sorted[i], uint256(8))); + for (uint256 i; i < sorted.length; i++) { + bytes32 ownerSlot = keccak256(abi.encode(sorted[i], SAFE_APPROVED_HASHES_SLOT)); bytes32 approvalSlot = keccak256(abi.encode(txHash, ownerSlot)); /// forge-lint: disable-next-line(unsafe-cheatcode) vm.store(safeAddress, approvalSlot, bytes32(uint256(1))); @@ -362,7 +363,7 @@ library Safe { /// @dev Bubble-sort signers ascending. Safe requires signatures ordered by signer address. function _sortAddresses(address[] memory arr) private pure returns (address[] memory) { uint256 n = arr.length; - for (uint256 i = 0; i < n; i++) { + for (uint256 i; i < n; i++) { for (uint256 j = i + 1; j < n; j++) { if (uint160(arr[i]) > uint160(arr[j])) (arr[i], arr[j]) = (arr[j], arr[i]); } From 3dce8c2bea94da95719704b6f5d6db141382d48d Mon Sep 17 00:00:00 2001 From: chasebrownn Date: Thu, 14 May 2026 10:15:32 -0700 Subject: [PATCH 07/10] now checking for empty signers array --- src/Safe.sol | 4 ++++ test/SafeSimulation.t.sol | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/Safe.sol b/src/Safe.sol index f42325d..47dc0cb 100644 --- a/src/Safe.sol +++ b/src/Safe.sol @@ -334,6 +334,10 @@ library Safe { Enum.Operation operation, address[] memory signers ) private returns (bool) { + if (signers.length == 0) { + console.log("[safe-utils] simulation failed: no signers provided"); + return false; + } uint256 nonce = getNonce(self); address safeAddress = instance(self).safe; bytes32 txHash = getSafeTxHash(self, to, 0, data, operation, nonce); diff --git a/test/SafeSimulation.t.sol b/test/SafeSimulation.t.sol index c2f1e35..25f0f3f 100644 --- a/test/SafeSimulation.t.sol +++ b/test/SafeSimulation.t.sol @@ -137,6 +137,24 @@ contract SafeSimulationTest is Test { assertFalse(ok); } + function test_Safe_simulateTransactionMultiSigNoSign_returnsFalseWithEmptySigners() public { + // Empty signer array must return false, not revert with an out-of-bounds panic + address[] memory signers = new address[](0); + bool ok = safe.simulateTransactionMultiSigNoSign(WETH, abi.encodeCall(IWETH.withdraw, (0)), signers); + assertFalse(ok); + } + + function test_Safe_simulateTransactionsMultiSigNoSign_returnsFalseWithEmptySigners() public { + address[] memory targets = new address[](1); + bytes[] memory datas = new bytes[](1); + targets[0] = WETH; + datas[0] = abi.encodeCall(IWETH.withdraw, (0)); + + address[] memory signers = new address[](0); + bool ok = safe.simulateTransactionsMultiSigNoSign(targets, datas, signers); + assertFalse(ok); + } + function test_Safe_simulateTransactionsMultiSigNoSign_succeeds() public { vm.prank(SAFE_ADDRESS); ISafeOwnerManager(SAFE_ADDRESS).changeThreshold(2); From 7b4d64e8f5ca4650531bd7d3eb3b45c03da92138 Mon Sep 17 00:00:00 2001 From: chasebrownn Date: Thu, 14 May 2026 10:22:05 -0700 Subject: [PATCH 08/10] now filtering for owners before sorting signers --- src/ISafeSmartAccount.sol | 2 ++ src/Safe.sol | 30 +++++++++++++++++++++++++++--- test/SafeSimulation.t.sol | 26 ++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/ISafeSmartAccount.sol b/src/ISafeSmartAccount.sol index 667513a..3ef377f 100644 --- a/src/ISafeSmartAccount.sol +++ b/src/ISafeSmartAccount.sol @@ -6,6 +6,8 @@ import {Enum} from "safe-smart-account/common/Enum.sol"; interface ISafeSmartAccount { function nonce() external view returns (uint256); + function isOwner(address owner) external view returns (bool); + function getTransactionHash( address to, uint256 value, diff --git a/src/Safe.sol b/src/Safe.sol index 47dc0cb..246ac29 100644 --- a/src/Safe.sol +++ b/src/Safe.sol @@ -306,7 +306,7 @@ library Safe { /// @notice Simulate a single transaction with a multi-sig Safe (threshold > 1) without hardware wallets /// @dev Signers are sorted ascending as required by Safe's checkNSignatures. - /// Provide at least `threshold` addresses — extras are ignored by the Safe. + /// Non-owners are filtered out; provide at least `threshold` valid owners. function simulateTransactionMultiSigNoSign( Client storage self, address to, @@ -338,10 +338,18 @@ library Safe { console.log("[safe-utils] simulation failed: no signers provided"); return false; } - uint256 nonce = getNonce(self); address safeAddress = instance(self).safe; + // Filter to actual Safe owners. Safe's checkNSignatures only validates the first + // `threshold` signatures (post-sort) and rejects non-owners with GS026, so a + // low-address non-owner could otherwise poison the prefix and break simulation. + address[] memory validOwners = _filterOwners(safeAddress, signers); + if (validOwners.length == 0) { + console.log("[safe-utils] simulation failed: no valid owners in signers"); + return false; + } + uint256 nonce = getNonce(self); bytes32 txHash = getSafeTxHash(self, to, 0, data, operation, nonce); - address[] memory sorted = _sortAddresses(signers); + address[] memory sorted = _sortAddresses(validOwners); bytes memory signatures; for (uint256 i; i < sorted.length; i++) { bytes32 ownerSlot = keccak256(abi.encode(sorted[i], SAFE_APPROVED_HASHES_SLOT)); @@ -364,6 +372,22 @@ library Safe { ); } + /// @dev Return only the addresses in `signers` that are current owners of the Safe. + function _filterOwners(address safeAddress, address[] memory signers) private view returns (address[] memory) { + address[] memory tmp = new address[](signers.length); + uint256 count; + for (uint256 i; i < signers.length; i++) { + if (ISafeSmartAccount(safeAddress).isOwner(signers[i])) { + tmp[count++] = signers[i]; + } + } + address[] memory result = new address[](count); + for (uint256 i; i < count; i++) { + result[i] = tmp[i]; + } + return result; + } + /// @dev Bubble-sort signers ascending. Safe requires signatures ordered by signer address. function _sortAddresses(address[] memory arr) private pure returns (address[] memory) { uint256 n = arr.length; diff --git a/test/SafeSimulation.t.sol b/test/SafeSimulation.t.sol index 25f0f3f..91ace1a 100644 --- a/test/SafeSimulation.t.sol +++ b/test/SafeSimulation.t.sol @@ -137,6 +137,32 @@ contract SafeSimulationTest is Test { assertFalse(ok); } + function test_Safe_simulateTransactionMultiSigNoSign_filtersNonOwnerExtras() public { + // Pass a low-address non-owner alongside two real owners. Without filtering, + // sorting would put the non-owner first and Safe's checkNSignatures would + // reject it with GS026. Filtering should drop it and let the sim succeed. + vm.prank(SAFE_ADDRESS); + ISafeOwnerManager(SAFE_ADDRESS).changeThreshold(2); + + address lowAddressNonOwner = address(0x1); // sorts before any real owner + address[] memory signers = new address[](3); + signers[0] = lowAddressNonOwner; + signers[1] = SIGNER_1; + signers[2] = SIGNER_2; + + bool ok = safe.simulateTransactionMultiSigNoSign(WETH, abi.encodeCall(IWETH.withdraw, (0)), signers); + assertTrue(ok); + } + + function test_Safe_simulateTransactionMultiSigNoSign_returnsFalseWithOnlyNonOwners() public { + address[] memory signers = new address[](2); + signers[0] = address(0x1); + signers[1] = address(0x2); + + bool ok = safe.simulateTransactionMultiSigNoSign(WETH, abi.encodeCall(IWETH.withdraw, (0)), signers); + assertFalse(ok); + } + function test_Safe_simulateTransactionMultiSigNoSign_returnsFalseWithEmptySigners() public { // Empty signer array must return false, not revert with an out-of-bounds panic address[] memory signers = new address[](0); From e06448edaa0eb99defbc4f2d9e85f44c60a0fcd3 Mon Sep 17 00:00:00 2001 From: chasebrownn Date: Thu, 14 May 2026 10:35:20 -0700 Subject: [PATCH 09/10] pushing single signer to signers array --- src/SafeScriptBase.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/src/SafeScriptBase.sol b/src/SafeScriptBase.sol index 5756984..1012912 100644 --- a/src/SafeScriptBase.sol +++ b/src/SafeScriptBase.sol @@ -56,6 +56,7 @@ abstract contract SafeScriptBase is Script { deployerSafeAddress = vm.envAddress("DEPLOYER_SAFE_ADDRESS"); safe.initialize(deployerSafeAddress); signer = vm.envAddress("SIGNER_ADDRESS"); + signers.push(signer); derivationPath = vm.envOr("DERIVATION_PATH", string("")); _isSimulation = Safe.isSimulationMode(); currentNonce = safe.getNonce(); From be90175bfd2902cf08505cffab469e58b0fbf24b Mon Sep 17 00:00:00 2001 From: chasebrownn Date: Thu, 14 May 2026 10:39:48 -0700 Subject: [PATCH 10/10] documentation updates and signer duplicate handling --- README.md | 3 ++- src/Safe.sol | 10 +++++++++- src/SafeScriptBase.sol | 2 +- test/SafeSimulation.t.sol | 28 ++++++++++++++++++++++++++++ 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 872a20a..999c442 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,8 @@ bool ok = safe.simulateTransactionNoSign(target, data, signerAddress); // Batch bool ok = safe.simulateTransactionsNoSign(targets, datas, signerAddress); -// Multi-sig (threshold > 1) — pass all required signers +// Multi-sig (threshold > 1) — pass at least `threshold` valid owner addresses. +// Non-owners and duplicates in the array are silently filtered out. address[] memory signers = new address[](2); signers[0] = signer1; signers[1] = signer2; diff --git a/src/Safe.sol b/src/Safe.sol index 246ac29..e6b164e 100644 --- a/src/Safe.sol +++ b/src/Safe.sol @@ -306,7 +306,7 @@ library Safe { /// @notice Simulate a single transaction with a multi-sig Safe (threshold > 1) without hardware wallets /// @dev Signers are sorted ascending as required by Safe's checkNSignatures. - /// Non-owners are filtered out; provide at least `threshold` valid owners. + /// Non-owners and duplicates are filtered out; provide at least `threshold` unique valid owners. function simulateTransactionMultiSigNoSign( Client storage self, address to, @@ -317,6 +317,8 @@ library Safe { } /// @notice Simulate a batch of transactions with a multi-sig Safe without hardware wallets + /// @dev Signers are sorted ascending as required by Safe's checkNSignatures. + /// Non-owners and duplicates are filtered out; provide at least `threshold` unique valid owners. function simulateTransactionsMultiSigNoSign( Client storage self, address[] memory targets, @@ -351,12 +353,18 @@ library Safe { bytes32 txHash = getSafeTxHash(self, to, 0, data, operation, nonce); address[] memory sorted = _sortAddresses(validOwners); bytes memory signatures; + // Skip duplicates: Safe's checkNSignatures requires strictly ascending owners, + // so a repeated address (e.g. typo in SIGNER_ADDRESS_*) would otherwise fail. + // address(0) is never a valid Safe owner, so the initial value is safe. + address lastSigner = address(0); for (uint256 i; i < sorted.length; i++) { + if (sorted[i] == lastSigner) continue; bytes32 ownerSlot = keccak256(abi.encode(sorted[i], SAFE_APPROVED_HASHES_SLOT)); bytes32 approvalSlot = keccak256(abi.encode(txHash, ownerSlot)); /// forge-lint: disable-next-line(unsafe-cheatcode) vm.store(safeAddress, approvalSlot, bytes32(uint256(1))); signatures = abi.encodePacked(signatures, bytes32(uint256(uint160(sorted[i]))), bytes32(0), uint8(1)); + lastSigner = sorted[i]; } return simulateTransaction( self, diff --git a/src/SafeScriptBase.sol b/src/SafeScriptBase.sol index 1012912..f4d99f2 100644 --- a/src/SafeScriptBase.sol +++ b/src/SafeScriptBase.sol @@ -35,7 +35,7 @@ abstract contract SafeScriptBase is Script { /// @dev Primary signer for single-sig scripts; index-0 signer in multi-sig scripts. address internal signer; - /// @dev All signers loaded by _initializeSafeMultiSig(). + /// @dev All signers loaded by _initializeSafe() (single-element) or _initializeSafeMultiSig() (one or more). address[] internal signers; string internal derivationPath; diff --git a/test/SafeSimulation.t.sol b/test/SafeSimulation.t.sol index 91ace1a..0923a44 100644 --- a/test/SafeSimulation.t.sol +++ b/test/SafeSimulation.t.sol @@ -163,6 +163,34 @@ contract SafeSimulationTest is Test { assertFalse(ok); } + function test_Safe_simulateTransactionMultiSigNoSign_dedupesDuplicateSigners() public { + // Duplicates of a valid owner should be dropped, leaving enough unique + // signers to satisfy the threshold. + vm.prank(SAFE_ADDRESS); + ISafeOwnerManager(SAFE_ADDRESS).changeThreshold(2); + + address[] memory signers = new address[](3); + signers[0] = SIGNER_1; + signers[1] = SIGNER_1; // typo: duplicate of SIGNER_1 + signers[2] = SIGNER_2; + + bool ok = safe.simulateTransactionMultiSigNoSign(WETH, abi.encodeCall(IWETH.withdraw, (0)), signers); + assertTrue(ok); + } + + function test_Safe_simulateTransactionMultiSigNoSign_returnsFalseWhenDedupLeavesTooFew() public { + // Two copies of the same owner is still only 1 unique signer; threshold 2 cannot be met. + vm.prank(SAFE_ADDRESS); + ISafeOwnerManager(SAFE_ADDRESS).changeThreshold(2); + + address[] memory signers = new address[](2); + signers[0] = SIGNER_1; + signers[1] = SIGNER_1; + + bool ok = safe.simulateTransactionMultiSigNoSign(WETH, abi.encodeCall(IWETH.withdraw, (0)), signers); + assertFalse(ok); + } + function test_Safe_simulateTransactionMultiSigNoSign_returnsFalseWithEmptySigners() public { // Empty signer array must return false, not revert with an out-of-bounds panic address[] memory signers = new address[](0);