Skip to content

Commit 801d788

Browse files
committed
Merge branch 'main' of github.com:Recon-Fuzz/safe-utils
2 parents 4f47c84 + 006ad8f commit 801d788

File tree

6 files changed

+208
-45
lines changed

6 files changed

+208
-45
lines changed

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,38 @@ If you are using ledger, make sure to pass the derivation path as the last argum
4242
safe.proposeTransaction(weth, abi.encodeCall(IWETH.withdraw, (0)), sender, "m/44'/60'/0'/0/0");
4343
```
4444

45+
Proposing a transaction/transactions using a Ledger will also require pre-computing the signature, due to a (current) limitation with forge.
46+
47+
The first step is to pre-compute the signature:
48+
49+
```solidity
50+
bytes memory signature = safe.sign(weth, abi.encodeCall(IWETH.withdraw, (0)), Enum.Operation.Call, sender, "m/44'/60'/0'/0/0");
51+
```
52+
53+
Note that this call will fail if `forge script` is called with the `--ledger` flag, as that would block this library's contracts from utilising the same device. Instead, pass the Ledger derivation path as an argument to the script.
54+
55+
The second step is to take the value for the returned `bytes` and provide them when proposing the transaction:
56+
57+
```solidity
58+
safe.proposeTransactionWithSignature(weth, abi.encodeCall(IWETH.withdraw, (0)), sender, signature);
59+
```
60+
61+
#### Batch transactions
62+
63+
```solidity
64+
safe.proposeTransactions(targets, datas, sender, "m/44'/60'/0'/0/0");
65+
```
66+
67+
For pre-computed signatures with hardware wallets:
68+
69+
```solidity
70+
(address to, bytes memory data) = safe.getProposeTransactionsTargetAndData(targets, datas);
71+
bytes memory signature = safe.sign(to, data, Enum.Operation.DelegateCall, sender, "m/44'/60'/0'/0/0");
72+
safe.proposeTransactionsWithSignature(targets, datas, sender, signature);
73+
```
74+
75+
**⚠️ Important**: Batch transactions require `Enum.Operation.DelegateCall` (not `Call`). Using `Call` causes signature validation errors.
76+
4577
### Requirements
4678

4779
- Foundry with FFI enabled:
@@ -55,6 +87,10 @@ ffi = true
5587

5688
- All `Recon-Fuzz/solidity-http` dependencies
5789

90+
### Demo
91+
92+
https://github.com/Recon-Fuzz/governance-proposals-done-right
93+
5894
### Disclaimer
5995

6096
This code is provided "as is" and has not undergone a formal security audit.

foundry.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@
1111
"lib/solidity-stringutils": {
1212
"rev": "4b2fcc43fa0426e19ce88b1f1ec16f5903a2e461"
1313
}
14-
}
14+
}

remappings.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
safe-smart-account/=lib/safe-smart-account/contracts/
2+
solidity-http/=lib/solidity-http/src/
3+
solidity-stringutils/=lib/solidity-stringutils/src/

src/ISafeSmartAccount.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// SPDX-License-Identifier: MIT
22
pragma solidity ^0.8.13;
33

4-
import {Enum} from "../lib/safe-smart-account/contracts/common/Enum.sol";
4+
import {Enum} from "safe-smart-account/common/Enum.sol";
55

66
interface ISafeSmartAccount {
77
function nonce() external view returns (uint256);

src/Safe.sol

Lines changed: 136 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22
pragma solidity ^0.8.13;
33

44
import {Vm} from "forge-std/Vm.sol";
5-
import {HTTP} from "../lib/solidity-http/src/HTTP.sol";
6-
import {MultiSendCallOnly} from "../lib/safe-smart-account/contracts/libraries/MultiSendCallOnly.sol";
7-
import {Enum} from "../lib/safe-smart-account/contracts/common/Enum.sol";
5+
import {HTTP} from "solidity-http/HTTP.sol";
6+
import {MultiSendCallOnly} from "safe-smart-account/libraries/MultiSendCallOnly.sol";
7+
import {Enum} from "safe-smart-account/common/Enum.sol";
88
import {ISafeSmartAccount} from "./ISafeSmartAccount.sol";
99

1010
library Safe {
1111
using HTTP for *;
1212

13+
/// forge-lint: disable-next-line(screaming-snake-case-const)
1314
Vm constant vm = Vm(address(bytes20(uint160(uint256(keccak256("hevm cheat code"))))));
1415

1516
// https://github.com/safe-global/safe-smart-account/blob/release/v1.4.1/contracts/libraries/SafeStorage.sol
@@ -23,6 +24,7 @@ library Safe {
2324
error ApiKitUrlNotFound(uint256 chainId);
2425
error MultiSendCallOnlyNotFound(uint256 chainId);
2526
error ArrayLengthsMismatch(uint256 a, uint256 b);
27+
error ProposeTransactionFailed(uint256 statusCode, string response);
2628

2729
struct Instance {
2830
address safe;
@@ -56,28 +58,27 @@ library Safe {
5658
self.instances.push();
5759
Instance storage i = self.instances[self.instances.length - 1];
5860
i.safe = safe;
59-
// https://github.com/safe-global/safe-core-sdk/blob/r60/packages/api-kit/src/utils/config.ts
60-
i.urls[1] = "https://safe-transaction-mainnet.safe.global/api";
61-
i.urls[10] = "https://safe-transaction-optimism.safe.global/api";
62-
i.urls[56] = "https://safe-transaction-bsc.safe.global/api";
63-
i.urls[100] = "https://safe-transaction-gnosis-chain.safe.global/api";
64-
i.urls[130] = "https://safe-transaction-unichain.safe.global/api";
65-
i.urls[137] = "https://safe-transaction-polygon.safe.global/api";
66-
i.urls[196] = "https://safe-transaction-xlayer.safe.global/api";
67-
i.urls[324] = "https://safe-transaction-zksync.safe.global/api";
68-
i.urls[480] = "https://safe-transaction-worldchain.safe.global/api";
69-
i.urls[1101] = "https://safe-transaction-zkevm.safe.global/api";
70-
i.urls[5000] = "https://safe-transaction-mantle.safe.global/api";
71-
i.urls[8453] = "https://safe-transaction-base.safe.global/api";
72-
i.urls[42161] = "https://safe-transaction-arbitrum.safe.global/api";
73-
i.urls[42220] = "https://safe-transaction-celo.safe.global/api";
74-
i.urls[43114] = "https://safe-transaction-avalanche.safe.global/api";
75-
i.urls[59144] = "https://safe-transaction-linea.safe.global/api";
76-
i.urls[81457] = "https://safe-transaction-blast.safe.global/api";
77-
i.urls[84532] = "https://safe-transaction-base-sepolia.safe.global/api";
78-
i.urls[534352] = "https://safe-transaction-scroll.safe.global/api";
79-
i.urls[11155111] = "https://safe-transaction-sepolia.safe.global/api";
80-
i.urls[1313161554] = "https://safe-transaction-aurora.safe.global/api";
61+
// https://github.com/safe-global/safe-core-sdk/blob/4d89cb9b1559e4349c323a48a10caf685f7f8c88/packages/api-kit/src/utils/config.ts
62+
i.urls[1] = "https://api.safe.global/tx-service/eth/api";
63+
i.urls[10] = "https://api.safe.global/tx-service/oeth/api";
64+
i.urls[56] = "https://api.safe.global/tx-service/bnb/api";
65+
i.urls[100] = "https://api.safe.global/tx-service/gno/api";
66+
i.urls[130] = "https://api.safe.global/tx-service/unichain/api";
67+
i.urls[137] = "https://api.safe.global/tx-service/pol/api";
68+
i.urls[196] = "https://api.safe.global/tx-service/okb/api";
69+
i.urls[324] = "https://api.safe.global/tx-service/zksync/api";
70+
i.urls[480] = "https://api.safe.global/tx-service/wc/api";
71+
i.urls[1101] = "https://api.safe.global/tx-service/zkevm/api";
72+
i.urls[5000] = "https://api.safe.global/tx-service/mantle/api";
73+
i.urls[8453] = "https://api.safe.global/tx-service/base/api";
74+
i.urls[42161] = "https://api.safe.global/tx-service/arb1/api";
75+
i.urls[42220] = "https://api.safe.global/tx-service/celo/api";
76+
i.urls[43114] = "https://api.safe.global/tx-service/avax/api";
77+
i.urls[59144] = "https://api.safe.global/tx-service/linea/api";
78+
i.urls[84532] = "https://api.safe.global/tx-service/basesep/api";
79+
i.urls[534352] = "https://api.safe.global/tx-service/scr/api";
80+
i.urls[11155111] = "https://api.safe.global/tx-service/sep/api";
81+
i.urls[1313161554] = "https://api.safe.global/tx-service/aurora/api";
8182

8283
// https://github.com/safe-global/safe-deployments/blob/v1.37.32/src/assets/v1.3.0/multi_send_call_only.json
8384
i.multiSendCallOnly[1] = MultiSendCallOnly(MULTI_SEND_CALL_ONLY_ADDRESS_CANONICAL);
@@ -96,7 +97,6 @@ library Safe {
9697
i.multiSendCallOnly[42220] = MultiSendCallOnly(MULTI_SEND_CALL_ONLY_ADDRESS_CANONICAL);
9798
i.multiSendCallOnly[43114] = MultiSendCallOnly(MULTI_SEND_CALL_ONLY_ADDRESS_CANONICAL);
9899
i.multiSendCallOnly[59144] = MultiSendCallOnly(MULTI_SEND_CALL_ONLY_ADDRESS_CANONICAL);
99-
i.multiSendCallOnly[81457] = MultiSendCallOnly(MULTI_SEND_CALL_ONLY_ADDRESS_CANONICAL);
100100
i.multiSendCallOnly[84532] = MultiSendCallOnly(MULTI_SEND_CALL_ONLY_ADDRESS_CANONICAL);
101101
i.multiSendCallOnly[534352] = MultiSendCallOnly(MULTI_SEND_CALL_ONLY_ADDRESS_CANONICAL);
102102
i.multiSendCallOnly[11155111] = MultiSendCallOnly(MULTI_SEND_CALL_ONLY_ADDRESS_CANONICAL);
@@ -138,9 +138,8 @@ library Safe {
138138
Enum.Operation operation,
139139
uint256 nonce
140140
) internal view returns (bytes32) {
141-
return ISafeSmartAccount(instance(self).safe).getTransactionHash(
142-
to, value, data, operation, 0, 0, 0, address(0), address(0), nonce
143-
);
141+
return ISafeSmartAccount(instance(self).safe)
142+
.getTransactionHash(to, value, data, operation, 0, 0, 0, address(0), address(0), nonce);
144143
}
145144

146145
// https://github.com/safe-global/safe-core-sdk/blob/r60/packages/api-kit/src/SafeApiKit.ts#L574
@@ -158,14 +157,21 @@ library Safe {
158157
instance(self).requestBody = vm.serializeUint(".proposeTransaction", "gasPrice", 0);
159158
instance(self).requestBody = vm.serializeUint(".proposeTransaction", "nonce", params.nonce);
160159

161-
instance(self).http.instance().POST(
162-
string.concat(
163-
getApiKitUrl(self, block.chainid),
164-
"/v1/safes/",
165-
vm.toString(instance(self).safe),
166-
"/multisig-transactions/"
167-
)
168-
).withBody(instance(self).requestBody).request();
160+
HTTP.Response memory response = instance(self).http.instance()
161+
.POST(
162+
string.concat(
163+
getApiKitUrl(self, block.chainid),
164+
"/v1/safes/",
165+
vm.toString(instance(self).safe),
166+
"/multisig-transactions/"
167+
)
168+
).withBody(instance(self).requestBody).request();
169+
170+
// The response status should be 2xx, otherwise there was an issue
171+
if (response.status < 200 || response.status >= 300) {
172+
revert ProposeTransactionFailed(response.status, response.data);
173+
}
174+
169175
return safeTxHash;
170176
}
171177

@@ -204,6 +210,35 @@ library Safe {
204210
return proposeTransaction(self, params);
205211
}
206212

213+
/// @notice Propose a transaction with a precomputed signature
214+
/// @dev This can be used to propose transactions signed with a hardware wallet in a two-step process
215+
///
216+
/// @param self The Safe client
217+
/// @param to The target address for the transaction
218+
/// @param data The data payload for the transaction
219+
/// @param sender The address of the account that is proposing the transaction
220+
/// @param signature The precomputed signature for the transaction, e.g. using {sign}
221+
/// @return txHash The hash of the proposed Safe transaction
222+
function proposeTransactionWithSignature(
223+
Client storage self,
224+
address to,
225+
bytes memory data,
226+
address sender,
227+
bytes memory signature
228+
) internal returns (bytes32 txHash) {
229+
ExecTransactionParams memory params = ExecTransactionParams({
230+
to: to,
231+
value: 0,
232+
data: data,
233+
operation: Enum.Operation.Call,
234+
sender: sender,
235+
signature: signature,
236+
nonce: getNonce(self)
237+
});
238+
txHash = proposeTransaction(self, params);
239+
return txHash;
240+
}
241+
207242
function getProposeTransactionsTargetAndData(Client storage self, address[] memory targets, bytes[] memory datas)
208243
internal
209244
view
@@ -246,6 +281,45 @@ library Safe {
246281
return proposeTransaction(self, params);
247282
}
248283

284+
/// @notice Propose multiple transactions with a precomputed signature
285+
/// @dev This can be used to propose transactions signed with a hardware wallet in a two-step process.
286+
/// The signature must be created with Enum.Operation.DelegateCall, as batch transactions use
287+
/// DelegateCall to preserve msg.sender across sub-calls.
288+
///
289+
/// WARNING: Using Enum.Operation.Call instead of DelegateCall will cause the Safe API to reject
290+
/// your transaction with an error about an incorrect signer address. The signature will be invalid
291+
/// because it was signed with the wrong operation type.
292+
///
293+
/// @param self The Safe client
294+
/// @param targets The list of target addresses for the transactions
295+
/// @param datas The list of data payloads for the transactions
296+
/// @param sender The address of the account that is proposing the transactions
297+
/// @param signature The precomputed signature for the batch of transactions. MUST be signed with
298+
/// Enum.Operation.DelegateCall (use {sign} with DelegateCall operation).
299+
/// Signing with Call instead of DelegateCall will result in signature validation failure.
300+
/// @return txHash The hash of the proposed Safe transaction
301+
function proposeTransactionsWithSignature(
302+
Client storage self,
303+
address[] memory targets,
304+
bytes[] memory datas,
305+
address sender,
306+
bytes memory signature
307+
) internal returns (bytes32 txHash) {
308+
(address to, bytes memory data) = getProposeTransactionsTargetAndData(self, targets, datas);
309+
// using DelegateCall to preserve msg.sender across sub-calls
310+
ExecTransactionParams memory params = ExecTransactionParams({
311+
to: to,
312+
value: 0,
313+
data: data,
314+
operation: Enum.Operation.DelegateCall,
315+
sender: sender,
316+
signature: signature,
317+
nonce: getNonce(self)
318+
});
319+
txHash = proposeTransaction(self, params);
320+
return txHash;
321+
}
322+
249323
function getExecTransactionData(Client storage self, address to, bytes memory data, address sender)
250324
internal
251325
returns (bytes memory)
@@ -322,15 +396,25 @@ library Safe {
322396
);
323397
}
324398

399+
/// @notice Prepare the signature for a transaction, using a custom nonce
400+
///
401+
/// @param self The Safe client
402+
/// @param to The target address for the transaction
403+
/// @param data The data payload for the transaction
404+
/// @param operation The operation to perform
405+
/// @param sender The address of the account that is signing the transaction
406+
/// @param nonce The nonce of the transaction
407+
/// @param derivationPath The derivation path for the transaction
408+
/// @return signature The signature for the transaction
325409
function sign(
326410
Client storage self,
327411
address to,
328412
bytes memory data,
329413
Enum.Operation operation,
330414
address sender,
415+
uint256 nonce,
331416
string memory derivationPath
332417
) internal returns (bytes memory) {
333-
uint256 nonce = getNonce(self);
334418
if (bytes(derivationPath).length > 0) {
335419
string[] memory inputs = new string[](8);
336420
inputs[0] = "cast";
@@ -355,12 +439,25 @@ library Safe {
355439
vm.toString(nonce),
356440
',"safeTxGas":"0"},"primaryType":"SafeTx","types":{"SafeTx":[{"name":"to","type":"address"},{"name":"value","type":"uint256"},{"name":"data","type":"bytes"},{"name":"operation","type":"uint8"},{"name":"safeTxGas","type":"uint256"},{"name":"baseGas","type":"uint256"},{"name":"gasPrice","type":"uint256"},{"name":"gasToken","type":"address"},{"name":"refundReceiver","type":"address"},{"name":"nonce","type":"uint256"}]}}'
357441
);
442+
/// forge-lint: disable-next-line(unsafe-cheatcode)
358443
bytes memory output = vm.ffi(inputs);
359444
return output;
360445
} else {
361446
Signature memory sig;
362-
(sig.v, sig.r, sig.s) = vm.sign(sender, getSafeTxHash(self, to, 0, data, Enum.Operation.Call, nonce));
447+
(sig.v, sig.r, sig.s) = vm.sign(sender, getSafeTxHash(self, to, 0, data, operation, nonce));
363448
return abi.encodePacked(sig.r, sig.s, sig.v);
364449
}
365450
}
451+
452+
/// @notice Prepare the signature for a transaction, using the nonce from the Safe
453+
function sign(
454+
Client storage self,
455+
address to,
456+
bytes memory data,
457+
Enum.Operation operation,
458+
address sender,
459+
string memory derivationPath
460+
) internal returns (bytes memory) {
461+
return sign(self, to, data, operation, sender, getNonce(self), derivationPath);
462+
}
366463
}

test/Safe.t.sol

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ pragma solidity ^0.8.13;
33

44
import {Test, console} from "forge-std/Test.sol";
55
import {Safe} from "../src/Safe.sol";
6-
import {strings} from "../lib/solidity-stringutils/src/strings.sol";
6+
import {strings} from "solidity-stringutils/strings.sol";
77
import {IWETH} from "./interfaces/IWETH.sol";
8+
import {Enum} from "safe-smart-account/common/Enum.sol";
89

910
contract SafeTest is Test {
1011
using Safe for *;
@@ -16,7 +17,9 @@ contract SafeTest is Test {
1617
bytes32 foundrySigner1PrivateKey = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80;
1718

1819
function setUp() public {
19-
vm.createSelectFork("https://mainnet.base.org", 28363380);
20+
// Note: this was previously set to 28363380, but as the Safe API does not
21+
// operate on a specific block, it was throwing an error about the nonce being used already.
22+
vm.createSelectFork("https://mainnet.base.org");
2023
safe.initialize(safeAddress);
2124
}
2225

@@ -33,8 +36,32 @@ contract SafeTest is Test {
3336

3437
function test_Safe_getExecTransactionData() public {
3538
address weth = 0x4200000000000000000000000000000000000006;
36-
bytes memory data =
37-
safe.getExecTransactionData(weth, abi.encodeCall(IWETH.withdraw, (0)), foundrySigner1, "m/44'/60'/0'/0/0");
39+
vm.rememberKey(uint256(foundrySigner1PrivateKey));
40+
bytes memory data = safe.getExecTransactionData(weth, abi.encodeCall(IWETH.withdraw, (0)), foundrySigner1, "");
3841
console.logBytes(data);
3942
}
43+
44+
function test_Safe_proposeTransactionsWithSignature() public {
45+
address weth = 0x4200000000000000000000000000000000000006;
46+
47+
// Create batch of transactions
48+
address[] memory targets = new address[](2);
49+
bytes[] memory datas = new bytes[](2);
50+
51+
targets[0] = weth;
52+
datas[0] = abi.encodeCall(IWETH.withdraw, (0));
53+
54+
targets[1] = weth;
55+
datas[1] = abi.encodeCall(IWETH.withdraw, (1));
56+
57+
// Get the target and data for signing
58+
(address to, bytes memory data) = safe.getProposeTransactionsTargetAndData(targets, datas);
59+
60+
// Sign with DelegateCall operation (required for batch transactions)
61+
vm.rememberKey(uint256(foundrySigner1PrivateKey));
62+
bytes memory signature = safe.sign(to, data, Enum.Operation.DelegateCall, foundrySigner1, "");
63+
64+
// Propose transactions with the signature
65+
safe.proposeTransactionsWithSignature(targets, datas, foundrySigner1, signature);
66+
}
4067
}

0 commit comments

Comments
 (0)