Platform: Code4rena
Start Date: 29/06/2022
Pot Size: $50,000 USDC
Total HM: 20
Participants: 133
Period: 5 days
Judge: hickuphh3
Total Solo HM: 1
Id: 142
League: ETH
Rank: 8/133
Findings: 8
Award: $1,773.63
🌟 Selected for report: 3
🚀 Solo Findings: 0
🌟 Selected for report: IllIllI
Also found by: sashik_eth, shung, xiaoming90
302.7751 USDC - $302.78
https://github.com/code-423n4/2022-06-putty/blob/3b6b844bc39e897bd0bbb69897f2deff12dc3893/contracts/src/PuttyV2.sol#L636-L640 https://github.com/code-423n4/2022-06-putty/blob/3b6b844bc39e897bd0bbb69897f2deff12dc3893/contracts/src/PuttyV2.sol#L646-L650 https://github.com/code-423n4/2022-06-putty/blob/3b6b844bc39e897bd0bbb69897f2deff12dc3893/contracts/src/PuttyV2.sol#L657-L661
There are no bounds on the number of tokens transferred in an order, and gas requirements can change (especially since orders can have a duration of 27 years), so orders filled at time T1 may not be exercisable/withdrawable at time T2, or with the provided assets if the assets use a lot of gas during their transfers (e.g. aTokens and cTokens). The buyer of the option will have paid the premium, and will be unable to get the assets they are owed.
There are no upper bounds on the number of assets being transferred in these loops:
File: contracts/src/PuttyV2.sol #1 636 function _transferERC20sOut(ERC20Asset[] memory assets) internal { 637 for (uint256 i = 0; i < assets.length; i++) { 638 ERC20(assets[i].token).safeTransfer(msg.sender, assets[i].tokenAmount); 639 } 640 }
File: contracts/src/PuttyV2.sol #2 646 function _transferERC721sOut(ERC721Asset[] memory assets) internal { 647 for (uint256 i = 0; i < assets.length; i++) { 648 ERC721(assets[i].token).safeTransferFrom(address(this), msg.sender, assets[i].tokenId); 649 } 650 }
File: contracts/src/PuttyV2.sol #3 657 function _transferFloorsOut(address[] memory floorTokens, uint256[] memory floorTokenIds) internal { 658 for (uint256 i = 0; i < floorTokens.length; i++) { 659 ERC721(floorTokens[i]).safeTransferFrom(address(this), msg.sender, floorTokenIds[i]); 660 } 661 }
Code inspection
Have an upper bound on the number of assets, or allow them to be transferred out one at a time, if necessary
#0 - outdoteth
2022-07-07T14:05:57Z
Adding a hardcoded check at the contract level is not a viable fix given that gas costs and limits are subject change over time. Instead, there already exists a limit of 30 assets on the frontend/db level.
#1 - outdoteth
2022-07-08T13:38:55Z
Report: Unbounded loop can prevent put option from being exercised
#2 - HickupHH3
2022-07-11T00:58:04Z
Med severity is justified because, while very unlikely to happen, there could be a loss of assets.
420.5209 USDC - $420.52
Put option buyers pay an option premium to the seller for the privilege of being able to 'put' assets to the seller and get the strike price for it rather than the current market price. If they're unable to perform the 'put', they've paid the premium for nothing, and essentially have had funds stolen from them.
If the put option seller includes in order.erc20Assets
, an amount of zero for any of the assets, or specifies an asset that doesn't currently have any code at its address, the put buyer will be unable to exercise the option, and will have paid the premium for nothing:
File: contracts/src/PuttyV2.sol #1 453 // transfer assets from exerciser to putty 454 _transferERC20sIn(order.erc20Assets, msg.sender);
The function reverts if any amount is equal to zero, or the asset doesn't exist:
File: contracts/src/PuttyV2.sol #2 593 function _transferERC20sIn(ERC20Asset[] memory assets, address from) internal { 594 for (uint256 i = 0; i < assets.length; i++) { 595 address token = assets[i].token; 596 uint256 tokenAmount = assets[i].tokenAmount; 597 598 require(token.code.length > 0, "ERC20: Token is not contract"); 599 require(tokenAmount > 0, "ERC20: Amount too small"); 600 601 ERC20(token).safeTransferFrom(from, address(this), tokenAmount); 602 } 603 }
Code inspection
Verify the asset amounts and addresses during fillOrder()
, and allow exercise if the token no longer exists at that point in time
#0 - outdoteth
2022-07-07T13:43:57Z
At the contract level there exists 2 possible mitigations;
fillOrder
(gas tradeoff because it requires an O(n) loop to check)Instead, the best mitigation imo is to add a check on the frontend/db level to ensure that all erc20 assets have a token amount greater than 0 and that it exists as a contract.
If users want to go lower level than the db/frontend then they must exercise their own diligence.
edit: decided to go with a 3rd option instead.
simply skip the ERC20 transfer if the amount is 0.
#1 - outdoteth
2022-07-08T13:39:53Z
Report: Setting an erc20Asset with a zero amount or with no code at the address will result in a revert when exercising a put option
#2 - outdoteth
2022-07-15T10:31:58Z
PR with fix: https://github.com/outdoteth/putty-v2/pull/8
🌟 Selected for report: IllIllI
Also found by: 0x29A, 0xDjango, 0xc0ffEE, AmitN, BowTiedWardens, StErMi, auditor0517, berndartmueller, cccz, danb, dipp, dirk_y, hansfriese, horsefacts, hyh, kirk-baird, oyc_109, peritoflores, rfa, sseefried, swit, xiaoming90, zzzitron
5.5216 USDC - $5.52
https://github.com/code-423n4/2022-06-putty/blob/3b6b844bc39e897bd0bbb69897f2deff12dc3893/contracts/src/PuttyV2.sol#L324 https://github.com/code-423n4/2022-06-putty/blob/3b6b844bc39e897bd0bbb69897f2deff12dc3893/contracts/src/PuttyV2.sol#L338 https://github.com/code-423n4/2022-06-putty/blob/3b6b844bc39e897bd0bbb69897f2deff12dc3893/contracts/src/PuttyV2.sol#L436
fillOrder()
and exercise()
have code paths that require Ether to be sent to them (e.g. using WETH as the base asset, or the provision of the exercise price), and therefore those two functions have the payable
modifier. However, there are code paths within those functions that do not require Ether. Ether passed to the functions, when the non-Ether code paths are taken, is locked in the contract forever, and the sender gets nothing extra in return for it.
Ether can't be pulled from the order.maker
during the filling of a long order, so msg.value
shouldn't be provided here:
File: contracts/src/PuttyV2.sol #1 323 if (order.isLong) { 324 ERC20(order.baseAsset).safeTransferFrom(order.maker, msg.sender, order.premium); 325 } else {
If the baseAsset
isn't WETH during order fulfillment, msg.value
is unused:
File: contracts/src/PuttyV2.sol #2 337 } else { 338 ERC20(order.baseAsset).safeTransferFrom(msg.sender, order.maker, order.premium); 339 }
Same for the exercise of call options:
File: contracts/src/PuttyV2.sol #3 435 } else { 436 ERC20(order.baseAsset).safeTransferFrom(msg.sender, address(this), order.strike); 437 }
Code inspection
Add a require(0 == msg.value)
for the above three conditions
#0 - GalloDaSballo
2022-07-05T01:53:19Z
Why would the caller send ETH when they don't have to?
#1 - sseefried
2022-07-05T04:41:00Z
User error is one possibility.
#2 - outdoteth
2022-07-08T13:39:27Z
Report: Native ETH can be lost if it’s not utilised in exercise and fillOrder
#3 - outdoteth
2022-07-15T10:33:35Z
PR with fix: https://github.com/outdoteth/putty-v2/pull/5
🌟 Selected for report: cccz
Also found by: IllIllI, minhquanym
622.994 USDC - $622.99
Judge has assessed an item in Issue #228 as Medium risk. The relevant finding follows:
#0 - HickupHH3
2022-07-15T14:26:00Z
dup of #16
🌟 Selected for report: horsefacts
Also found by: 0xc0ffEE, IllIllI, Picodes, Sm4rty, berndartmueller, csanuragjain, shenwilly, unforgiven
35.1904 USDC - $35.19
https://github.com/code-423n4/2022-06-putty/blob/3b6b844bc39e897bd0bbb69897f2deff12dc3893/contracts/src/PuttyV2.sol#L303 https://github.com/code-423n4/2022-06-putty/blob/3b6b844bc39e897bd0bbb69897f2deff12dc3893/contracts/src/PuttyV2.sol#L308
_mint()
is discouraged in favor of _safeMint()
which ensures that the recipient is either an EOA or implements IERC721Receiver
. Both OpenZeppelin and solmate have versions of this function.
Putty uses ERC-721 tokens as the method of tracking put/call ownership, and if they're sent to an address that cannot handle them, the options will be unusable and worthless.
Orders are filled by the buyer of the option, not the order.maker
, so the maker may have specified an address that cannot handle NFTs without realizing it. They'll only realize their mistake when they find that they have no way to exercise an in-the-money position:
File: contracts/src/PuttyV2.sol #1 303 _mint(order.maker, uint256(orderHash));
The taker too may be unable to handle NFTs, but this is less likely as they're the one interacting with the Putty contracts directly as msg.sender
:
File: contracts/src/PuttyV2.sol #2 305 // create opposite long/short position for taker 306 bytes32 oppositeOrderHash = hashOppositeOrder(order); 307 positionId = uint256(oppositeOrderHash); 308 _mint(msg.sender, positionId);
Code inspection
Use _safeMint()
instead of _mint()
#0 - outdoteth
2022-07-06T19:39:52Z
Duplicate: Contracts that can’t handle ERC721 tokens will lose their Putty ERC721 position tokens: https://github.com/code-423n4/2022-06-putty-findings/issues/327
🌟 Selected for report: berndartmueller
Also found by: IllIllI, Lambda, TrungOre, auditor0517, hansfriese, sashik_eth, shenwilly
137.9519 USDC - $137.95
A necessary part of a put option is the receipt of the assets 'put' to the put-option seller, when the put buyer exercises the option. If the seller is unable to get the assets put to them, they lose their capital.
If order.baseAsset
is a token that reverts if the amount transferred is zero, and the fee ends up being zero, all attempts to withdraw assets will fail:
File: contracts/src/PuttyV2.sol #1 499 feeAmount = (order.strike * fee) / 1000; 500 ERC20(order.baseAsset).safeTransfer(owner(), feeAmount);
LEND is a specific example of an extant token that has this behavior, and will cause assets to be locked.
Code inspection
Do not call safeTransfer()
if feeAmount
is zero
#0 - outdoteth
2022-07-07T12:49:00Z
Duplicate: withdraw() can be DOS’d for baseAsset ERC20s that prevent 0 transfers if the calculated feeAmount is 0 due to rounding: https://github.com/code-423n4/2022-06-putty-findings/issues/283
🌟 Selected for report: xiaoming90
Also found by: 0x1f8b, 0x29A, 0x52, 0xDjango, 0xNazgul, 0xNineDec, 0xSolus, 0xf15ers, 0xsanson, AmitN, Bnke0x0, BowTiedWardens, Chom, David_, ElKu, Funen, GalloDaSballo, GimelSec, Hawkeye, IllIllI, JC, JohnSmith, Kaiziron, Kenshin, Lambda, Limbooo, MadWookie, Metatron, MiloTruck, Nethermind, Picodes, ReyAdmirado, Sneakyninja0129, StErMi, TomJ, Treasure-Seeker, TrungOre, Waze, Yiko, _Adam, __141345__, antonttc, async, aysha, catchup, cccz, cryptphi, csanuragjain, danb, datapunk, defsec, delfin454000, dirk_y, doddle0x, durianSausage, exd0tpy, fatherOfBlocks, gogo, hake, hansfriese, horsefacts, hubble, itsmeSTYJ, joestakey, oyc_109, pedroais, peritoflores, rajatbeladiya, reassor, robee, rokinot, samruna, saneryee, sashik_eth, shenwilly, shung, simon135, sseefried, unforgiven, zer0dot, zzzitron
164.1296 USDC - $164.13
Issue | Instances | |
---|---|---|
1 | Putty does not follow ERC721 standard | 1 |
2 | NatSpec function description is misleading | 1 |
3 | Fees can be bypassed using wrapper tokens | 1 |
4 | No support for Punks, Kitties, and ERC-1155s | 1 |
5 | abi.encodePacked() should not be used with dynamic types when passing the result to a hash function such as keccak256() | 3 |
Total: 7 instances over 5 issues
Issue | Instances | |
---|---|---|
1 | Incomplete description of functionality | 1 |
2 | Consider revert() ing if final if -condition is not satisfied | 1 |
3 | Avoid the use of sensitive terms | 1 |
4 | Don't use periods with fragments | 1 |
5 | Adding a return statement when the function defines a named return variable, is redundant | 4 |
6 | public functions not called by the contract should be declared external instead | 5 |
7 | constant s should be defined rather than using magic numbers | 5 |
8 | Expressions for constant values such as a call to keccak256() , should use immutable rather than constant | 3 |
9 | Typos | 3 |
10 | File is missing NatSpec | 1 |
11 | Event is missing indexed fields | 2 |
12 | Duplicated require() /revert() checks should be refactored to a modifier or function | 4 |
Total: 31 instances over 12 issues
Putty
does not follow ERC721 standardbalanceOf()
returns the wrong value for all users. It's not 'reasonable' to disregard the standard to just save gas. The purpose of a standard is to ensure interoperability, and this current code breaks that. We file a lot of medium-risk issues on various projects because Tether (USDT) does things in a non-standard way which leads to bugs in other people's code because they don't know about the idiosyncrasies. This project is introducing yet another potential attack vector.
There is 1 instance of this issue:
File: contracts/src/PuttyV2Nft.sol #1 7 // removes balanceOf modifications 8 // questionable tradeoff but given our use-case it's reasonable 9: abstract contract PuttyV2Nft is ERC721("Putty", "OPUT") {
Not all orders can be batched; orders using native Ether intentionally cannot be batched. This limitation should be outlined in the NatSpec
There is 1 instance of this issue:
File: contracts/src/PuttyV2.sol #1 539 /** 540 @notice Batch fills multiple orders. 541 @param orders The orders to fill. 542 @param signatures The signatures to use for each respective order. 543 @param floorAssetTokenIds The floorAssetTokenIds to use for each respective order. 544 @return positionIds The ids of the position NFT that the msg.sender receives. 545 */ 546 function batchFillOrder( 547 Order[] memory orders, 548 bytes[] calldata signatures, 549 uint256[][] memory floorAssetTokenIds 550: ) public returns (uint256[] memory positionIds) {
If a user wishes to avoid fees, they can wrap their base asset in another token with fewer decimals (e.g. none) and cause the fee to be zero. Obviously this cannot be solved for custom tokens that have arbitrary logic, but tokens that deterministically scale amounts (e.g. one where balanceOf()
returns underlying.balanceOf() / 1e18
can be dealt with by rejecting any order where the fee ends up being zero during fillOrder()
There is 1 instance of this issue:
File: contracts/src/PuttyV2.sol #1 499 feeAmount = (order.strike * fee) / 1000; 500: ERC20(order.baseAsset).safeTransfer(owner(), feeAmount);
CryptoPunks and CryptoKitties came before the ERC-721 standard and thus do not support them. A lot of new NFTs follow the ERC-1155 standard rather than the ERC-721 standard. Consider adding Putty-specific wrappers for these sorts of NFTs so that your users don't have to look elsewhere to sell their NFTs
There is 1 instance of this issue:
File: contracts/src/PuttyV2.sol #1 610 function _transferERC721sIn(ERC721Asset[] memory assets, address from) internal { 611 for (uint256 i = 0; i < assets.length; i++) { 612 ERC721(assets[i].token).safeTransferFrom(from, address(this), assets[i].tokenId); 613 } 614: }
abi.encodePacked()
should not be used with dynamic types when passing the result to a hash function such as keccak256()
Use abi.encode()
instead which will pad items to 32 bytes, which will prevent hash collisions (e.g. abi.encodePacked(0x123,0x456)
=> 0x123456
=> abi.encodePacked(0x1,0x23456)
, but abi.encode(0x123,0x456)
=> 0x0...1230...456
). "Unless there is a compelling reason, abi.encode
should be preferred". If there is only one argument to abi.encodePacked()
it can often be cast to bytes()
or bytes32()
instead.
If all arguments are strings and or bytes, bytes.concat()
should be used instead
There are 3 instances of this issue:
File: contracts/src/PuttyV2.sol #1 90: keccak256(abi.encodePacked("ERC721Asset(address token,uint256 tokenId)"));
File: contracts/src/PuttyV2.sol #2 96: keccak256(abi.encodePacked("ERC20Asset(address token,uint256 tokenAmount)"));
File: contracts/src/PuttyV2.sol #3 102 keccak256( 103 abi.encodePacked( 104 "Order(", 105 "address maker,", 106 "bool isCall,", 107 "bool isLong,", 108 "address baseAsset,", 109 "uint256 strike,", 110 "uint256 premium,", 111 "uint256 duration,", 112 "uint256 expiration,", 113 "uint256 nonce," 114 "address[] whitelist,", 115 "address[] floorTokens,", 116 "ERC20Asset[] erc20Assets,", 117 "ERC721Asset[] erc721Assets", 118 ")", 119 "ERC20Asset(address token,uint256 tokenAmount)", 120 "ERC721Asset(address token,uint256 tokenId)" 121 ) 122: );
Not only is EIP-712 checked, but isValidSignatureNow()
also does EIP-1271 contract verification as well
There is 1 instance of this issue:
File: contracts/src/PuttyV2.sol #1 277 // check signature is valid using EIP-712 278: require(SignatureChecker.isValidSignatureNow(order.maker, orderHash, signature), "Invalid signature");
revert()
ing if final if
-condition is not satisfiedThe fillOrder()
function has a series of if
-statements covering all currently-possible scenarios. If, however, in the future new scenarios are added, one of the conditions may be missed, and the function will return the uninitialized return variable. Consider adding a revert(false)
to the end of the function to catch any such mistakes
There is 1 instance of this issue:
File: contracts/src/PuttyV2.sol #1 268 function fillOrder( 269 Order memory order, 270 bytes calldata signature, 271 uint256[] memory floorAssetTokenIds 272: ) public payable returns (uint256 positionId) {
Use alternative variants, e.g. allowlist/denylist instead of whitelist/blacklist
There is 1 instance of this issue:
File: contracts/src/PuttyV2.sol #1 284: require(order.whitelist.length == 0 || isWhitelisted(order.whitelist, msg.sender), "Not whitelisted");
Throughout the files, most of the comments have fragments that end with periods. They should either be converted to actual sentences with both a noun phrase and a verb phrase, or the periods should be removed.
There is 1 instance of this issue:
File: contracts/src/PuttyV2.sol (various lines) #1
return
statement when the function defines a named return variable, is redundantThere are 4 instances of this issue:
File: contracts/src/PuttyV2.sol #1 345: return positionId;
File: contracts/src/PuttyV2.sol #2 363: return positionId;
File: contracts/src/PuttyV2.sol #3 370: return positionId;
File: contracts/src/PuttyV2.sol #4 378: return positionId;
public
functions not called by the contract should be declared external
insteadContracts are allowed to override their parents' functions and change the visibility from external
to public
.
There are 5 instances of this issue:
File: contracts/src/PuttyV2.sol 389: function exercise(Order memory order, uint256[] calldata floorAssetTokenIds) public payable { 466: function withdraw(Order memory order) public { 546 function batchFillOrder( 547 Order[] memory orders, 548 bytes[] calldata signatures, 549 uint256[][] memory floorAssetTokenIds 550: ) public returns (uint256[] memory positionIds) { 573 function acceptCounterOffer( 574 Order memory order, 575 bytes calldata signature, 576 Order memory originalOrder 577: ) public payable returns (uint256 positionId) { 753: function domainSeparatorV4() public view returns (bytes32) {
constant
s should be defined rather than using magic numbersEven assembly can benefit from using readable constants instead of hex/numeric literals
There are 5 instances of this issue:
File: contracts/src/PuttyV2.sol /// @audit 30 241: require(_fee < 30, "fee must be less than 3%"); /// @audit 10_000 287: require(order.duration < 10_000 days, "Duration too long"); /// @audit 0xdead 413: transferFrom(msg.sender, address(0xdead), uint256(orderHash)); /// @audit 0xdead 488: transferFrom(msg.sender, address(0xdead), uint256(orderHash)); /// @audit 1000 499: feeAmount = (order.strike * fee) / 1000;
keccak256()
, should use immutable
rather than constant
There are 3 instances of this issue:
File: contracts/src/PuttyV2.sol #1 89 bytes32 public constant ERC721ASSET_TYPE_HASH = 90: keccak256(abi.encodePacked("ERC721Asset(address token,uint256 tokenId)"));
File: contracts/src/PuttyV2.sol #2 95 bytes32 public constant ERC20ASSET_TYPE_HASH = 96: keccak256(abi.encodePacked("ERC20Asset(address token,uint256 tokenAmount)"));
File: contracts/src/PuttyV2.sol #3 101 bytes32 public constant ORDER_TYPE_HASH = 102 keccak256( 103 abi.encodePacked( 104 "Order(", 105 "address maker,", 106 "bool isCall,", 107 "bool isLong,", 108 "address baseAsset,", 109 "uint256 strike,", 110 "uint256 premium,", 111 "uint256 duration,", 112 "uint256 expiration,", 113 "uint256 nonce," 114 "address[] whitelist,", 115 "address[] floorTokens,", 116 "ERC20Asset[] erc20Assets,", 117 "ERC721Asset[] erc721Assets", 118 ")", 119 "ERC20Asset(address token,uint256 tokenAmount)", 120 "ERC721Asset(address token,uint256 tokenId)" 121 ) 122: );
There are 3 instances of this issue:
File: contracts/src/PuttyV2.sol #1 /// @audit offchain 260: @notice Fills an offchain order and settles it onchain. Mints two
File: contracts/src/PuttyV2.sol #2 /// @audit onchain 260: @notice Fills an offchain order and settles it onchain. Mints two
File: contracts/src/PuttyV2.sol #3 /// @audit seperator 751: @return The domain seperator used when calculating the eip-712 hash.
There is 1 instance of this issue:
File: contracts/src/PuttyV2Nft.sol (various lines) #1
indexed
fieldsEach event
should use three indexed
fields if there are three or more fields
There are 2 instances of this issue:
File: contracts/src/PuttyV2.sol #1 171: event NewBaseURI(string baseURI);
File: contracts/src/PuttyV2.sol #2 177: event NewFee(uint256 fee);
require()
/revert()
checks should be refactored to a modifier or functionThe compiler will inline the function, which will avoid JUMP
instructions usually associated with functions
There are 4 instances of this issue:
File: contracts/src/PuttyV2Nft.sol #1 27: require(to != address(0), "INVALID_RECIPIENT");
File: contracts/src/PuttyV2.sol #2 405: ? require(floorAssetTokenIds.length == order.floorTokens.length, "Wrong amount of floor tokenIds")
File: contracts/src/PuttyV2.sol #3 429: require(msg.value == order.strike, "Incorrect ETH amount sent");
File: contracts/src/PuttyV2.sol #4 475: require(ownerOf(uint256(orderHash)) == msg.sender, "Not owner");
🌟 Selected for report: GalloDaSballo
Also found by: 0v3rf10w, 0x1f8b, 0xA5DF, 0xDjango, 0xHarry, 0xKitsune, 0xNazgul, 0xNineDec, 0xc0ffEE, 0xf15ers, 0xkatana, 0xsanson, ACai, Aymen0909, Bnke0x0, BowTiedWardens, Chom, ElKu, Fitraldys, Funen, Haruxe, Hawkeye, IllIllI, JC, JohnSmith, Kaiziron, Kenshin, Lambda, Limbooo, MadWookie, Metatron, MiloTruck, Picodes, PwnedNoMore, Randyyy, RedOneN, ReyAdmirado, Ruhum, Sm4rty, StErMi, StyxRave, TerrierLover, TomJ, Tomio, UnusualTurtle, Waze, Yiko, _Adam, __141345__, ajtra, ak1, apostle0x01, asutorufos, c3phas, cRat1st0s, catchup, codetilda, cryptphi, datapunk, defsec, delfin454000, durianSausage, exd0tpy, fatherOfBlocks, gogo, grrwahrr, hake, hansfriese, horsefacts, ignacio, jayfromthe13th, joestakey, ladboy233, m_Rassska, mektigboy, minhquanym, mrpathfindr, natzuu, oyc_109, rajatbeladiya, reassor, rfa, robee, rokinot, sach1r0, saian, sashik_eth, simon135, slywaters, swit, z3s, zeesaw, zer0dot
84.5476 USDC - $84.55
Issue | Instances | |
---|---|---|
1 | Using EIP-712 hashes for positionId s wastes gas | 1 |
2 | Multiple address /ID mappings can be combined into a single mapping of an address /ID to a struct , where appropriate | 1 |
3 | Using calldata instead of memory for read-only arguments in external functions saves gas | 7 |
4 | State variables should be cached in stack variables rather than re-reading them from storage | 1 |
5 | Multiple accesses of a mapping/array should use a local variable cache | 6 |
6 | <array>.length should not be looked up in every loop of a for -loop | 10 |
7 | ++i /i++ should be unchecked{++i} /unchecked{i++} when it is not possible for them to overflow, as is the case when used in for - and while -loops | 10 |
8 | Optimize names to save gas | 1 |
9 | Using bool s for storage incurs overhead | 2 |
10 | Using > 0 costs more gas than != 0 when used on a uint in a require() statement | 1 |
11 | It costs more gas to initialize non-constant /non-immutable variables to zero than to let the default of zero be applied | 11 |
12 | ++i costs less gas than i++ , especially when it's used in for -loops (--i /i-- too) | 10 |
13 | Using private rather than public for constants, saves gas | 3 |
14 | Inverting the condition of an if-else-statement wastes gas | 1 |
15 | Use custom errors rather than revert() /require() strings to save gas | 33 |
Total: 98 instances over 15 issues
positionId
s wastes gasEIP-712 introduces a lot of metadata into the data being hashed, in order for it to be able to be read before the data is signed. When the signature isn't being checked, it's inefficient to use these hashes over normal keccak256()
hashes. This gist shows that the most simple order (all zero-byte values, no internal arrays) has an overhead of 658 gas as compared to normal hashing. More complicated orders, especially with internal arrays, will save even more. Message signing should be separate from positionId
calculation, which will save gas. These hashes are used in all of the major functions of the project, so savings over time will be significant
There is 1 instance of this issue:
File: contracts/src/PuttyV2.sol #1 305 // create opposite long/short position for taker 306 bytes32 oppositeOrderHash = hashOppositeOrder(order); 307 positionId = uint256(oppositeOrderHash); 308: _mint(msg.sender, positionId);
https://github.com/code-423n4/2022-06-putty/blob/main/contracts/src/PuttyV2.sol#L305-L308
address
/ID mappings can be combined into a single mapping
of an address
/ID to a struct
, where appropriateSaves a storage slot for the mapping. Depending on the circumstances and sizes of types, can avoid a Gsset (20000 gas) per mapping combined. Reads and subsequent writes can also be cheaper when a function requires both values and they both fit in the same storage slot. Finally, if both fields are accessed in the same function, can save ~42 gas per access due to not having to recalculate the key's keccak256 hash (Gkeccak256 - 30 gas) and that calculation's associated stack operations.
There is 1 instance of this issue:
File: contracts/src/PuttyV2.sol #1 149 mapping(uint256 => uint256) public positionExpirations; 150 151 /** 152 @notice Whether or not a position has been exercised. Maps 153 from positionId to isExercised. 154 */ 155 mapping(uint256 => bool) public exercisedPositions; 156 157 /** 158 @notice The floor asset token ids for a position. Maps from 159 positionId to floor asset token ids. This should only 160 be set for a long call position in `fillOrder`, or for 161 a short put position in `exercise`. 162 */ 163: mapping(uint256 => uint256[]) public positionFloorAssetTokenIds;
calldata
instead of memory
for read-only arguments in external
functions saves gasWhen a function with a memory
array is called externally, the abi.decode()
step has to use a for-loop to copy each index of the calldata
to the memory
index. Each iteration of this for-loop costs at least 60 gas (i.e. 60 * <mem_array>.length
). Using calldata
directly, obliviates the need for such a loop in the contract code and runtime execution.
If the array is passed to an internal
function which passes the array to another internal function where the array is modified and therefore memory
is used in the external
call, it's still more gass-efficient to use calldata
when the external
function uses modifiers, since the modifiers may prevent the internal functions from being called. Structs have the same overhead as an array of length one
Note that I've also flagged instances where the function is public
but can be marked as external
since it's not called by the contract, and cases where a constructor is involved
There are 7 instances of this issue:
File: contracts/src/PuttyV2.sol /// @audit _baseURI 209 constructor( 210 string memory _baseURI, 211 uint256 _fee, 212: address _weth /// @audit order 389: function exercise(Order memory order, uint256[] calldata floorAssetTokenIds) public payable { /// @audit order 466: function withdraw(Order memory order) public { /// @audit orders 546 function batchFillOrder( 547 Order[] memory orders, 548 bytes[] calldata signatures, 549 uint256[][] memory floorAssetTokenIds 550: ) public returns (uint256[] memory positionIds) { /// @audit floorAssetTokenIds 546 function batchFillOrder( 547 Order[] memory orders, 548 bytes[] calldata signatures, 549 uint256[][] memory floorAssetTokenIds 550: ) public returns (uint256[] memory positionIds) { /// @audit order 573 function acceptCounterOffer( 574 Order memory order, 575 bytes calldata signature, 576 Order memory originalOrder 577: ) public payable returns (uint256 positionId) { /// @audit originalOrder 573 function acceptCounterOffer( 574 Order memory order, 575 bytes calldata signature, 576 Order memory originalOrder 577: ) public payable returns (uint256 positionId) {
The instances below point to the second+ access of a state variable within a function. Caching of a state variable replace each Gwarmaccess (100 gas) with a much cheaper stack read. Other less obvious fixes/optimizations include having local memory caches of state variable structs, or having local caches of state variable contracts/addresses.
There is 1 instance of this issue:
File: contracts/src/PuttyV2.sol #1 /// @audit fee on line 498 499: feeAmount = (order.strike * fee) / 1000;
The instances below point to the second+ access of a value inside a mapping/array, within a function. Caching a mapping's value in a local storage
or calldata
variable when the value is accessed multiple times, saves ~42 gas per access due to not having to recalculate the key's keccak256 hash (Gkeccak256 - 30 gas) and that calculation's associated stack operations. Caching an array's struct avoids recalculating the array offsets into memory/calldata
There are 6 instances of this issue:
File: contracts/src/PuttyV2.sol /// @audit assets[i] on line 595 596: uint256 tokenAmount = assets[i].tokenAmount; /// @audit assets[i] on line 612 612: ERC721(assets[i].token).safeTransferFrom(from, address(this), assets[i].tokenId); /// @audit assets[i] on line 638 638: ERC20(assets[i].token).safeTransfer(msg.sender, assets[i].tokenAmount); /// @audit assets[i] on line 648 648: ERC721(assets[i].token).safeTransferFrom(address(this), msg.sender, assets[i].tokenId); /// @audit arr[i] on line 731 731: keccak256(abi.encode(ERC20ASSET_TYPE_HASH, arr[i].token, arr[i].tokenAmount)) /// @audit arr[i] on line 745 745: keccak256(abi.encode(ERC721ASSET_TYPE_HASH, arr[i].token, arr[i].tokenId))
<array>.length
should not be looked up in every loop of a for
-loopThe overheads outlined below are PER LOOP, excluding the first loop
MLOAD
(3 gas)CALLDATALOAD
(3 gas)Caching the length changes each of these to a DUP<N>
(3 gas), and gets rid of the extra DUP<N>
needed to store the stack offset
There are 10 instances of this issue:
File: contracts/src/PuttyV2.sol 556: for (uint256 i = 0; i < orders.length; i++) { 594: for (uint256 i = 0; i < assets.length; i++) { 611: for (uint256 i = 0; i < assets.length; i++) { 627: for (uint256 i = 0; i < floorTokens.length; i++) { 637: for (uint256 i = 0; i < assets.length; i++) { 647: for (uint256 i = 0; i < assets.length; i++) { 658: for (uint256 i = 0; i < floorTokens.length; i++) { 670: for (uint256 i = 0; i < whitelist.length; i++) { 728: for (uint256 i = 0; i < arr.length; i++) { 742: for (uint256 i = 0; i < arr.length; i++) {
++i
/i++
should be unchecked{++i}
/unchecked{i++}
when it is not possible for them to overflow, as is the case when used in for
- and while
-loopsThe unchecked
keyword is new in solidity version 0.8.0, so this only applies to that version or higher, which these instances are. This saves 30-40 gas per loop
There are 10 instances of this issue:
File: contracts/src/PuttyV2.sol 556: for (uint256 i = 0; i < orders.length; i++) { 594: for (uint256 i = 0; i < assets.length; i++) { 611: for (uint256 i = 0; i < assets.length; i++) { 627: for (uint256 i = 0; i < floorTokens.length; i++) { 637: for (uint256 i = 0; i < assets.length; i++) { 647: for (uint256 i = 0; i < assets.length; i++) { 658: for (uint256 i = 0; i < floorTokens.length; i++) { 670: for (uint256 i = 0; i < whitelist.length; i++) { 728: for (uint256 i = 0; i < arr.length; i++) { 742: for (uint256 i = 0; i < arr.length; i++) {
public
/external
function names and public
member variable names can be optimized to save gas. See this link for an example of how it works. Below are the interfaces/abstract contracts that can be optimized so that the most frequently-called functions use the least amount of gas possible during method lookup. Method IDs that have two leading zero bytes can save 128 gas each during deployment, and renaming functions to have lower method IDs will save 22 gas per call, per sorted position shifted
There is 1 instance of this issue:
File: contracts/src/PuttyV2.sol #1 /// @audit setBaseURI(), setFee(), fillOrder(), exercise(), withdraw(), cancel(), batchFillOrder(), acceptCounterOffer(), isWhitelisted(), hashOppositeOrder(), hashOrder(), encodeERC20Assets(), encodeERC721Assets(), domainSeparatorV4() 53: contract PuttyV2 is PuttyV2Nft, EIP712("Putty", "2.0"), ERC721TokenReceiver, Ownable {
bool
s for storage incurs overhead// Booleans are more expensive than uint256 or any type that takes up a full // word because each write operation emits an extra SLOAD to first read the // slot's contents, replace the bits taken up by the boolean, and then write // back. This is the compiler's defense against contract upgrades and // pointer aliasing, and it cannot be disabled.
https://github.com/OpenZeppelin/openzeppelin-contracts/blob/58f635312aa21f947cae5f8578638a85aa2519f5/contracts/security/ReentrancyGuard.sol#L23-L27
Use uint256(1)
and uint256(2)
for true/false to avoid a Gwarmaccess (100 gas) for the extra SLOAD, and to avoid Gsset (20000 gas) when changing from false
to true
, after having been true
in the past
There are 2 instances of this issue:
File: contracts/src/PuttyV2.sol #1 143: mapping(bytes32 => bool) public cancelledOrders;
File: contracts/src/PuttyV2.sol #2 155: mapping(uint256 => bool) public exercisedPositions;
> 0
costs more gas than != 0
when used on a uint
in a require()
statementThis change saves 6 gas per instance. The optimization works until solidity version 0.8.13 where there is a regression in gas costs.
There is 1 instance of this issue:
File: contracts/src/PuttyV2.sol #1 599: require(tokenAmount > 0, "ERC20: Amount too small");
constant
/non-immutable
variables to zero than to let the default of zero be appliedNot overwriting the default for stack variables saves 8 gas. Storage and memory variables have larger savings
There are 11 instances of this issue:
File: contracts/src/PuttyV2.sol 497: uint256 feeAmount = 0; 556: for (uint256 i = 0; i < orders.length; i++) { 594: for (uint256 i = 0; i < assets.length; i++) { 611: for (uint256 i = 0; i < assets.length; i++) { 627: for (uint256 i = 0; i < floorTokens.length; i++) { 637: for (uint256 i = 0; i < assets.length; i++) { 647: for (uint256 i = 0; i < assets.length; i++) { 658: for (uint256 i = 0; i < floorTokens.length; i++) { 670: for (uint256 i = 0; i < whitelist.length; i++) { 728: for (uint256 i = 0; i < arr.length; i++) { 742: for (uint256 i = 0; i < arr.length; i++) {
++i
costs less gas than i++
, especially when it's used in for
-loops (--i
/i--
too)Saves 5 gas per loop
There are 10 instances of this issue:
File: contracts/src/PuttyV2.sol 556: for (uint256 i = 0; i < orders.length; i++) { 594: for (uint256 i = 0; i < assets.length; i++) { 611: for (uint256 i = 0; i < assets.length; i++) { 627: for (uint256 i = 0; i < floorTokens.length; i++) { 637: for (uint256 i = 0; i < assets.length; i++) { 647: for (uint256 i = 0; i < assets.length; i++) { 658: for (uint256 i = 0; i < floorTokens.length; i++) { 670: for (uint256 i = 0; i < whitelist.length; i++) { 728: for (uint256 i = 0; i < arr.length; i++) { 742: for (uint256 i = 0; i < arr.length; i++) {
private
rather than public
for constants, saves gasIf needed, the values can be read from the verified contract source code, or if there are multiple values there can be a single getter function that returns a tuple of the values of all currently-public constants. Saves 3406-3606 gas in deployment gas due to the compiler not having to create non-payable getter functions for deployment calldata, not having to store the bytes of the value outside of where it's used, and not adding another entry to the method ID table
There are 3 instances of this issue:
File: contracts/src/PuttyV2.sol #1 89 bytes32 public constant ERC721ASSET_TYPE_HASH = 90: keccak256(abi.encodePacked("ERC721Asset(address token,uint256 tokenId)"));
File: contracts/src/PuttyV2.sol #2 95 bytes32 public constant ERC20ASSET_TYPE_HASH = 96: keccak256(abi.encodePacked("ERC20Asset(address token,uint256 tokenAmount)"));
File: contracts/src/PuttyV2.sol #3 101 bytes32 public constant ORDER_TYPE_HASH = 102 keccak256( 103 abi.encodePacked( 104 "Order(", 105 "address maker,", 106 "bool isCall,", 107 "bool isLong,", 108 "address baseAsset,", 109 "uint256 strike,", 110 "uint256 premium,", 111 "uint256 duration,", 112 "uint256 expiration,", 113 "uint256 nonce," 114 "address[] whitelist,", 115 "address[] floorTokens,", 116 "ERC20Asset[] erc20Assets,", 117 "ERC721Asset[] erc721Assets", 118 ")", 119 "ERC20Asset(address token,uint256 tokenAmount)", 120 "ERC721Asset(address token,uint256 tokenId)" 121 ) 122: );
if
-else
-statement wastes gasFlipping the true
and false
blocks instead saves 3 gas
There is 1 instance of this issue:
File: contracts/src/PuttyV2.sol #1 404 !order.isCall 405 ? require(floorAssetTokenIds.length == order.floorTokens.length, "Wrong amount of floor tokenIds") 406: : require(floorAssetTokenIds.length == 0, "Invalid floor tokenIds length");
revert()
/require()
strings to save gasCustom errors are available from solidity version 0.8.4. Custom errors save ~50 gas each time they're hit by avoiding having to allocate and store the revert string. Not defining the strings also save deployment gas
There are 33 instances of this issue:
File: contracts/src/PuttyV2Nft.sol 12: require(to != address(0), "INVALID_RECIPIENT"); 13: require(_ownerOf[id] == address(0), "ALREADY_MINTED"); 26: require(from == _ownerOf[id], "WRONG_FROM"); 27: require(to != address(0), "INVALID_RECIPIENT"); 28 require( 29 msg.sender == from || isApprovedForAll[from][msg.sender] || msg.sender == getApproved[id], 30 "NOT_AUTHORIZED" 31: ); 41: require(owner != address(0), "ZERO_ADDRESS");
File: contracts/src/PuttyV2.sol 214: require(_weth != address(0), "Unset weth address"); 241: require(_fee < 30, "fee must be less than 3%"); 278: require(SignatureChecker.isValidSignatureNow(order.maker, orderHash, signature), "Invalid signature"); 281: require(!cancelledOrders[orderHash], "Order has been cancelled"); 284: require(order.whitelist.length == 0 || isWhitelisted(order.whitelist, msg.sender), "Not whitelisted"); 287: require(order.duration < 10_000 days, "Duration too long"); 290: require(block.timestamp < order.expiration, "Order has expired"); 293: require(order.baseAsset.code.length > 0, "baseAsset is not contract"); 297: ? require(floorAssetTokenIds.length == order.floorTokens.length, "Wrong amount of floor tokenIds") 298: : require(floorAssetTokenIds.length == 0, "Invalid floor tokens length"); 329: require(msg.value == order.premium, "Incorrect ETH amount sent"); 353: require(msg.value == order.strike, "Incorrect ETH amount sent"); 395: require(ownerOf(uint256(orderHash)) == msg.sender, "Not owner"); 398: require(order.isLong, "Can only exercise long positions"); 401: require(block.timestamp < positionExpirations[uint256(orderHash)], "Position has expired"); 405: ? require(floorAssetTokenIds.length == order.floorTokens.length, "Wrong amount of floor tokenIds") 406: : require(floorAssetTokenIds.length == 0, "Invalid floor tokenIds length"); 429: require(msg.value == order.strike, "Incorrect ETH amount sent"); 470: require(!order.isLong, "Must be short position"); 475: require(ownerOf(uint256(orderHash)) == msg.sender, "Not owner"); 481: require(block.timestamp > positionExpirations[longPositionId] || isExercised, "Must be exercised or expired"); 527: require(msg.sender == order.maker, "Not your order"); 551: require(orders.length == signatures.length, "Length mismatch in input"); 552: require(signatures.length == floorAssetTokenIds.length, "Length mismatch in input"); 598: require(token.code.length > 0, "ERC20: Token is not contract"); 599: require(tokenAmount > 0, "ERC20: Amount too small"); 765: require(_ownerOf[id] != address(0), "URI query for NOT_MINTED token");