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: 39/133
Findings: 4
Award: $148.61
๐ Selected for report: 0
๐ Solo Findings: 0
๐ 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
82.1485 USDC - $82.15
Few vulnerabilities were found. The main concerns are with:
strings and bytes are encoded with padding when using abi.encodePacked
. This can lead to hash collision when passing the result to keccak256
Low
Two instances where abi.encodePacked
is used with bytes
arguments:
encoded = abi.encodePacked(encoded,keccak256(abi.encode(ERC20ASSET_TYPE_HASH, arr[i].token, arr[i].tokenAmount)))
encoded = abi.encodePacked(encoded,keccak256(abi.encode(ERC721ASSET_TYPE_HASH, arr[i].token, arr[i].tokenId)))
Manual Analysis
Use abi.encode()
instead.
_mint()
defined in PuttyV2Nft.sol
does not checks whether to
can handle ERC721 tokens if it is not an EOA. The function is called in PuttyV2.fillOrder
to mint the tokens representing the long and short position for the order. If either order.maker
or the caller is a contract not handling ERC721 tokens, one of the minted tokens will be lost, which can result in either exercise
or withdraw
not being able to be called
Low
Instances include:
scope: fillOrder
_mint(order.maker, uint256(orderHash))
_mint(msg.sender, positionId)
Manual Analysis
Add a check in _mint
to ensure to
implements an ERC721Receiver if it is not an EOA
There should be a require(0 == msg.value)
to ensure no Ether is being sent to PuttyV2.sol
when the order.baseAsset
used in an order is a ERC20 token. As there is no way to withdraw ETH from the contract, any ETH mistakenly sent when order.baseAsset
is an ERC20 token will result in locked ETH in the contract.
Low
Instances include:
scope: fillOrder
order.baseAsset
is an ERC20 token, there is no check to see if msg.value == 0
. Same issue herescope: exercise
order.baseAsset
is an ERC20 token, there is no check to see if msg.value == 0
. Same issue hereManual Analysis
Add require(0 == msg.value)
in all blocks mentioned above.
It is best practice to use constant variables rather than literal values to make the code easier to understand and maintain.
Non-Critical
Instances include:
require(_fee < 30, "fee must be less than 3%")
require(order.duration < 10_000 days, "Duration too long")
feeAmount = (order.strike * fee) / 1000
Manual Analysis
Define constant variables for the literal values aforementioned.
Events should be emitted at the end of a function call, to prevent emission if the function reverts.
Non-Critical
Instances include:
All the following events are emitted before external calls are performed.
emit FilledOrder(orderHash, floorAssetTokenIds, order)
emit ExercisedOrder(orderHash, floorAssetTokenIds, order)
emit WithdrawOrder(orderHash, order)
Manual Analysis
Emit events at the end of functions.
Events should use indexed fields, to make them easier to filter in logs.
Non-Critical
Instances include:
event NewBaseURI(string baseURI)
event NewFee(uint256 fee)
Manual Analysis
Add indexed fields to these events.
Functions should be ordered following the Soldiity conventions. This means ordering functions based on their visibility.
Non-Critical
Several contracts have public functions defined after internal functions:
PuttyV2.sol
PuttyV2Nft.sol
Manual Analysis
Place the public
functions internal
functions.
`
It is good practice to mark functions as external
instead of public
if they are not called by the contract where they are defined.
Non-Critical
Instances include:
function exercise()
function withdraw()
function batchFillOrder()
function acceptCounterOffer()
function domainSeparatorV4()
Manual Analysis
Declare this function as external
instead of public
When there are mappings that use the same key value, having separate fields is error prone, for instance in case of deletion or with future new fields.
Non-Critical
Instances include:
mapping(uint256 => uint256) public positionExpirations
mapping(uint256 => bool) public exercisedPositions
mapping(uint256 => uint256[]) public positionFloorAssetTokenIds
Manual Analysis
Group the related data in a struct and use one mapping. For instance, for the PuttyV2.sol
mappings, the mitigation could be:
struct Position { uint256 expiration; bool exercised; uint256[] floorAssetTokenId; }
And it would be used as a state variable:
mapping(uint256 => Position) public positions;
For readability, it is best to use scientific notation (e.g 10e4
) rather than decimal literals(10_000
) or exponentiation(10**4
)
Non-Critical
Instances include:
require(order.duration < 10_000 days, "Duration too long")
feeAmount = (order.strike * fee) / 1000
Manual Analysis
Replace the numbers aforementioned with their scientific notation. Consider also writing replacing two numbers with constant variables
It is good practice to give time for users to react and adjust to critical changes. A timelock provides more guarantees and reduces the level of trust required, thus decreasing risk for users. Here it is merely a suggestion as setFee
already has a very reasonable upper boundary.
Non-Critical
Instances include:
function setBaseURI
function setFee
Manual Analysis
Consider adding a timelock to these setters
The current ownership transfer process uses OpenZeppelin Ownable
contract, where the current owner writes the next owner and the change is immediate. You can consider using 2-step process, where the new owner has to accept the ownership. preventing passing the ownership to an uncontrolled account.
Non-Critical
Manual Analysis
Adding a return statement when the function defines a named return variable is redundant.
Non-Critical
Instances include:
positionId loops through the array of addresses and delete the address to remove when it finds it. The costs of calling this function may vary drastically depending on the location of the address in the array and how large the array is
Manual Analysis
You don't need to explicitly return positionId
.
#0 - outdoteth
2022-07-07T19:12:27Z
_mint does not check if recipient can handle tokens
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
Payable functions when using ERC20
Duplicate: Native ETH can be lost if itโs not utilised in exercise and fillOrder: https://github.com/code-423n4/2022-06-putty-findings/issues/226
#1 - outdoteth
2022-07-07T19:12:33Z
high quality report
๐ 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
25.7456 USDC - $25.75
It wastes gas to read an array's length in every iteration of a for
loop, even if it is a memory or calldata array: 3
gas per read.
10 instances:
for (uint256 i = 0; i < orders.length; i++)
for (uint256 i = 0; i < assets.length; i++)
for (uint256 i = 0; i < assets.length; i++)
for (uint256 i = 0; i < floorTokens.length; i++)
for (uint256 i = 0; i < assets.length; i++)
for (uint256 i = 0; i < assets.length; i++)
for (uint256 i = 0; i < floorTokens.length; i++)
for (uint256 i = 0; i < whitelist.length; i++)
for (uint256 i = 0; i < arr.length; i++)
for (uint256 i = 0; i < arr.length; i++)
Manual Analysis
Caching the lengths in a variable before the for
loop
If a reference type function parameter is read-only, it is cheaper in gas to use calldata instead of memory.
Calldata is a non-modifiable, non-persistent area where function arguments are stored, and behaves mostly like memory,but it alleviates the compiler from the abi.decode()
step that copies each index of the calldata to the memory index, each iteration costing 60
gas.
Instances:
scope: fillOrder()
Order memory order
uint256[] memory floorAssetTokenIds
scope: exercise()
scope: exercise()
scope: withdraw()
scope: cancel()
scope: batchFillOrder()
Order memory order
uint256[] memory floorAssetTokenIds
scope: batchFillOrder()
Order memory order
Order memory originalOrder
scope: _transferERC20sIn()
scope: _transferERC721sIn()
scope: _transferFloorsIn()
address[] memory floorTokens, uint256[] memory floorTokenIds
scope: _transferERC20sOut()
scope: _transferERC721sOut()
scope: _transferFloorsOut()
address[] memory floorTokens, uint256[] memory floorTokenIds
scope: isWhitelisted()
scope: hashOppositeOrder()
scope: encodeERC20Assets()
scope: encodeERC721Assets()
Manual Analysis
Replace memory
with calldata
Constant expressions are re-calculated each time they are in use, costing an extra 97
gas than a constant every time they are called.
3 instances:
89: bytes32 public constant ERC721ASSET_TYPE_HASH = 90: keccak256(abi.encodePacked("ERC721Asset(address token,uint256 tokenId)"));
95: bytes32 public constant ERC20ASSET_TYPE_HASH = 96: keccak256(abi.encodePacked("ERC20Asset(address token,uint256 tokenAmount)"));
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: );
Manual Analysis
Mark these as immutable
instead of constant
Marking constants as private
can save a significant amount gas upon deployment, as the compiler does not have to create getter functions for these variables. It is worth noting that a private
variable can still be read using either the verified contract source code or the bytecode.
Instances:
Manual Analysis
Make weth
private
instead of public
,
โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโฌโโโโโโโโโฌโโโโโโโโโฌโโโโโโโโโฌโโโโโโโโโโฎ โ PuttyV2 contract โ โ โ โ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโชโโโโโโโโโโโโโโโโโโชโโโโโโโโโชโโโโโโโโโชโโโโโโโโโชโโโโโโโโโโก โ Deployment Cost โ Deployment Size โ โ โ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโผโโโโโโโโโผโโโโโโโโโผโโโโโโโโโผโโโโโโโโโโค โ 4774098 โ 25226 โ โ โ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโผโโโโโโโโโผโโโโโโโโโผโโโโโโโโโผโโโโโโโโโโค
โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโฌโโโโโโโโโฌโโโโโโโโโฌโโโโโโโโโฌโโโโโโโโโโฎ โ PuttyV2 contract โ โ โ โ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโชโโโโโโโโโโโโโโโโโโชโโโโโโโโโชโโโโโโโโโชโโโโโโโโโชโโโโโโโโโโก โ Deployment Cost โ Deployment Size โ โ โ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโผโโโโโโโโโผโโโโโโโโโผโโโโโโโโโผโโโโโโโโโโค โ 4761466 โ 25156 โ โ โ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโผโโโโโโโโโผโโโโโโโโโผโโโโโโโโโผโโโโโโโโโโค
This change saves 12,632
gas upon deployment.
Constructor parameters are expensive. The contract deployment will be cheaper in gas if they are hard coded instead of using constructor parameters.
Instances include:
setBaseURI(_baseURI)
setFee(_fee)
weth = _weth
Manual Analysis
Hardcode weth
with its value instead of writing it during deployment with a constructor parameter. Do the same with baseUri
and fee
, which will also allow you to declare setBaseURI()
and setFee()
as external
instead of public
- which does not save gas but is still a good practice for functions not called by the contract itself.
Custom errors from Solidity 0.8.4 are cheaper than revert strings (cheaper deployment cost and runtime cost when the revert condition is met) while providing the same amount of information, as explained here.
It not only saves gas upon deployment - ~9500
gas saved per custom error instead of a require statement, but it is also cheaper in a function call, 22
gas saved per require statement replaced with a custom error.
Custom errors are defined using the error statement
Instances include:
require(_weth != address(0), "Unset weth address")
require(_fee < 30, "fee must be less than 3%")
require(SignatureChecker.isValidSignatureNow(order.maker, orderHash, signature), "Invalid signature")
require(!cancelledOrders[orderHash], "Order has been cancelled")
require(order.whitelist.length == 0 || isWhitelisted(order.whitelist, msg.sender), "Not whitelisted")
require(order.duration < 10_000 days, "Duration too long")
require(block.timestamp < order.expiration, "Order has expired")
require(order.baseAsset.code.length > 0, "baseAsset is not contract")
require(floorAssetTokenIds.length == order.floorTokens.length, "Wrong amount of floor tokenIds")
require(floorAssetTokenIds.length == 0, "Invalid floor tokens length")
require(msg.value == order.premium, "Incorrect ETH amount sent")
require(msg.value == order.strike, "Incorrect ETH amount sent")
require(ownerOf(uint256(orderHash)) == msg.sender, "Not owner")
require(order.isLong, "Can only exercise long positions")
require(block.timestamp < positionExpirations[uint256(orderHash)], "Position has expired")
require(floorAssetTokenIds.length == order.floorTokens.length, "Wrong amount of floor tokenIds")
require(floorAssetTokenIds.length == 0, "Invalid floor tokens length")
require(msg.value == order.strike, "Incorrect ETH amount sent")
require(!order.isLong, "Must be short position")
require(ownerOf(uint256(orderHash)) == msg.sender, "Not owner")
require(block.timestamp > positionExpirations[longPositionId] || isExercised, "Must be exercised or expired")
require(msg.sender == order.maker, "Not your order")
require(orders.length == signatures.length, "Length mismatch in input")
require(signatures.length == floorAssetTokenIds.length, "Length mismatch in input")
require(token.code.length > 0, "ERC20: Token is not contract")
require(tokenAmount > 0, "ERC20: Amount too small")
require(_ownerOf[id] != address(0), "URI query for NOT_MINTED token")
require(to != address(0), "INVALID_RECIPIENT")
require(_ownerOf[id] == address(0), "ALREADY_MINTED")
require(from == _ownerOf[id], "WRONG_FROM")
require(to != address(0), "INVALID_RECIPIENT")
require(msg.sender == from || isApprovedForAll[from][msg.sender] || msg.sender == getApproved[id],"NOT_AUTHORIZED")
require(owner != address(0), "ZERO_ADDRESS")
Manual Analysis
Replace require and revert statements with custom errors.
For instance, in PuttyV2.sol
:
Replace
- require(tokenAmount > 0, "ERC20: Amount too small")
with
+ if (tokenAmount == 0) { + revert AmountZero(); +}
and define the custom error in the contract
+ error AmountZero();
The example above (ie only one require statement replaced with a custom error) saves 9609
gas on deployment:
โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโฌโโโโโโโโโฌโโโโโโโโโฌโโโโโโโโโฌโโโโโโโโโโฎ โ PuttyV2 contract โ โ โ โ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโชโโโโโโโโโโโโโโโโโโชโโโโโโโโโชโโโโโโโโโชโโโโโโโโโชโโโโโโโโโโก โ Deployment Cost โ Deployment Size โ โ โ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโผโโโโโโโโโผโโโโโโโโโผโโโโโโโโโผโโโโโโโโโโค โ 4774098 โ 25226 โ โ โ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโผโโโโโโโโโผโโโโโโโโโผโโโโโโโโโผโโโโโโโโโโค
โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโฌโโโโโโโโโฌโโโโโโโโโฌโโโโโโโโโฌโโโโโโโโโโฎ โ PuttyV2 contract โ โ โ โ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโชโโโโโโโโโโโโโโโโโโชโโโโโโโโโชโโโโโโโโโชโโโโโโโโโชโโโโโโโโโโก โ Deployment Cost โ Deployment Size โ โ โ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโผโโโโโโโโโผโโโโโโโโโผโโโโโโโโโผโโโโโโโโโโค โ 4764489 โ 25178 โ โ โ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโผโโโโโโโโโผโโโโโโโโโผโโโโโโโโโผโโโโโโโโโโค
If a variable is not set/initialized, it is assumed to have the default value (0, false, 0x0 etc depending on the data type).
Explicitly initializing it with its default value is an anti-pattern and wastes 3
gas per variable initialized.
Instances include:
uint256 feeAmount = 0
uint256 i = 0
uint256 i = 0
uint256 i = 0
uint256 i = 0
uint256 i = 0
uint256 i = 0
uint256 i = 0
uint256 i = 0
uint256 i = 0
uint256 i = 0
Manual Analysis
Remove explicit initialization for default values.
Constant expressions are left as expressions, not constants, and are re-calculated each time they are used, costing approximately an extra 100
gas on each access.
Instances include:
bytes32 public constant ERC721ASSET_TYPE_HASH = keccak256(abi.encodePacked("ERC721Asset(address token,uint256 tokenId)"))
bytes32 public constant ERC20ASSET_TYPE_HASH = keccak256(abi.encodePacked("ERC20Asset(address token,uint256 tokenAmount)"))
bytes32 public constant ORDER_TYPE_HASH
Manual Analysis
Replace constant
with immutable
When a require
statement is use multiple times, it is cheaper to use a modifier instead.
4 instances of require
statements repeated twice each:
require(floorAssetTokenIds.length == order.floorTokens.length, "Wrong amount of floor tokenIds")
require(floorAssetTokenIds.length == order.floorTokens.length, "Wrong amount of floor tokenIds")
require(floorAssetTokenIds.length == 0, "Invalid floor tokens length")
require(floorAssetTokenIds.length == 0, "Invalid floor tokens length")
require(msg.value == order.strike, "Incorrect ETH amount sent")
require(msg.value == order.strike, "Incorrect ETH amount sent")
require(to != address(0), "INVALID_RECIPIENT")
require(to != address(0), "INVALID_RECIPIENT")
Manual Analysis
Use modifiers for these repeated statements
Prefix increments are cheaper than postfix increments: it returns the incremented variable instead of returning a temporary variable storing the initial value of the variable. It saves 5
gas per iteration
Instances include:
i++
i++
i++
i++
i++
i++
i++
i++
i++
i++
Manual Analysis
change variable++
to ++variable
.
Gas can be saved by avoiding ERC20.transfer
function calls when the amount
to be transferred is 0
. Merely an observation here as users are not likely to create orders with null order.premium
or order.strike
Instances include:
order.premium
is not zeroorder.strike
is not zeroorder.strike
is not zeroManual Analysis
Add checks to ensure the amounts transferred are not 0
. Alternatively, this can be handled on the front-end side.
The default "checked" behavior costs more gas when adding/diving/multiplying, because under-the-hood those checks are implemented as a series of opcodes that, prior to performing the actual arithmetic, check for under/overflow and revert if it is detected.
if it can statically be determined there is no possible way for your arithmetic to under/overflow (such as a condition in an if statement), surrounding the arithmetic in an unchecked
block will save gas
Instances include:
ERC20(order.baseAsset).safeTransfer(msg.sender, order.strike - feeAmount): feeAmount = (order.strike * fee) / 1000
, and as _fee < 30
, feeAmount < order.strike
, so order.strike - feeAmount
cannot underflow.
10 instances of an incremented variable in a for loop that cannot overflow as it is bounded by the gas limit:
i++
i++
i++
i++
i++
i++
i++
i++
i++
i++
Manual Analysis
Place the arithmetic operations in an unchecked
block