Platform: Code4rena
Start Date: 07/04/2023
Pot Size: $47,000 USDC
Total HM: 20
Participants: 120
Period: 6 days
Judge: GalloDaSballo
Total Solo HM: 4
Id: 230
League: ETH
Rank: 24/120
Findings: 1
Award: $297.96
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: AkshaySrivastav
Also found by: 0x5rings, 0xbepresent, ABA, Bauchibred, BenRai, DadeKuma, ElKu, RaymondFam, Rolezn, adriro, btk, chaduke, devscrooge, dingo2077, minhtrng, nemveer, p0wd3r, rbserver, ulqiorra
297.9612 USDC - $297.96
During the audit, 3 low, 6 refactoring and 1 non-critical issues were found.
Total: 10 instances over 3 issues
# | Issue | Instances |
---|---|---|
[L-01] | Input is not checked when setting sensitive addresses across protocol | 4 |
[L-02] | Any leftover ETH on EthRouter can be taken with empty buy/change orders | 2 |
[L-03] | Precision loss on royalties payments | 4 |
Total: 10 instances over 6 issues
# | Issue | Instances |
---|---|---|
[R-01] | Events missing key information | 3 |
[R-02] | Inconsistent checks for royaltyRecipient | 2 |
[R-03] | Reuse getRoyalty implementation | 2 |
[R-04] | Use a constant for the flashLoan callback keccak256 | 1 |
[R-05] | Combine royalty transfer logic and ERC721 transfer logic in PrivatePool 's buy function | 1 |
[R-06] | buyQuote reverts with arithmetic underflow instead of meaningful message | 1 |
Total: 2 instances over 1 issues
# | Issue | Instances |
---|---|---|
[NC-01] | misleading or incorrect documentation | 2 |
Factory
, when setting the privatePoolMetadata
or privatePoolImplementation
addresses, there is no check if the new address is 0 or that it is actually a contract.EthRouter
when deploying the contract, the royaltyRegistry
variable is set, but not checked if the provided address is 0 or that it is actually a contract.PrivatePool
when deploying the contract, the factory, royaltyRegistry and stolenNftOracle are set but not checked if the provided address is 0 or that it is actually a contract.https://github.com/code-423n4/2023-04-caviar/blob/main/src/Factory.sol#L127-L137
/// @notice Sets private pool metadata contract. /// @param _privatePoolMetadata The private pool metadata contract. function setPrivatePoolMetadata(address _privatePoolMetadata) public onlyOwner { privatePoolMetadata = _privatePoolMetadata; }
https://github.com/code-423n4/2023-04-caviar/blob/main/src/EthRouter.sol#L90-L92
https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L143-L147
Add check for 0 address at minimum. Add check that the provided address is a contract (has code size).
EthRouter
can be taken with empty buy/change ordersIn EthRouter
during a buy or change, ETH is sent to the contract. At the end of the execution of the functions, a check if any leftover ETH remains in the contract and is sent back to the msg.sender
.
This mechanism ensures that any, normal operation within the contract, will always leave it with a 0 ETH balance.
The issue, of low probability, appears when users mistakenly send ETH to the contract directly without a buy/change operation. This means that on the next buy/change the ETH will be taken by the caller.
Also, as the functions are constructed now, anybody can call them with empty values resulting in the equivalent of calling msg.sender.safeTransferETH(address(this).balance);
directly.
For buys: https://github.com/code-423n4/2023-04-caviar/blob/main/src/EthRouter.sol#L99-L144
function buy(Buy[] calldata buys, uint256 deadline, bool payRoyalties) public payable { // check that the deadline has not passed (if any) if (block.timestamp > deadline && deadline != 0) { revert DeadlinePassed(); } // loop through and execute the the buys for (uint256 i = 0; i < buys.length; i++) { // as buys ca be empty this code block is skipped } // refund any surplus ETH to the caller if (address(this).balance > 0) { msg.sender.safeTransferETH(address(this).balance); } }
Identical for change: https://github.com/code-423n4/2023-04-caviar/blob/main/src/EthRouter.sol#L254-L293
Since this is by protocol design, there is no way of completely mitigating it. The most that can be suggested to add a check that at least 1 Buy
/Change
operation is required and to give a warning in documentation that any ETH sent directly to the router will be lost by MEV opportunists.
Royalty fee is calculated based on the sale price (salePrice
) For each instances this sale price is calculated by a division, that results in a minor rounding error loss. Example:
uint256 salePrice = (netInputAmount - feeAmount - protocolFeeAmount) / tokenIds.length;
https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L236 https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L335 https://github.com/code-423n4/2023-04-caviar/blob/main/src/EthRouter.sol#L115 https://github.com/code-423n4/2023-04-caviar/blob/main/src/EthRouter.sol#L182
Guard the division with a precision multiplier.
Some events are missing key information when emitted.
Factory
the Withdraw
event should also contain the current factory owner, as the contract can change owner (code)PrivatePool
the SetVirtualReserves
event should also contain the old values for virtualBaseTokenReserves
and virtualNftReserves
(code)PrivatePool
the SetMerkleRoot
event should also contain the old value for merkleRoot
(code)Add the missing information.
royaltyRecipient
In the protocol, there are 2 implementations that get the royaltyFee and recipient. Namely getRoyalty
in EthRouter
(code) and _getRoyalty
in PrivatePool
(code).
Both have identical outputs. These outputs are then verified in code, but in one case the check is for the existance of the fee and recipient to not be 0 address
https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L277
if (royaltyFee > 0 && recipient != address(0))
and in other cases only if the fee exists.
https://github.com/code-423n4/2023-04-caviar/blob/main/src/EthRouter.sol#L121
if (royaltyFee > 0)
The extra check for the 0 address should be duplicated in all occurrences.
https://github.com/code-423n4/2023-04-caviar/blob/main/src/EthRouter.sol#L121 https://github.com/code-423n4/2023-04-caviar/blob/main/src/EthRouter.sol#L188
Add the recipient 0 address check on all occurrences.
getRoyalty
implementationIn the protocol, there are 2 implementations that get the royaltyFee and recipient. Namely getRoyalty
in EthRouter
(code) and _getRoyalty
in PrivatePool
(code).
Both have identical outputs and only differ slightly in implementation. They can be moved to a separate utils like library and be then used by both getRoyalty
and EthRouter
.
https://github.com/code-423n4/2023-04-caviar/blob/main/src/EthRouter.sol#L301-L316 https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L778-L794
Move getRoyalty
logic to a separate contract and reuse it.
Also, in the PrivatePool
case, the function can also be made public.
When executing a flashLoan
the result from calling onFlashLoan
is checked, as per standard with the keccak256("ERC3156FlashBorrower.onFlashLoan")
.
This, however is done on each call, resulting in the same keccak256
calculation to be done multiple times.
bool success = receiver.onFlashLoan(msg.sender, token, tokenId, fee, data) == keccak256("ERC3156FlashBorrower.onFlashLoan");
https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L642
Precalculate the keccak256
, save it, and compare with it on each loan, instead of calculating it each time.
bytes32 public constant CALLBACK_SUCCESS = keccak256("ERC3156FlashBorrower.onFlashLoan");
PrivatePool
's buy
functionIn PrivatePool
, in the buy
function there are 2 set of operations that can be combined, without any reentrancy issues, together:
https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L238-L249
for (uint256 i = 0; i < tokenIds.length; i++) { // transfer the NFT to the caller ERC721(nft).safeTransferFrom(address(this), msg.sender, tokenIds[i]); if (payRoyalties) { // get the royalty fee for the NFT (uint256 royaltyFee,) = _getRoyalty(tokenIds[i], salePrice); // add the royalty fee to the total royalty fee amount royaltyFeeAmount += royaltyFee; } }
and
https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L271-L285
if (payRoyalties) { for (uint256 i = 0; i < tokenIds.length; i++) { // get the royalty fee for the NFT (uint256 royaltyFee, address recipient) = _getRoyalty(tokenIds[i], salePrice); // transfer the royalty fee to the recipient if it's greater than 0 if (royaltyFee > 0 && recipient != address(0)) { if (baseToken != address(0)) { ERC20(baseToken).safeTransfer(recipient, royaltyFee); } else { recipient.safeTransferETH(royaltyFee); } } } }
https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L238-L249 https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L271-L285
Combine the 2 operations into one iteration:
for (uint256 i = 0; i < tokenIds.length; i++) { // transfer the NFT to the caller ERC721(nft).safeTransferFrom(address(this), msg.sender, tokenIds[i]); if (payRoyalties) { // get the royalty fee for the NFT (uint256 royaltyFee, address recipient) = _getRoyalty(tokenIds[i], salePrice); // add the royalty fee to the total royalty fee amount royaltyFeeAmount += royaltyFee; // transfer the royalty fee to the recipient if it's greater than 0 if (royaltyFee > 0 && recipient != address(0)) { if (baseToken != address(0)) { ERC20(baseToken).safeTransfer(recipient, royaltyFee); } else { recipient.safeTransferETH(royaltyFee); } } } }
buyQuote
reverts with arithmetic underflow instead of meaningful messageWhen buying and NFF, execution reaches buyQuote
where, we have the following calculation:
https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L701
FixedPointMathLib.mulDivUp(outputAmount, virtualBaseTokenReserves, (virtualNftReserves - outputAmount));
virtualNftReserves
is how many NFTs there are in the pool and outputAmount
is how many are to be bought (multiplied by 1e18).
This operation underflows and reverts if there are less NFTs that intended to buy. The halting of the execution is normal, but a check should be done before hand to gracefully exit with a custom error/require message
https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L701
Add a require/check that there are enough NFTs to be bought before continuing the buy
call.
There are places in the project where there is either incorrect or misleading code documentation (either via NatSpec or inline). These need to be corrected
tokenURI
declares a uri
return variable in NatSpec but it simply returns the value (code)flashFee
declares a feeAmount
return variable in NatSpec but it simply returns the value (code)Resolve the mentioned issues
#0 - GalloDaSballo
2023-05-01T06:41:49Z
[L-01] Input is not checked when setting sensitive addresses across protocol 4 L
[L-02] Any leftover ETH on EthRouter can be taken with empty buy/change orders 2 L
[L-03] Precision loss on royalties payments 4 L
[R-01] Events missing key information 3 NC
[R-02] Inconsistent checks for royaltyRecipient 2 NC
[R-03] Reuse getRoyalty implementation 2 R
[R-04] Use a constant for the flashLoan callback keccak256 1 R
[R-05] Combine royalty transfer logic and ERC721 transfer logic in PrivatePool's buy function 1 R
[R-06] buyQuote reverts with arithmetic underflow instead of meaningful message 1 L
[NC-01] misleading or incorrect documentation 2 NC
#1 - GalloDaSballo
2023-05-01T08:34:55Z
From dups 3L 2*(L+3)
4L 3R 3NC
9L 3R 3NC + 3
#2 - c4-judge
2023-05-01T09:16:14Z
GalloDaSballo marked the issue as grade-a
#3 - GalloDaSballo
2023-05-01T09:23:17Z
Very good report, choosing to award best to the other one due to more interesting findings, mostly subjective