Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,61 @@ 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

For proposing multiple transactions together, use `proposeTransactions`:

```solidity
address[] memory targets = new address[](2);
bytes[] memory datas = new bytes[](2);

targets[0] = address(contract1);
datas[0] = abi.encodeCall(Contract1.someFunction, ());

targets[1] = address(contract2);
datas[1] = abi.encodeCall(Contract2.anotherFunction, ());

safe.proposeTransactions(targets, datas, sender, "m/44'/60'/0'/0/0");
```

If you need to pre-compute the signature for batch transactions (e.g., when using a Ledger), you must:

1. Get the target and data for the batch transaction using `getProposeTransactionsTargetAndData`:

```solidity
(address to, bytes memory data) = safe.getProposeTransactionsTargetAndData(targets, datas);
```

2. Sign the transaction with `Enum.Operation.DelegateCall` (**NOT** `Call`):

```solidity
bytes memory signature = safe.sign(to, data, Enum.Operation.DelegateCall, sender, "m/44'/60'/0'/0/0");
```

3. Propose the transactions with the signature:

```solidity
safe.proposeTransactionsWithSignature(targets, datas, sender, signature);
```

**⚠️ CRITICAL**: Batch transactions **MUST** use `Enum.Operation.DelegateCall` to preserve `msg.sender` across sub-calls.

**Common Mistake**: If you sign with `Enum.Operation.Call` instead of `DelegateCall`, the Safe API will reject your transaction with an error about an incorrect signer address. The signer address reported in the error will not match your actual signing address because the signature will be invalid.

**Correct usage**:
```solidity
// ✓ CORRECT - Use DelegateCall for batch transactions
bytes memory signature = safe.sign(to, data, Enum.Operation.DelegateCall, sender, derivationPath);
safe.proposeTransactionsWithSignature(targets, datas, sender, signature);
```

**Incorrect usage**:
```solidity
// ✗ WRONG - Using Call instead of DelegateCall will cause signature validation to fail
bytes memory signature = safe.sign(to, data, Enum.Operation.Call, sender, derivationPath);
safe.proposeTransactionsWithSignature(targets, datas, sender, signature);
```

Comment on lines +61 to +76
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make this documentation shorter. Instead of 115 lines, make it 30 lines @copilot

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Condensed the batch transactions documentation in commit 2f8ed08. Reduced from 55 lines to 16 lines while preserving the key information about the DelegateCall requirement.

### Requirements

- Foundry with FFI enabled:
Expand Down
12 changes: 10 additions & 2 deletions src/Safe.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions test/Safe.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 *;
Expand Down Expand Up @@ -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);
}
}