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: 39/120
Findings: 3
Award: $112.97
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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#L236
Duo to the pool's assumption of the sale price per NFT, it's possible for recipients to receive less or more royalty fee based on the scenario.
The given example shows how a recipient receives less royalty fee than he is supposed to, duo to the system wrongfully assuming the sale price per NFT. In the opposite case it's possible for a recipient to receive more royalty fee, therefore the problem is clearly in the accounting of the sale price.
Let's say a user wants to buy two NFTs from the private pool.
NFT A - 3e18 weight, NFT B - 1e18 weight.
NFT A has 40% royalty fee of the sale price.
NFT B has 0% royalty fee of the sale price.
Technically 40% of NFT A sale price should be paid, but the function sums the weights of the two NFTs. And as you can see below the salePrice is calculated by assuming it's the same for each NFT, which is wrong and leads to the wrong accounting of the royalty fee.
// calculate the sale price (assume it's the same for each NFT even if weights differ) uint256 salePrice = (netInputAmount - feeAmount - protocolFeeAmount) / tokenIds.length;
A simple example: NFT A - 3e18 weight - 40% royalty fee applied NFT B - 1e18 weight. - 0% royalty fee The function should get the 40% of NFT A's weight, but this leads to diff outcome uint256 salePrice = (netInputAmount - feeAmount - protocolFeeAmount) / tokenIds.length; uint256 salePrice = (4e18 - 0 - 0) / 2; uint256 salePrice = 2e18 You can see above that the assumed price per NFT is 2e18, therefore the function "_getRoyalty" will get 40% of the 2e18 sale price and not from the right one which should be 3e18. This problem leads to recipients receiving less royalty fee.
By far l described the problem occurring, but lets see how much less royalty fee a user gets.
Lets take for example first, that the sale price is calculated separately:
We have two NFTs, A and B.
A has a sale price of 3e18 and 40% royalty fee.
B has a sale price of 1e18 and 0% royalty fee.
The outcome in the function "royaltyInfo" will look like this:
// Fee for NFT A is set to 40%, which is 4000 uint256 royaltyAmount = (salePrice * royalty.royaltyFraction) / _feeDenominator(); uint256 royaltyAmount = (3e18 * 4000) / 10000; uint256 royaltyAmount = 1200000000000000000 // The royalty fee for NFT A is 1200000000000000000.
Lets go over the second example, which is how the private pool works.
We have two NFTs, A and B.
A has a sale price of 3e18 and 40% royalty fee.
B has a sale price of 1e18 and 0% royalty fee.
We sum the sale price for these two NFTs and divide it by two. Therefore both NFTs will have a sale price of 2e18.
// Fee for NFT A is set to 40%, which is 4000 uint256 royaltyAmount = (salePrice * royalty.royaltyFraction) / _feeDenominator(); uint256 royaltyAmount = (2e18 * 4000) / 10000; uint256 royaltyAmount = 800000000000000000 // The royalty fee for NFT A is 800000000000000000.
You can clearly see the difference between the two scenarios and how much less royalty fee a user gets, by the assumption of the sale price per NFT in the system.
Manual review
Consider calculating the royalty fee based on the real sale price for the NFT and not on the assumption of it, as duo to the issue a recipient can get less or more royalty fee depending on the scenario occurring.
#0 - c4-pre-sort
2023-04-20T17:43:32Z
0xSorryNotSorry marked the issue as duplicate of #580
#1 - c4-judge
2023-04-30T16:30:28Z
GalloDaSballo marked the issue as duplicate of #669
#2 - c4-judge
2023-05-01T07:24:45Z
GalloDaSballo changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-05-01T07:25:18Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: Voyvoda
Also found by: CodingNameKiki, DishWasher, GT_Blockchain, J4de, JGcarv, Josiah, RaymondFam, neumo, saian
72.6437 USDC - $72.64
https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L731-L738
Private pools initialized with zero changeFee won't be able to pay the protocolFee, as the calculation of the protocol fee is based on the changeFee, therefore the private pools won't pay any protocol fee duo to multiplication with zero.
Users are able to create a private pool however they like, therefore there might be some private pools charging zero change fee prior to calling the function "change".
The following snippet below calculates the feeAmount and the protocolFeeAmount accrued from the change. We can see that both the feeAmount and protocolFeeAmount is based on the changeFee state variable, which shouldn't be the case here. As private pool initialized with zero changeFee won't only not pay the feeAmount, but will skip the protocolFee as well.
uint256 feePerNft = changeFee * 10 ** exponent; uint256 feePerNft = 0 * 10 ** exponent; uint256 feePerNft = 0; feeAmount = (inputAmount * feePerNft) / 1e18; feeAmount = (inputAmount * 0) / 1e18; feeAmount = 0; protocolFeeAmount = (feeAmount * Factory(factory).protocolFeeRate()) / 10_000; protocolFeeAmount = (0 * Factory(factory).protocolFeeRate()) / 10_000; protocolFeeAmount = 0;
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 exponent = baseToken == address(0) ? 18 - 4 : ERC20(baseToken).decimals() - 4; uint256 feePerNft = changeFee * 10 ** exponent; feeAmount = (inputAmount * feePerNft) / 1e18; protocolFeeAmount = (feeAmount * Factory(factory).protocolFeeRate()) / 10_000; }
Manual review
Hard to think regarding a fix here, since the calculation of the protocolFee depends on the changeFee. Perhaps there should be a hardcoded amount for use, whenever the pool is initialized with zero changeFee.
E.g:
function changeFeeQuote( uint256 inputAmount ) public view returns (uint256, uint256) { // multiply the changeFee to get the fee per NFT (4 decimals of accuracy) uint256 exponent = baseToken == address(0) ? 18 - 4 : ERC20(baseToken).decimals() - 4; if(changeFee != 0){ uint256 feePerNft = changeFee * 10 ** exponent; feeAmount = (inputAmount * feePerNft) / 1e18; protocolFeeAmount = (feeAmount * Factory(factory).protocolFeeRate()) / 10_000; return (feeAmount, protocolFeeAmount); else { uint256 feePerNft = hardcodedAmount * 10 ** exponent; feeAmount = (inputAmount * feePerNft) / 1e18; protocolFeeAmount = (feeAmount * Factory(factory).protocolFeeRate()) / 10_000; return (0, protocolFeeAmount); } }
#0 - c4-pre-sort
2023-04-20T18:32:22Z
0xSorryNotSorry marked the issue as primary issue
#1 - outdoteth
2023-04-21T15:56:35Z
If the change fee is zero then there is also no protocol fee. This is intentional.
#2 - c4-sponsor
2023-04-21T15:56:40Z
outdoteth marked the issue as sponsor disputed
#3 - CodingNameKiki
2023-04-27T07:21:05Z
Will leave a fact-based comment and won't comment further.
By the sponsor this is intentional behaviour and private pools initialized with zero change fee aren't supposed to pay any protocol fee.
But if we apply the same concept of thinking prior to buying and selling NFTs, private pools initialized with zero feeRate shouldn't pay a protocol fee either, but they still pay it anyways as the calculation of protocolFeeAmount doesn't depend on the feeAmount value like in the function "changeFeeQuote".
Protocol fee should be payed on every trade and shouldn't depend on the pool owner's set values, like the functions "buyQuote", "sellQuote" are designed.
function buyQuote(uint256 outputAmount) public view returns (uint256 netInputAmount, uint256 feeAmount, uint256 protocolFeeAmount) { // calculate the input amount based on xy=k invariant and round up by 1 wei uint256 inputAmount = FixedPointMathLib.mulDivUp(outputAmount, virtualBaseTokenReserves, (virtualNftReserves - outputAmount)); protocolFeeAmount = inputAmount * Factory(factory).protocolFeeRate() / 10_000; feeAmount = inputAmount * feeRate / 10_000; netInputAmount = inputAmount + feeAmount + protocolFeeAmount; }
function sellQuote(uint256 inputAmount) public view returns (uint256 netOutputAmount, uint256 feeAmount, uint256 protocolFeeAmount) { // calculate the output amount based on xy=k invariant uint256 outputAmount = inputAmount * virtualBaseTokenReserves / (virtualNftReserves + inputAmount); protocolFeeAmount = outputAmount * Factory(factory).protocolFeeRate() / 10_000; feeAmount = outputAmount * feeRate / 10_000; netOutputAmount = outputAmount - feeAmount - protocolFeeAmount; }
#4 - GalloDaSballo
2023-04-27T09:40:55Z
The protocol charges a % of the fee If the pool takes 0 fee
Then the protocol % is X% of 0 which is still zero
#5 - GalloDaSballo
2023-04-30T19:01:30Z
This is a logical duplicate of #463 which only shows a specific instance
#6 - c4-judge
2023-04-30T19:01:43Z
GalloDaSballo marked the issue as duplicate of #463
#7 - c4-judge
2023-04-30T19:01:49Z
GalloDaSballo changed the severity to 2 (Med Risk)
#8 - GalloDaSballo
2023-04-30T19:02:34Z
While the Sponsor has given different feedback I think both findings mention the same thing so am awarding this 100%
#9 - c4-judge
2023-04-30T19:02:39Z
GalloDaSballo marked the issue as satisfactory