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: 20/120
Findings: 4
Award: $433.42
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: Voyvoda
Also found by: AkshaySrivastav, Haipls, aviggiano, teddav
410.0759 USDC - $410.08
https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L244 https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L274
In PrivatePool.buy
, the function PrivatePool._getRoyalty
is called twice, which calls IERC2981(lookupAddress).royaltyInfo(tokenId, salePrice)
under the hood. EIP-2981 states that
Implementers of this standard MUST calculate a percentage of the
_salePrice
when calculating the royalty amount. Subsequent invocations ofroyaltyInfo()
MAY return a differentroyaltyAmount
.
It is possible that the royalty lookup address implements a mechanism where different invocations return different royalty amounts. As a result, the first invocation of PrivatePool._getRoyalty
may return a different number from the second invocation for the same NFT.
For example, the first invocation can return a value higher than the second value, which would make the PrivatePool.buy
send more funds to the royalty recipient than necessary. As a result, users funds may be lost in the process.
Users loss of funds depending on the specific implementation of the royalty lookup address.
PrivatePool.buy
with a specific NFT addressPrivatePool
contractManual review
Use an array to cache the royalty fee and recipient on the first invocation and use the cached value to pay royalties.
#0 - c4-pre-sort
2023-04-20T19:02:39Z
0xSorryNotSorry marked the issue as duplicate of #593
#1 - c4-judge
2023-04-30T16:04:55Z
GalloDaSballo marked the issue as duplicate of #320
#2 - c4-judge
2023-05-01T07:01:51Z
GalloDaSballo marked the issue as partial-50
#3 - GalloDaSballo
2023-05-01T07:02:00Z
Valid but missed the way to achieve it
#4 - c4-judge
2023-05-01T07:02:06Z
GalloDaSballo changed the severity to 3 (High Risk)
🌟 Selected for report: adriro
Also found by: 0xNorman, 0xRobocop, Aymen0909, ElKu, GT_Blockchain, Josiah, KrisApostolov, RaymondFam, SpicyMeatball, ToonVH, Voyvoda, anodaram, aviggiano, bin2chen, climber2002, giovannidisiena, jpserrat, minhtrng, rbserver, sashik_eth, shaka, wintermute
8.0283 USDC - $8.03
https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L162-L163
PrivatePool.flashLoan
is designed to execute a flash loan for the given NFT. The function calculates the fee associated with the flash loan using the flashFee
function, that uses changeFee
under the hood. However, depending on the difference between changeFee
and feeRate
, there is a possibility that an attacker can drain token funds from the pool by executing a flash loan, selling the NFT, and then buying it back at a lower cost.
This vulnerability can lead to a loss of funds for the pool owner/liquidity provider, as an attacker can exploit the fee calculation issue to drain token funds from the pool.
Manual review
Change fees should be derived from buy/sell fees. These should not be two distinct parameters.
Uniswap, for example, derives the following formula for a "single-token swap":
Single-Token In the case where the token withdrawn is the same as the token returned (i.e. DAI was requested in the flash swap, used, then returned, or vice versa with WETH), the following condition must be satisfied: DAIReservePre - DAIWithdrawn + (DAIReturned * .997) >= DAIReservePre It may be more intuitive to rewrite this formula in terms of a "fee" levied on the withdrawn amount (despite the fact that Uniswap always levies fees on input amounts, in this case the returned amount, here we can simplify to an effective fee on the withdrawn amount). If we rearrange, the formula looks like: (DAIReturned * .997) - DAIWithdrawn >= 0 DAIReturned >= DAIWithdrawn / .997 So, the effective fee on the withdrawn amount is .003 / .997 ≈ 0.3009027%.
This can be generalized as
changeFee = feeRate / (1 - feeRate)
The recommendation is, in summary, to use a single feeRate
parameter, and to derive the changeFee
using the formula above.
#0 - c4-pre-sort
2023-04-20T15:08:36Z
0xSorryNotSorry marked the issue as duplicate of #864
#1 - c4-judge
2023-05-01T07:06:49Z
GalloDaSballo changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-05-01T07:08:44Z
GalloDaSballo marked the issue as satisfactory
5.9827 USDC - $5.98
https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L726-L738
PrivatePool.changeFeeQuote
calculates the fee required to change a given amount of NFTs based on the current changeFee. The function accounts for the base token decimals by calculating an exponent depending on the base token's decimals. However, the current implementation does not support ERC-20 tokens with less than 4 decimals, as the exponent calculation may result in a negative number, leading to a revert.
The protocol does not support ERC-20 base tokens with less than 4 decimals
For example, Gemini dollar (GUSD) has only 2 decimals. Using it on a PrivatePool
would make all PrivatePool.change
calls revert due to an underflow.
Manual review
Change the implementation of changeFee
to use a different number of decimals of accuracy. Alternatively, assume fees to be zero in case of ERC-20 tokens with fewer than 4 decimals
function changeFeeQuote(uint256 inputAmount) public view returns (uint256 feeAmount, uint256 protocolFeeAmount) { // multiply the changeFee to get the fee per NFT (4 decimals of accuracy) uint256 decimals = ERC20(baseToken).decimals(); if (decimals >= 4) { uint256 exponent = baseToken == address(0) ? 18 - 4 : decimals - 4; uint256 feePerNft = changeFee * 10 ** exponent; feeAmount = inputAmount * feePerNft / 1e18; protocolFeeAmount = feeAmount * Factory(factory).protocolFeeRate() / 10_000; } }
#0 - c4-pre-sort
2023-04-20T15:22:25Z
0xSorryNotSorry marked the issue as duplicate of #858
#1 - c4-judge
2023-05-01T07:14:26Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: 0xLanterns
Also found by: AkshaySrivastav, Bason, CodingNameKiki, DadeKuma, DishWasher, Dug, ElKu, J4de, MiloTruck, Nyx, RaymondFam, Ruhum, T1MOH, Voyvoda, abiih, adriro, aviggiano, bshramin, sashik_eth, savi0ur, yixxas
9.3258 USDC - $9.33
https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L235-L236 https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L244 https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L788 https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L334-L335
In PrivatePool.{buy,sell}
, there is an assumption that the sale price is the same for each NFT even if weights differ. This impacts the royalty calculation, as the same salePrice
is used for different tokenId
s on the _getRoyalty
calculation. Nevertheless, EIP-2981 states that:
The implementer MAY choose to change the percentage value based on other predictable variables that do not make assumptions about the unit of exchange. For example, the percentage value may drop linearly over time. An approach like this SHOULD NOT be based on variables that are unpredictable like
block.timestamp
, but instead on other more predictable state changes. One more reasonable approach MAY use the number of transfers of an NFT to decide which percentage value is used to calculate theroyaltyAmount
. The idea being that the percentage value could decrease after each transfer of the NFT. Another example could be using a different percentage value for each unique_tokenId
.
As the EIP allows, it is possible that the royalty lookup address defines different percentage values for each unique _tokenId
. As a result, assuming the same price value would yield different royalty amounts instead of using the weighted price.
User loss of funds depending on the implementation of the royalty lookup price.
Imagine the following scenario, where a pool contains three NFTs, each one with a different weight, and a different royalty percentage defined by the lookup address:
_tokenId | weight | royalty percentage |
---|---|---|
1337 | 1 | 5% |
42 | 2 | 10% |
31415 | 3 | 15% |
If we assume the weighted price of all NFTs to be 6 ether, the average salePrice
as defined by the current PrivatePool
implementation would be 2 ether per NFT. This would yield the following total royalty amount:
_tokenId | weight | royalty percentage | salePrice (incorrect) | royalty amount (incorrect) |
---|---|---|---|---|
1337 | 1 | 5% | 2 ether | 0.1 ether |
42 | 2 | 10% | 2 ether | 0.2 ether |
31415 | 3 | 15% | 2 ether | 0.3 ether |
Total royalty amount (incorrect): 0.60 ether.
However, by assuming the salePrice
to be the weighted price of each NFT, it will yield the following total royalty amount:
_tokenId | weight | royalty percentage | salePrice (weighted) | royalty amount (weighted) |
---|---|---|---|---|
1337 | 1 | 5% | 1 ether | 0.05 ether |
42 | 2 | 10% | 2 ether | 0.2 ether |
31415 | 3 | 15% | 3 ether | 0.45 ether |
Total royalty amount (weighted): 0.70 ether.
Manual review
Calculate the salePrice
to be the weighted price for each tokenId
and use that value for the _getRoyalty
calculation.
#0 - c4-pre-sort
2023-04-20T17:31:47Z
0xSorryNotSorry marked the issue as duplicate of #669
#1 - c4-judge
2023-05-01T07:27:12Z
GalloDaSballo marked the issue as satisfactory