Platform: Code4rena
Start Date: 14/03/2024
Pot Size: $49,000 USDC
Total HM: 3
Participants: 51
Period: 7 days
Judge: 3docSec
Id: 350
League: ETH
Rank: 13/51
Findings: 1
Award: $120.84
π Selected for report: 0
π Solo Findings: 0
π Selected for report: K42
Also found by: 0x11singh99, 0xAnah, Hajime, SAQ, SM3_SS, albahaca, clara, dharma09, hunter_w3b, naman1778, shamsulhaq123, slvDev
120.838 USDC - $120.84
Note : G-08, G-11 and G-12 contains only those instances which were missed by bot. Since they are major gas savings so I included those missed instances
[G-07] Check contract
balance before sending ethers
to fail early
[G-08] Use unchecked where underflow not possible(Gas Saved: ~150 Gas)(Missed by bot)
[G-10] Cache address(this).balance instead of recalculating it
[G-11] Use nested if and separate if instead of && and || respectively(Instances Missed by bot)
[G-12] Use abi.encodePacked
instead of abi.encode
(Instances Missed by bot)
All these findings are good findings and 100% safe to implement at no security/logic risk. They all are found by thorough manual review.
storage
variable once outside of the loop instead of updating it every time in loop it saves 1 SSTORE, 1SLOAD per iteration(Saves ~2200 Gas per iteration)Since nextOwnerIndex
doesn't depend on loop iteration, it is simply being updated one by one. And nextOwnerIndex
is not updated anywhere in the loop including inside _addOwnerAtIndex
function except here in param nor it's updated value accessed. In _addOwnerAtIndex
2nd param updated nextOwnerIndex
value passed from here. We can achieve the same result by caching nextOwnerIndex
value. And update that cached stack value inside loop instead of this and passed also that cached updated value into _addOwnerAtIndex
function. It will work as same since in place of updating storage nextOwnerIndex
we are we are updating cached stack value. And after the end of loop outside the loop we will update the storage var. _getMultiOwnableStorage().nextOwnerIndex
with the updated stack var. value. Which will result in same result ans saves a lot of gas.
File : SmartWallet/MultiOwnable.sol 163: for (uint256 i; i < owners.length; i++) { ... 172: _addOwnerAtIndex(owners[i], _getMultiOwnableStorage().nextOwnerIndex++); ... }
Recommended Mitigation Steps:
File : SmartWallet/MultiOwnable.sol + uint256 cached_nextOwnerIndex = _getMultiOwnableStorage().nextOwnerIndex;//@audit cache initial nextOwnerIndex storage value. 163: for (uint256 i; i < owners.length; i++) { ... -172: _addOwnerAtIndex(owners[i], _getMultiOwnableStorage().nextOwnerIndex++); +172: _addOwnerAtIndex(owners[i], cached_nextOwnerIndex++);//@audit updating cached stack value instead of nextOwnerIndex storage value ... } + _getMultiOwnableStorage().nextOwnerIndex = cached_nextOwnerIndex; //@audit update storage var. with final value
validatePaymasterUserOp
function to fail early and save 2 function calls (Saves ~20k-21k Gas Half of the times)Since withdrawAmount
is not changed during the function calls. So we can check first address(this).balance < withdrawAmount
to fail early by placing this if check before any call. Since this if check var. withdrawAmount
not based on any function call nor updated by them so it will be same before or after those function call so place this if check before to fail early if this check is failing it can avoid up to 2 function calls. Where _validateRequest
function doing 1 SSTORE and 1 SLOAD. Where SSTORE is used to change the value from false to true which takes ~20k Gas. So placing this if check before can save these operations when if check fails and can save up to ~20k gas.
_validateRequest
function 1 SSTORE and 1 SLOAD, where SSTORE
is used to change the value from false
to true
which take ~20k gas. Saves ~20-21k Gas Half of the times ie. when if check fails and reverts.File : MagicSpend/MagicSpend.sol 125: _validateRequest(userOp.sender, withdrawRequest); 126: 127: bool sigFailed = !isValidWithdrawSignature(userOp.sender, withdrawRequest); 128: validationData = (sigFailed ? 1 : 0) | (uint256(withdrawRequest.expiry) << 160); 129: 130: // Ensure at validation that the contract has enough balance to cover the requested funds. 131: // NOTE: This check is necessary to enforce that the contract will be able to transfer the remaining funds 132: // when `postOp()` is called back after the `UserOperation` has been executed. 133: if (address(this).balance < withdrawAmount) { 134: revert InsufficientBalance(withdrawAmount, address(this).balance); 135: }
Recommended Mitigation Steps:
File : MagicSpend/MagicSpend.sol + if (address(this).balance < withdrawAmount) { + revert InsufficientBalance(withdrawAmount, address(this).balance); + } 125: _validateRequest(userOp.sender, withdrawRequest); 126: 127: bool sigFailed = !isValidWithdrawSignature(userOp.sender, withdrawRequest); 128: validationData = (sigFailed ? 1 : 0) | (uint256(withdrawRequest.expiry) << 160); 129: 130: // Ensure at validation that the contract has enough balance to cover the requested funds. 131: // NOTE: This check is necessary to enforce that the contract will be able to transfer the remaining funds 132: // when `postOp()` is called back after the `UserOperation` has been executed. -133: if (address(this).balance < withdrawAmount) { -134: revert InsufficientBalance(withdrawAmount, address(this).balance); -135: }
_getMultiOwnableStorage()
one time to fetch storage pointer and avoid extra internal function callSince _getMultiOwnableStorage()
returns same storage pointer of MultiOwnableStorage
type so it is enough to call once and take that pointer in storage instead of calling _getMultiOwnableStorage()
second time since it returns same storage pointer which is already returned and points to the same storage location. So weather it is returned through function or cache in storage pointer it will point to same storage location and update that same storage by that pointer. See Mitigation for clarity. So Saves 1 internal function call
This is below the function which is returning storage pointer of MultiOwnableStorage
type
File : SmartWallet/MultiOwnable.sol 212: function _getMultiOwnableStorage() internal pure returns (MultiOwnableStorage storage $) { 213: assembly ("memory-safe") { 214: $.slot := MUTLI_OWNABLE_STORAGE_LOCATION 215: } 216: }
File : SmartWallet/MultiOwnable.sol 102: function removeOwnerAtIndex(uint256 index) public virtual onlyOwner { 103: bytes memory owner = ownerAtIndex(index); 104: if (owner.length == 0) revert NoOwnerAtIndex(index); 105: 106: delete _getMultiOwnableStorage().isOwner[owner]; 107: delete _getMultiOwnableStorage().ownerAtIndex[index];
Recommended Mitigation Steps:
File : SmartWallet/MultiOwnable.sol 102: function removeOwnerAtIndex(uint256 index) public virtual onlyOwner { 103: bytes memory owner = ownerAtIndex(index); 104: if (owner.length == 0) revert NoOwnerAtIndex(index); + MultiOwnableStorage storage $ = _getMultiOwnableStorage(); 105: -106: delete _getMultiOwnableStorage().isOwner[owner]; +106: delete $.isOwner[owner]; -107: delete _getMultiOwnableStorage().ownerAtIndex[index]; +107: delete $.ownerAtIndex[index];
File : SmartWallet/MultiOwnable.sol 189: function _addOwnerAtIndex(bytes memory owner, uint256 index) internal virtual { 190: if (isOwnerBytes(owner)) revert AlreadyOwner(owner); 191: 192: _getMultiOwnableStorage().isOwner[owner] = true; 193: _getMultiOwnableStorage().ownerAtIndex[index] = owner;
Recommended Mitigation Steps:
File : SmartWallet/MultiOwnable.sol 189: function _addOwnerAtIndex(bytes memory owner, uint256 index) internal virtual { 190: if (isOwnerBytes(owner)) revert AlreadyOwner(owner); + MultiOwnableStorage storage $ = _getMultiOwnableStorage(); 191: -192: _getMultiOwnableStorage().isOwner[owner] = true; +192: $.isOwner[owner] = true; -193: _getMultiOwnableStorage().ownerAtIndex[index] = owner; +193: $.ownerAtIndex[index] = owner;
withdrawAmount - maxCost
for greater than 0
if it is 0
it can save 1 Gsreset
(~2900 Gas) and 1 Gcoldsload
(Saves ~5000 Gas)From Ethereum yellow Paper : Gsreset
2900 Gas , Paid for an SSTORE operation when the storage valueβs zeroness remains unchanged or is set to zero.
Since if withdrawAmount - maxCost
is 0 _withdrawableETH[userOp.sender]
mapping will be updated by adding 0 which is unnecessary and only wastes gas. So it is recommended to check for withdrawAmount - maxCost
greater than 0
then only add this into the mapping otherwise skip this mapping update and continue execution since withdrawAmount - maxCost
is not storage var. so checking them will not cost more gas but updating the mapping by adding 0 will cost big chunk of gas.
Here zeroness remains unchanged by adding 0 to the mapping. So by adding the check we can avoid 1 Gsreset
( ~2900 Gas) and 1 Gcoldsload
(~2100 Gas) of reading _withdrawableETH[userOp.sender]
unnecessarily when withdrawAmount - maxCost
is 0.
Gsreset
(~2900 Gas) and 1 Gcoldsload
(Saves ~5000 Gas)File : MagicSpend/MagicSpend.sol 138: _withdrawableETH[userOp.sender] += withdrawAmount - maxCost;
Recommended Mitigation Steps:
File : MagicSpend/MagicSpend.sol + if((withdrawAmount - maxCost) > 0){ + _withdrawableETH[userOp.sender] += withdrawAmount - maxCost; + } -138: _withdrawableETH[userOp.sender] += withdrawAmount - maxCost;
keccak256 hash
of constant string
instead of re-calculating the hash on every function call (Saves ~50 Gas)Since string "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
is constant so it's keccak256 hash will be same every time it is calculated.
So it is efficient to cache the result of keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")
in to a bytes32 type constant variable instead of re-calculating it on every function call. Saves it's hash calculating gas cost every time the domainSeparator()
function will be called.
Since it contains 3 32 byte words s approx 30 + 6*3 becomes 48 and some mem cost so it will take approx ~50 Gas Every time.
File : SmartWallet/ERC1271.sol 100: function domainSeparator() public view returns (bytes32) { (string memory name, string memory version) = _domainNameAndVersion(); return keccak256( abi.encode( 104: keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), keccak256(bytes(name)), keccak256(bytes(version)), block.chainid, address(this) ) ); }
Recommended Mitigation Steps:
File : SmartWallet/ERC1271.sol + bytes32 public constant DOMAIN_HASH= keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); ... 100: function domainSeparator() public view returns (bytes32) { (string memory name, string memory version) = _domainNameAndVersion(); return keccak256( abi.encode( - 104: keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + 104: DOMAIN_HASH keccak256(bytes(name)), keccak256(bytes(version)), block.chainid, address(this) ) ); }
Switch
the order of if
statement to fail early saves 1 function call half of the times where function have SLOAD (Saves ~2100 Gas Half of the times)It is recommended to order checks from low to high gas consuming to revert early.
First statement do a function call and second only checks stake variable so we can switch the order of these two if statements to save gas.
File : MagicSpend/MagicSpend.sol 184: if (!isValidWithdrawSignature(msg.sender, withdrawRequest)) { 185: revert InvalidSignature(); 186: } 187: 188: if (block.timestamp > withdrawRequest.expiry) { 189: revert Expired(); 190: }
Recommended Mitigation Steps:
File : MagicSpend/MagicSpend.sol + if (block.timestamp > withdrawRequest.expiry) { + revert Expired(); + } 184: if (!isValidWithdrawSignature(msg.sender, withdrawRequest)) { 185: revert InvalidSignature(); 186: } 187: -188: if (block.timestamp > withdrawRequest.expiry) { -189: revert Expired(); -190: }
contract
balance before sending ethers
to fail early.To fail early check value is less than or equal to address(this).balance.
File : SmartWallet/CoinbaseSmartWallet.sol 272: function _call(address target, uint256 value, bytes memory data) internal { 273: (bool success, bytes memory result) = target.call{value: value}(data); 274: if (!success) { 275: assembly ("memory-safe") { 276: revert(add(result, 32), mload(result)) 277: } 278: } 279: }
CoinbaseSmartWallet.sol#L272-L279
Recommended Mitigation Steps:
File : SmartWallet/CoinbaseSmartWallet.sol 272: function _call(address target, uint256 value, bytes memory data) internal { + require(value <= address(this).balance),"Not enough balance"; 273: (bool success, bytes memory result) = target.call{value: value}(data); 274: if (!success) { 275: assembly ("memory-safe") { 276: revert(add(result, 32), mload(result)) 277: } 278: } 279: }
Cache n - r
in unchecked
block to save 1 checked subtraction.
File : FreshCryptoLib/FCL.sol 67: x1 = addmod(x1, n - r, n);
Recommended Mitigation Steps:
File : FreshCryptoLib/FCL.sol + unchecked{ + uint256 nSUBr = n - r; + } -67: x1 = addmod(x1, n - r, n); +67: x1 = addmod(x1, nSUBr, n);
Do not assign salt
and extensions
variables with their default values.
File : SmartWallet/ERC1271.sol 54: salt = salt; // `bytes32(0)`. 55: extensions = extensions; // `new uint256[](0)`.
address(this).balance
instead of recalculating itFile : MagicSpend/MagicSpend.sol 133: if (address(this).balance < withdrawAmount) { 134: revert InsufficientBalance(withdrawAmount, address(this).balance); 135: }
Recommended Mitigation Steps:
File : MagicSpend/MagicSpend.sol + uint256 balanceOfAddressThis = address(this).balance; -133: if (address(this).balance < withdrawAmount) { +133: if (balanceOfAddressThis < withdrawAmount) { -134: revert InsufficientBalance(withdrawAmount, address(this).balance); +134: revert InsufficientBalance(withdrawAmount, balanceOfAddressThis); 135: }
The code employs logical operators && (logical AND) and || (logical OR) to combine conditions within if statements. It's recommended to refactor these expressions by using nested if statements for logical AND operations and separate if statements for logical OR operations. This enhances code readability, modularity, and maintainability.
File : FreshCryptoLib/FCL.sol 79: if (((0 == x) && (0 == y)) || x == p || y == p) { 80: return false; 81: }
Recommended Mitigation Steps:
File : FreshCryptoLib/FCL.sol -79: if (((0 == x) && (0 == y)) || x == p || y == p) { -80: return false; -81: } + if (0 == x) { + if (0 == y) { + return false; + } + } + if (x == p || y == p) { + return false; +}
abi.encodePacked
instead of abi.encode
(Instances Missed by bot)By using abi.encodePacked, unnecessary padding is avoided, resulting in a more efficient encoding process and reducing gas consumption for transactions or contract deployments. This optimization aligns with best practices for gas-efficient smart contract development.
File : MagicSpend/MagicSpend.sol 139: context = abi.encode(maxCost, userOp.sender);
Recommended Mitigation Steps:
Replace abi.encode with abi.encodePacked to optimize gas usage in encoding data.
File : MagicSpend/MagicSpend.sol -139: context = abi.encode(maxCost, userOp.sender); +139: context = abi.encodePacked(maxCost, userOp.sender);
#0 - raymondfam
2024-03-22T22:02:19Z
12 well elaborate G.
#1 - c4-pre-sort
2024-03-22T22:02:23Z
raymondfam marked the issue as high quality report
#2 - c4-sponsor
2024-03-26T14:05:41Z
wilsoncusack (sponsor) acknowledged
#3 - c4-judge
2024-03-27T13:36:10Z
3docSec marked the issue as grade-a
#4 - wilsoncusack
2024-04-01T17:22:50Z
G1: will do G2: will do G3: will do G4: will do G5: won't do, needs to update because version changes and proxy G6: will do G7: won't do G8: won't do G9: maybe G10: won't do G11: won't do G12: won't do, can't reliably decode