Skip to content

Commit b55fd8f

Browse files
authored
optimize getTransactionHash by implementing it in assembly (#847)
This pull request includes significant changes to the `Safe` contract and its associated test suite. The changes focus on optimizing the encoding of transaction data and enhancing the test coverage for transaction hash calculations. ### Optimizations in `Safe` contract: * [`contracts/Safe.sol`](diffhunk://#diff-587b494ea631bb6b7adf4fc3e1a2e6a277a385ff16e1163b26e39de24e9483deL414-R467): Rewrote the transaction data encoding logic in assembly to avoid multiple memory allocations, improving gas efficiency. ### Enhancements in test suite: * [`test/core/Safe.Signatures.spec.ts`](diffhunk://#diff-d7bc3771858069f85022d38344b6cb5302146da4bafde1ac18910e3d7bfac43bL49-R56): Enhanced the test case for calculating EIP-712 hash by introducing a loop to generate and test multiple random transactions. [[1]](diffhunk://#diff-d7bc3771858069f85022d38344b6cb5302146da4bafde1ac18910e3d7bfac43bL49-R56) [[2]](diffhunk://#diff-d7bc3771858069f85022d38344b6cb5302146da4bafde1ac18910e3d7bfac43bR72). The previous test case was inefficient as it contained empty safe transaction data. The test would still pass if you forgot to include it in hashing. * I also added a FV rule to verify hash computation correctness. ### Benchmarks #### Before ``` ERC20 - transfer Used 51800n gas for >transfer< ✔ with an EOA (137ms) Used 82980n gas for >transfer< ✔ with a single owner Safe Used 88874n gas for >transfer< ✔ with a single owner and guard Safe Used 90024n gas for >transfer< ✔ with a 2 out of 2 Safe Used 97094n gas for >transfer< ✔ with a 3 out of 3 Safe Used 97094n gas for >transfer< ✔ with a 3 out of 5 Safe ``` #### After ``` ERC20 - transfer Used 51800n gas for >transfer< ✔ with an EOA (71ms) Used 82494n gas for >transfer< ✔ with a single owner Safe Used 88375n gas for >transfer< ✔ with a single owner and guard Safe Used 89547n gas for >transfer< ✔ with a 2 out of 2 Safe Used 96577n gas for >transfer< ✔ with a 3 out of 3 Safe Used 96589n gas for >transfer< ✔ with a 3 out of 5 Safe ``` On average, it saves ~485 gas, not much, but considering this is the hottest path, it should result in significant accumulated savings. (After 44 Safe transactions, a user would save 21k gas - enough for broadcasting a native token transfer) ### Codesize It saves 273 bytes in code size. #### Before SafeL2 22582 bytes (limit is 24576) #### After SafeL2 22309 bytes (limit is 24576)
1 parent b0514b8 commit b55fd8f

File tree

4 files changed

+158
-120
lines changed

4 files changed

+158
-120
lines changed

certora/applyHarness.patch

Lines changed: 59 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,7 @@
1-
diff -druN base/Executor.sol base/Executor.sol
2-
--- base/Executor.sol 2024-04-17 14:32:25.133704630 +0200
3-
+++ base/Executor.sol 2024-04-18 12:13:12.682392677 +0200
4-
@@ -26,12 +26,8 @@
5-
uint256 txGas
6-
) internal returns (bool success) {
7-
if (operation == Enum.Operation.DelegateCall) {
8-
- /* solhint-disable no-inline-assembly */
9-
- /// @solidity memory-safe-assembly
10-
- assembly {
11-
- success := delegatecall(txGas, to, add(data, 0x20), mload(data), 0, 0)
12-
- }
13-
- /* solhint-enable no-inline-assembly */
14-
+ // MUNGED lets just be a bit more optimistic, `execute` does nothing for `DELEGATECALL` and always returns true
15-
+ return true;
16-
} else {
17-
/* solhint-disable no-inline-assembly */
18-
/// @solidity memory-safe-assembly
19-
diff -druN interfaces/ISafe.sol interfaces/ISafe.sol
20-
--- interfaces/ISafe.sol 2024-04-18 13:33:47.817387950 +0200
21-
+++ interfaces/ISafe.sol 2024-04-24 12:56:22.448349149 +0200
22-
@@ -109,7 +109,7 @@
23-
*/
24-
function domainSeparator() external view returns (bytes32);
25-
26-
- /**
27-
+ /*
28-
* @notice Returns transaction hash to be signed by owners.
29-
* @param to Destination address.
30-
* @param value Ether value.
31-
@@ -122,7 +122,6 @@
32-
* @param refundReceiver Address of receiver of gas payment (or 0 if tx.origin).
33-
* @param _nonce Transaction nonce.
34-
* @return Transaction hash.
35-
- */
36-
function getTransactionHash(
37-
address to,
38-
uint256 value,
39-
@@ -135,6 +134,8 @@
40-
address refundReceiver,
41-
uint256 _nonce
42-
) external view returns (bytes32);
43-
+ */
44-
+ // MUNGED: The function was made internal to enable CVL summaries.
45-
46-
/**
47-
* External getter function for state variables.
481
diff -druN Safe.sol Safe.sol
49-
--- Safe.sol 2024-04-19 12:20:27.643013980 +0200
50-
+++ Safe.sol 2024-04-24 12:57:47.960375971 +0200
51-
@@ -72,22 +72,24 @@
2+
--- Safe.sol 2024-10-23 15:00:06
3+
+++ Safe.sol 2024-10-23 15:04:05
4+
@@ -75,22 +75,24 @@
525
* so we create a Safe with 0 owners and threshold 1.
536
* This is an unusable Safe, perfect for the singleton
547
*/
@@ -77,8 +30,8 @@ diff -druN Safe.sol Safe.sol
7730
// setupOwners checks if the Threshold is already set, therefore preventing that this method is called twice
7831
setupOwners(_owners, _threshold);
7932
if (fallbackHandler != address(0)) internalSetFallbackHandler(fallbackHandler);
80-
@@ -417,9 +419,6 @@
81-
return abi.encodePacked(bytes1(0x19), bytes1(0x01), domainSeparator(), safeTxHash);
33+
@@ -386,9 +388,6 @@
34+
return keccak256(abi.encode(DOMAIN_SEPARATOR_TYPEHASH, chainId, this));
8235
}
8336

8437
- /**
@@ -87,13 +40,61 @@ diff -druN Safe.sol Safe.sol
8740
function getTransactionHash(
8841
address to,
8942
uint256 value,
90-
@@ -431,7 +430,8 @@
43+
@@ -400,7 +399,9 @@
9144
address gasToken,
9245
address refundReceiver,
9346
uint256 _nonce
94-
- ) public view override returns (bytes32) {
95-
+ ) internal view returns (bytes32) {
47+
- ) public view override returns (bytes32 txHash) {
48+
+ ) internal view returns (bytes32 txHash) {
9649
+ // MUNGED: The function was made internal to enable CVL summaries.
97-
return keccak256(encodeTransactionData(to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, _nonce));
98-
}
99-
}
50+
+
51+
bytes32 domainHash = domainSeparator();
52+
53+
// We opted out for using assembly code here, because the way Solidity compiler we use (0.7.6)
54+
diff -druN base/Executor.sol base/Executor.sol
55+
--- base/Executor.sol 2024-10-18 15:20:48
56+
+++ base/Executor.sol 2024-10-23 15:03:22
57+
@@ -26,12 +26,8 @@
58+
uint256 txGas
59+
) internal returns (bool success) {
60+
if (operation == Enum.Operation.DelegateCall) {
61+
- /* solhint-disable no-inline-assembly */
62+
- /// @solidity memory-safe-assembly
63+
- assembly {
64+
- success := delegatecall(txGas, to, add(data, 0x20), mload(data), 0, 0)
65+
- }
66+
- /* solhint-enable no-inline-assembly */
67+
+ // MUNGED lets just be a bit more optimistic, `execute` does nothing for `DELEGATECALL` and always returns true
68+
+ return true;
69+
} else {
70+
/* solhint-disable no-inline-assembly */
71+
/// @solidity memory-safe-assembly
72+
diff -druN interfaces/ISafe.sol interfaces/ISafe.sol
73+
--- interfaces/ISafe.sol 2024-10-18 15:20:48
74+
+++ interfaces/ISafe.sol 2024-10-23 15:03:22
75+
@@ -110,7 +110,7 @@
76+
*/
77+
function domainSeparator() external view returns (bytes32);
78+
79+
- /**
80+
+ /*
81+
* @notice Returns transaction hash to be signed by owners.
82+
* @param to Destination address.
83+
* @param value Ether value.
84+
@@ -123,7 +123,6 @@
85+
* @param refundReceiver Address of receiver of gas payment (or 0 if tx.origin).
86+
* @param _nonce Transaction nonce.
87+
* @return Transaction hash.
88+
- */
89+
function getTransactionHash(
90+
address to,
91+
uint256 value,
92+
@@ -136,6 +135,8 @@
93+
address refundReceiver,
94+
uint256 _nonce
95+
) external view returns (bytes32);
96+
+ */
97+
+ // MUNGED: The function was made internal to enable CVL summaries.
98+
99+
/**
100+
* External getter function for state variables.

certora/specs/Safe.spec

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ methods {
44
function nonce() external returns (uint256) envfree;
55
function signedMessages(bytes32) external returns (uint256) envfree;
66
function isOwner(address) external returns (bool) envfree;
7+
function getTransactionHashPublic(address,uint256,bytes,Enum.Operation,uint256,uint256,uint256,address,address,uint256) external returns (bytes32) envfree;
78

89
// harnessed
910
function getModule(address) external returns (address) envfree;
@@ -271,3 +272,25 @@ rule onlyModuleCanExecuteModuleThransactionsWithReturnData(
271272
execTransactionFromModuleReturnData@withrevert(e, to, value, data, operation);
272273
assert !lastReverted => getModule(e.msg.sender) != 0, "Only modules can execute module transactions";
273274
}
275+
276+
rule transactionHashCantCollide() {
277+
env e;
278+
279+
address to1; address to2;
280+
uint256 value1; uint256 value2;
281+
bytes data1; bytes data2;
282+
Enum.Operation operation1; Enum.Operation operation2;
283+
uint256 safeTxGas1; uint256 safeTxGas2;
284+
uint256 baseGas1; uint256 baseGas2;
285+
uint256 gasPrice1; uint256 gasPrice2;
286+
address gasToken1; address gasToken2;
287+
address refundReceiver1; address refundReceiver2;
288+
uint256 nonce1; uint256 nonce2;
289+
290+
bytes32 hash1 = getTransactionHashPublic(to1, value1, data1, operation1, safeTxGas1, baseGas1, gasPrice1, gasToken1, refundReceiver1, nonce1);
291+
bytes32 hash2 = getTransactionHashPublic(to2, value2, data2, operation2, safeTxGas2, baseGas2, gasPrice2, gasToken2, refundReceiver2, nonce2);
292+
293+
assert hash1 == hash2 =>
294+
to1 == to2 && value1 == value2 && data1 == data2 && operation1 == operation2 && safeTxGas1 == safeTxGas2
295+
&& baseGas1 == baseGas2 && gasPrice1 == gasPrice2 && gasToken1 == gasToken2 && refundReceiver1 == refundReceiver2 && nonce1 == nonce2;
296+
}

contracts/Safe.sol

Lines changed: 52 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -386,50 +386,6 @@ contract Safe is
386386
return keccak256(abi.encode(DOMAIN_SEPARATOR_TYPEHASH, chainId, this));
387387
}
388388

389-
/**
390-
* @notice Returns the pre-image of the transaction hash (see getTransactionHash).
391-
* @param to Destination address.
392-
* @param value Ether value.
393-
* @param data Data payload.
394-
* @param operation Operation type.
395-
* @param safeTxGas Gas that should be used for the safe transaction.
396-
* @param baseGas Gas costs for that are independent of the transaction execution(e.g. base transaction fee, signature check, payment of the refund)
397-
* @param gasPrice Maximum gas price that should be used for this transaction.
398-
* @param gasToken Token address (or 0 if ETH) that is used for the payment.
399-
* @param refundReceiver Address of receiver of gas payment (or 0 if tx.origin).
400-
* @param _nonce Transaction nonce.
401-
* @return Transaction hash bytes.
402-
*/
403-
function encodeTransactionData(
404-
address to,
405-
uint256 value,
406-
bytes calldata data,
407-
Enum.Operation operation,
408-
uint256 safeTxGas,
409-
uint256 baseGas,
410-
uint256 gasPrice,
411-
address gasToken,
412-
address refundReceiver,
413-
uint256 _nonce
414-
) private view returns (bytes memory) {
415-
bytes32 safeTxStructHash = keccak256(
416-
abi.encode(
417-
SAFE_TX_TYPEHASH,
418-
to,
419-
value,
420-
keccak256(data),
421-
operation,
422-
safeTxGas,
423-
baseGas,
424-
gasPrice,
425-
gasToken,
426-
refundReceiver,
427-
_nonce
428-
)
429-
);
430-
return abi.encodePacked(bytes1(0x19), bytes1(0x01), domainSeparator(), safeTxStructHash);
431-
}
432-
433389
/**
434390
* @inheritdoc ISafe
435391
*/
@@ -444,8 +400,58 @@ contract Safe is
444400
address gasToken,
445401
address refundReceiver,
446402
uint256 _nonce
447-
) public view override returns (bytes32) {
448-
return keccak256(encodeTransactionData(to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, _nonce));
403+
) public view override returns (bytes32 txHash) {
404+
bytes32 domainHash = domainSeparator();
405+
406+
// We opted out for using assembly code here, because the way Solidity compiler we use (0.7.6)
407+
// allocates memory is inefficient. We only need to allocate memory for temporary variables to be used in the keccak256 call.
408+
/* solhint-disable no-inline-assembly */
409+
assembly {
410+
// Get the free memory pointer
411+
let ptr := mload(0x40)
412+
413+
// Step 1: Hash the transaction data
414+
// Copy transaction data to memory and hash it
415+
calldatacopy(ptr, data.offset, data.length)
416+
let calldataHash := keccak256(ptr, data.length)
417+
418+
// Step 2: Prepare the SafeTX struct for hashing
419+
// Layout in memory:
420+
// ptr + 0: SAFE_TX_TYPEHASH (constant defining the struct hash)
421+
// ptr + 32: to address
422+
// ptr + 64: value
423+
// ptr + 96: calldataHash
424+
// ptr + 128: operation
425+
// ptr + 160: safeTxGas
426+
// ptr + 192: baseGas
427+
// ptr + 224: gasPrice
428+
// ptr + 256: gasToken
429+
// ptr + 288: refundReceiver
430+
// ptr + 320: nonce
431+
mstore(ptr, SAFE_TX_TYPEHASH)
432+
mstore(add(ptr, 32), to)
433+
mstore(add(ptr, 64), value)
434+
mstore(add(ptr, 96), calldataHash)
435+
mstore(add(ptr, 128), operation)
436+
mstore(add(ptr, 160), safeTxGas)
437+
mstore(add(ptr, 192), baseGas)
438+
mstore(add(ptr, 224), gasPrice)
439+
mstore(add(ptr, 256), gasToken)
440+
mstore(add(ptr, 288), refundReceiver)
441+
mstore(add(ptr, 320), _nonce)
442+
443+
// Step 3: Calculate the final EIP-712 hash
444+
// First, hash the SafeTX struct (352 bytes total length)
445+
mstore(add(ptr, 64), keccak256(ptr, 352))
446+
// Store the EIP-712 prefix (0x1901), note that integers are left-padded
447+
// so the EIP-712 encoded data starts at add(ptr, 30)
448+
mstore(ptr, 0x1901)
449+
// Store the domain separator
450+
mstore(add(ptr, 32), domainHash)
451+
// Calculate the hash
452+
txHash := keccak256(add(ptr, 30), 66)
453+
}
454+
/* solhint-enable no-inline-assembly */
449455
}
450456

451457
/**

test/core/Safe.Signatures.spec.ts

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { getCompatFallbackHandler } from "./../utils/setup";
22
import { calculateSafeMessageHash, signHash, buildContractSignature } from "./../../src/utils/execution";
33
import { expect } from "chai";
44
import hre from "hardhat";
5+
import crypto from "crypto";
56
import { AddressZero } from "@ethersproject/constants";
67
import { getSafeTemplate, getSafe } from "../utils/setup";
78
import {
@@ -46,22 +47,29 @@ describe("Safe", () => {
4647
it("should correctly calculate EIP-712 hash", async () => {
4748
const { safe } = await setupTests();
4849
const safeAddress = await safe.getAddress();
49-
const tx = buildSafeTransaction({ to: safeAddress, nonce: await safe.nonce() });
50-
const typedDataHash = calculateSafeTransactionHash(safeAddress, tx, await chainId());
51-
await expect(
52-
await safe.getTransactionHash(
53-
tx.to,
54-
tx.value,
55-
tx.data,
56-
tx.operation,
57-
tx.safeTxGas,
58-
tx.baseGas,
59-
tx.gasPrice,
60-
tx.gasToken,
61-
tx.refundReceiver,
62-
tx.nonce,
63-
),
64-
).to.be.eq(typedDataHash);
50+
51+
for (let i = 0; i < 100; i++) {
52+
const randomAddress = "0x" + crypto.randomBytes(20).toString("hex");
53+
const randomValue = "0x" + crypto.randomBytes(32).toString("hex");
54+
const randomData = "0x" + crypto.randomBytes(128).toString("hex");
55+
56+
const tx = buildSafeTransaction({ to: randomAddress, nonce: await safe.nonce(), value: randomValue, data: randomData });
57+
const typedDataHash = calculateSafeTransactionHash(safeAddress, tx, await chainId());
58+
await expect(
59+
await safe.getTransactionHash(
60+
tx.to,
61+
tx.value,
62+
tx.data,
63+
tx.operation,
64+
tx.safeTxGas,
65+
tx.baseGas,
66+
tx.gasPrice,
67+
tx.gasToken,
68+
tx.refundReceiver,
69+
tx.nonce,
70+
),
71+
).to.be.eq(typedDataHash);
72+
}
6573
});
6674
});
6775

0 commit comments

Comments
 (0)