diff --git a/README.md b/README.md index 8e9b85b..999c442 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,73 @@ 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 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; +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/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 3d0dd64..e6b164e 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"; @@ -25,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); @@ -163,6 +167,23 @@ library Safe { return ISafeSmartAccount(instance(self).safe).nonce(); } + /// @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 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 +196,217 @@ 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); + 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))); + // 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, 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))); + 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. + /// Non-owners and duplicates are filtered out; provide at least `threshold` unique valid owners. + 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 + /// @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, + 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) { + if (signers.length == 0) { + console.log("[safe-utils] simulation failed: no signers provided"); + return false; + } + 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(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, + ExecTransactionParams({ + to: to, + value: 0, + data: data, + operation: operation, + sender: sorted[0], + signature: signatures, + nonce: nonce + }) + ); + } + + /// @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; + 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]); + } + } + 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 +504,32 @@ 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 +611,31 @@ 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..f4d99f2 --- /dev/null +++ b/src/SafeScriptBase.sol @@ -0,0 +1,196 @@ +// 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 _initializeSafe() (single-element) or _initializeSafeMultiSig() (one or more). + 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"); + signers.push(signer); + 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; + } +} diff --git a/test/SafeSimulation.t.sol b/test/SafeSimulation.t.sol new file mode 100644 index 0000000..0923a44 --- /dev/null +++ b/test/SafeSimulation.t.sol @@ -0,0 +1,269 @@ +// 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_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_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); + 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); + + 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_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 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_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); + 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 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)); + } +}