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: 16/133
Findings: 6
Award: $967.03
π Selected for report: 0
π Solo Findings: 0
π Selected for report: zishansami
Also found by: 0x52, 0xsanson, auditor0517, berndartmueller, csanuragjain, zzzitron
https://github.com/code-423n4/2022-06-putty/blob/main/contracts/src/PuttyV2.sol#L495-L506
The protocol takes a fee proportional to the order's strike. This happens during a withdraw
:
// transfer strike to owner if put is expired or call is exercised if ((order.isCall && isExercised) || (!order.isCall && !isExercised)) { // send the fee to the admin/DAO if fee is greater than 0% uint256 feeAmount = 0; if (fee > 0) { feeAmount = (order.strike * fee) / 1000; ERC20(order.baseAsset).safeTransfer(owner(), feeAmount); } ERC20(order.baseAsset).safeTransfer(msg.sender, order.strike - feeAmount); return; }
It doesn't make sense to take a fee proportional to the strike when the order is unexercised. This fee will make some orders unprofitable for both sides.
Alice makes a put order with premium 100$ and strike 10000$. The current fee is 2%. When Bob fills the order this happens: (i) Alice pays Bob 100$, (ii) Bob deposits 10000$. If the option doesn't get exercised, Bob will call withdraw getting 10000*(100-2)=9800$ back. So at the end, both Alice and Bob end up losing 100$ each, and the owner gets 200$.
Clearly, in this situation it would be expected for Bob to win something.
Since an option can end up unexercised and it doesn't make sense to collect a fee on the strike, the solution is taking a fee on the premium during fillOrder
.
The fee on the strike should be collected only if the order was exercised.
#0 - GalloDaSballo
2022-07-05T01:31:02Z
While no loss of funds nor odd behaviour was shown, the finding may be valid in that a non-exercised call is still paying the fee, which may make it less palatable
#1 - outdoteth
2022-07-05T12:59:51Z
Yes, agree that this is invalid business logic
#2 - outdoteth
2022-07-06T18:27:52Z
Duplicate: Fees are only applied on puts if they are expired: https://github.com/code-423n4/2022-06-putty-findings/issues/269
π Selected for report: Picodes
Also found by: 0xNineDec, 0xsanson, Metatron, antonttc, berndartmueller, catchup, dirk_y, sseefried, unforgiven
50.1287 USDC - $50.13
Judge has assessed an item in Issue #417 as Medium risk. The relevant finding follows:
#0 - HickupHH3
2022-07-15T02:45:50Z
(L1) fee can change for ongoing orders The owner can call setFee(uint256 _fee) and change the fee amount. This changes the fee taken for all orders already filled/exercised.
In some situations, an user may not have filled an order if they knew the fee would end up higher. This situation is alleviated by the fact that the fee is capped at 3%.
Recommendations The fee can be written in the Order struct and checked that it matches the current correct value during fillOrder. This way when exercising/withdrawing we can use the "order.fee" irrespective to the current global variable.
#1 - HickupHH3
2022-07-15T02:46:05Z
dup of #422
π 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
47.6468 USDC - $47.65
fee
can change for ongoing ordersThe owner can call setFee(uint256 _fee)
and change the fee amount. This changes the fee taken for all orders already filled/exercised.
In some situations, an user may not have filled an order if they knew the fee would end up higher. This situation is alleviated by the fact that the fee is capped at 3%.
The fee can be written in the Order struct and checked that it matches the current correct value during fillOrder
. This way when exercising/withdrawing we can use the "order.fee
" irrespective to the current global variable.
fee
only for call ordersThe protocol collects a fee proportional to the strike.
In the current implementation, this happens only during the withdraw
of a exercised call order (or unexercised put) (lines).
I think there should be a fee also for the other order, but it got missing.
Add a similar fee collection during exercise
of a put order. Overall, check all possible paths and look if the fees collected make sense.
payable
even though they don't accept etherFunctions setBaseURI
and setFee
are payable
but they don't use ether.
If the owner sets msg.value > 0
by mistake, it will get lost.
https://github.com/code-423n4/2022-06-putty/blob/main/contracts/src/PuttyV2.sol#L228 https://github.com/code-423n4/2022-06-putty/blob/main/contracts/src/PuttyV2.sol#L240
Remove the payable
keyword in these functions.
During a fill, two ERC721 tokens are minted (lines):
// create long/short position for maker _mint(order.maker, uint256(orderHash)); // create opposite long/short position for taker bytes32 oppositeOrderHash = hashOppositeOrder(order); positionId = uint256(oppositeOrderHash); _mint(msg.sender, positionId);
If the recipient is a contract, it may misbehave and lose the NFT, since it may expect a onERC721Received
call.
Since order.maker
can't be a contract (we need its signature), this only applies to msg.sender
.
Document that NFTs are minted this way, so contracts interacting with Putty can be aware. _safeMint
is not suggested so we can save gas.
// check duration is valid require(order.duration < 10_000 days, "Duration too long");
The value 10_000 days
can be saved as uint256 private constant MAX_DURATION
. This helps the reader keeping track of values.
https://github.com/code-423n4/2022-06-putty/blob/main/contracts/src/PuttyV2.sol#L287
The function cancel(Order memory order)
can be called multiple times for the same order
.
No big problem, the only issue here is that it continues emitting a CancelledOrder
, which may lead to unexpected behavior for off-chain software.
Suggested adding:
require(!cancelledOrders[orderHash], "Order already cancelled");
https://github.com/code-423n4/2022-06-putty/blob/main/contracts/src/PuttyV2.sol#L526
#0 - outdoteth
2022-07-07T18:20:29Z
(L2) fee only for call orders
Duplicate: Fees are only applied on puts if they are expired: https://github.com/code-423n4/2022-06-putty-findings/issues/269
(L4) Positions NFT mints aren't "safe"
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
#1 - HickupHH3
2022-07-11T03:20:25Z
L2 is a dup of #285
π 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
23.0597 USDC - $23.06
In multiple instances, the same array length is read multiple time from memory/calldata. It would be better to save the value on the stack once and for all. Example:
function batchFillOrder( Order[] memory orders, bytes[] calldata signatures, uint256[][] memory floorAssetTokenIds ) public returns (uint256[] memory positionIds) { require(orders.length == signatures.length, "Length mismatch in input"); require(signatures.length == floorAssetTokenIds.length, "Length mismatch in input"); positionIds = new uint256[](orders.length); for (uint256 i = 0; i < orders.length; i++) { positionIds[i] = fillOrder(orders[i], signatures[i], floorAssetTokenIds[i]); } }
It can be rewritten like:
function batchFillOrder( Order[] memory orders, bytes[] calldata signatures, uint256[][] memory floorAssetTokenIds ) public returns (uint256[] memory positionIds) { uint256 _length = orders.length; require(_length == signatures.length, "Length mismatch in input"); require(_length == floorAssetTokenIds.length, "Length mismatch in input"); positionIds = new uint256[](_length); for (uint256 i = 0; i < _length; i++) { positionIds[i] = fillOrder(orders[i], signatures[i], floorAssetTokenIds[i]); } }
grep -r '\.length'
Recent versions of solidity support custom errors. They are more gas efficient than error strings when deploying the contract and when the error is raised. For example:
require(msg.sender == order.maker, "Not your order");
It can be rewritten like:
error NotYourOrder(); // this in the global scope ... if(msg.sender != order.maker) revert NotYourOrder();
grep -r 'require'
function _transferERC721sIn(ERC721Asset[] memory assets, address from) internal { for (uint256 i = 0; i < assets.length; i++) { ERC721(assets[i].token).safeTransferFrom(from, address(this), assets[i].tokenId); } }
Here safeTransferFrom
performs a onERC721Received
call to address(this)
, which isn't needed (we know it does nothing).
Suggested using the simpler transferFrom
.
https://github.com/code-423n4/2022-06-putty/blob/main/contracts/src/PuttyV2.sol#L610-L614
function hashOppositeOrder(Order memory order) public view returns (bytes32 orderHash) { // use decode/encode to get a copy instead of reference Order memory oppositeOrder = abi.decode(abi.encode(order), (Order)); // get the opposite side of the order (short/long) oppositeOrder.isLong = !order.isLong; orderHash = hashOrder(oppositeOrder); }
Here oppositeOrder
is created by copying order
to new memory. It would be better working with the same memory reference. We can make it safe by recasting order.isLong
at the end.
function hashOppositeOrder(Order memory order) public view returns (bytes32 orderHash) { bool _isLong = order.isLong; // save on stack order.isLong = !_isLong; orderHash = hashOrder(order); order.isLong = _isLong; }
https://github.com/code-423n4/2022-06-putty/blob/main/contracts/src/PuttyV2.sol#L683-L690
i++
In general, a for-loop like this can be made more gas efficient.
for (uint256 i = 0; i < length; i++) { ... }
Consider rewriting it like this:
for (uint256 i = 0; i < length; ) { ... unchecked { ++i; } }
grep -r 'i++'