-
Notifications
You must be signed in to change notification settings - Fork 917
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
optimize getTransactionHash
by implementing it in assembly
#847
Conversation
be08482
to
da51b2d
Compare
da51b2d
to
9d2ea5b
Compare
9d2ea5b
to
6fd853b
Compare
contracts/Safe.sol
Outdated
// we can save gas by reusing the same memory block by overwriting the data. | ||
/* solhint-disable no-inline-assembly */ | ||
assembly { | ||
let freeMemoryPointer := mload(0x40) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
random fact: renaming this variable increases gas by 12 units? weird
edit: probably because the contract metadata hash appended to the bytecode is different, and the target address contains one more non-zero byte
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We had "mined" an extra 0-byte in the data hash :P.
I wonder if we can mine a more optimal metadata hash by randomizing characters in a comment at the end of a file? Would be a fun thought experiment 😛
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This translates to a 0.3% saving, so I'm not 100% sure it is worth the optimization to be honest.
That being said, it is fairly simple and there is no additional control flow introduced in the assembly, which makes me lean on the "optimization is slightly worth it" side of the fence.
Its really annoying that Solidity fails to generate reasonable code for these simple cases (as in... why on Earth does it unconditionally allocate intermediate "rvalues" (to borrow from C/C++ lingo)???).
a90fdb8
to
16b5924
Compare
I tried to optimize it further and removed the
Gas results:
If that sounds better, I can adjust the PR |
return keccak256(encodeTransactionData(to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, _nonce)); | ||
) public view override returns (bytes32 txHash) { | ||
bytes32 separatorHash = domainSeparator(); | ||
/* solhint-disable no-inline-assembly */ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: Can you add a comment here justifying the use of inline assembly? I think it makes sense to add these kinds of justifications so that auditors and other people reviewing the code can understand our rationale behind not just using plain Solidity for this.
contracts/Safe.sol
Outdated
// Hash the SafeTX struct and store it at the end of the result | ||
// Hashing first so we can re-use the same memory block for the result | ||
mstore(add(ptr, 34), keccak256(ptr, 352)) | ||
// Store the EIP-712 prefix (0x1901), note that strings are right-padded. | ||
// We write it before the domain separator and hash to use the remaining space. | ||
mstore(ptr, "\x19\x01") | ||
// Store the domain separator | ||
mstore(add(ptr, 2), separatorHash) | ||
|
||
// Calculate the hash | ||
txHash := keccak256(ptr, 66) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just for completeness, can we compare with something like this:
// Hash the SafeTX struct and store it at the end of the result | |
// Hashing first so we can re-use the same memory block for the result | |
mstore(add(ptr, 34), keccak256(ptr, 352)) | |
// Store the EIP-712 prefix (0x1901), note that strings are right-padded. | |
// We write it before the domain separator and hash to use the remaining space. | |
mstore(ptr, "\x19\x01") | |
// Store the domain separator | |
mstore(add(ptr, 2), separatorHash) | |
// Calculate the hash | |
txHash := keccak256(ptr, 66) | |
// Hash the SafeTX struct and store it at the end of the result | |
// Hashing first so we can re-use the same memory block for the result | |
mstore(add(ptr, 64), keccak256(ptr, 352)) | |
// Store the EIP-712 prefix (0x1901), note that integers are left-padded | |
// so the EIP-712 encoded data starts at add(ptr, 30). | |
mstore(ptr, 0x1901) | |
// Store the domain separator | |
mstore(add(ptr, 32), separatorHash) | |
// Calculate the hash | |
txHash := keccak256(add(ptr, 30), 66) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rationale: Solidity won't emit a push32 0x19010000..00
but instead a push2 0x1901
at the cost of an extra addition. Just want to see if we prefer code size savings or runtime savings for this particular case...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Gas:
ERC20 - transfer
Used 51800n gas for >transfer<
✔ with an EOA (73ms)
Used 82500n gas for >transfer<
✔ with a single owner Safe
Used 88381n gas for >transfer<
✔ with a single owner and guard Safe
Used 89553n gas for >transfer<
✔ with a 2 out of 2 Safe
Used 96607n gas for >transfer<
✔ with a 3 out of 3 Safe
Used 96595n gas for >transfer<
✔ with a 3 out of 5 Safe
codesize:
Safe 21502 bytes (limit is 24576)
SafeL2 22282 bytes (limit is 24576)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good! Just one nit (around adding a comment justifying the use of assembly), and an optional thing to try out and compare gas characteristics.
I was a bit more reluctant at first for this change, but now that we got rid of the Awesome assembly 🧙 work! |
getTransactionHash
by implementing it in assembly
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
: 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
: Enhanced the test case for calculating EIP-712 hash by introducing a loop to generate and test multiple random transactions. [1] [2]. 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.Benchmarks
Before
After
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)