diff --git a/README.md b/README.md index 4b05629..0688aba 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,22 @@ The second step is to take the value for the returned `bytes` and provide them w safe.proposeTransactionWithSignature(weth, abi.encodeCall(IWETH.withdraw, (0)), sender, signature); ``` +#### Batch transactions + +```solidity +safe.proposeTransactions(targets, datas, sender, "m/44'/60'/0'/0/0"); +``` + +For pre-computed signatures with hardware wallets: + +```solidity +(address to, bytes memory data) = safe.getProposeTransactionsTargetAndData(targets, datas); +bytes memory signature = safe.sign(to, data, Enum.Operation.DelegateCall, sender, "m/44'/60'/0'/0/0"); +safe.proposeTransactionsWithSignature(targets, datas, sender, signature); +``` + +**⚠️ Important**: Batch transactions require `Enum.Operation.DelegateCall` (not `Call`). Using `Call` causes signature validation errors. + ### Requirements - Foundry with FFI enabled: diff --git a/src/Safe.sol b/src/Safe.sol index cefa1ef..2e3a9d1 100644 --- a/src/Safe.sol +++ b/src/Safe.sol @@ -282,13 +282,21 @@ library Safe { } /// @notice Propose multiple transactions with a precomputed signature - /// @dev This can be used to propose transactions signed with a hardware wallet in a two-step process + /// @dev This can be used to propose transactions signed with a hardware wallet in a two-step process. + /// The signature must be created with Enum.Operation.DelegateCall, as batch transactions use + /// DelegateCall to preserve msg.sender across sub-calls. + /// + /// WARNING: Using Enum.Operation.Call instead of DelegateCall will cause the Safe API to reject + /// your transaction with an error about an incorrect signer address. The signature will be invalid + /// because it was signed with the wrong operation type. /// /// @param self The Safe client /// @param targets The list of target addresses for the transactions /// @param datas The list of data payloads for the transactions /// @param sender The address of the account that is proposing the transactions - /// @param signature The precomputed signature for the batch of transactions, e.g. using {sign} + /// @param signature The precomputed signature for the batch of transactions. MUST be signed with + /// Enum.Operation.DelegateCall (use {sign} with DelegateCall operation). + /// Signing with Call instead of DelegateCall will result in signature validation failure. /// @return txHash The hash of the proposed Safe transaction function proposeTransactionsWithSignature( Client storage self, diff --git a/test/Safe.t.sol b/test/Safe.t.sol index 8a8a971..d26409c 100644 --- a/test/Safe.t.sol +++ b/test/Safe.t.sol @@ -5,6 +5,7 @@ import {Test, console} from "forge-std/Test.sol"; import {Safe} from "../src/Safe.sol"; import {strings} from "solidity-stringutils/strings.sol"; import {IWETH} from "./interfaces/IWETH.sol"; +import {Enum} from "safe-smart-account/common/Enum.sol"; contract SafeTest is Test { using Safe for *; @@ -39,4 +40,28 @@ contract SafeTest is Test { bytes memory data = safe.getExecTransactionData(weth, abi.encodeCall(IWETH.withdraw, (0)), foundrySigner1, ""); console.logBytes(data); } + + function test_Safe_proposeTransactionsWithSignature() public { + address weth = 0x4200000000000000000000000000000000000006; + + // Create batch of transactions + 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)); + + // Get the target and data for signing + (address to, bytes memory data) = safe.getProposeTransactionsTargetAndData(targets, datas); + + // Sign with DelegateCall operation (required for batch transactions) + vm.rememberKey(uint256(foundrySigner1PrivateKey)); + bytes memory signature = safe.sign(to, data, Enum.Operation.DelegateCall, foundrySigner1, ""); + + // Propose transactions with the signature + safe.proposeTransactionsWithSignature(targets, datas, foundrySigner1, signature); + } }