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: 104/120
Findings: 1
Award: $9.33
🌟 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#L235-L249
EIP-2981, the NFT Royalty Standard, states:
The implementer MAY choose to change the percentage value based on other predictable variables that do not make assumptions about the unit of exchange.
One more reasonable approach MAY use the number of transfers of an NFT to decide which percentage value is used to calculate the
royaltyAmount
. 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 seen from above, contracts that implement EIP-2981 might return different royalty fees when the royaltyInfo()
function is called:
However, the way this protocol implements the calculation of royalty fees does not account for the above, making it non-compliant with EIP-2981.
In the buy()
function of the PrivatePool
contract, the royalty fee amount is calculated as such:
// calculate the sale price (assume it's the same for each NFT even if weights differ) uint256 salePrice = (netInputAmount - feeAmount - protocolFeeAmount) / tokenIds.length; uint256 royaltyFeeAmount = 0; 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; } }
Where:
tokenIds
- Array containing the token IDs of each NFT being bought.netInputAmount
- The total price of all NFTs.In the code above, salePrice
is taken as the average price of each NFT to be bought. Afterwards, the royaltyFee
for each NFT transfer is calculated based on salePrice
, instead of the actual price of each NFT.
However, this implementation would return an incorrect royaltyFeeAmount
if:
buy()
is called with multiple NFTs that have different prices.By calling _getRoyalty()
with the average sale price of each NFT instead of its actual price, the royaltyFee
returned for that NFT would be incorrect.
Consider a royalty fee implementation that has double the royalty fee for an NFT where tokenId == 100
:
function royaltyInfo(uint256 tokenId, uint256 salePrice) public view override returns (address receiver, uint256 royaltyAmount) { receiver = address(0xbeefbeef); royaltyAmount = salePrice * royaltyFeeRate / 1e18; if (tokenId == 100) { royaltyAmount = 2 * salePrice * royaltyFeeRate / 1e18; } }
If a user calls buy()
with the following NFTs:
tokenId = 100, price = 2e18
tokenId = 1, price = 1e18
the average sale price, would be 15e17
.
If each transfer has a 1% royalty fee (royaltyFeeRate = 1e16
), the total royalty fee amount should be:
royaltyFeeAmount = (2 * 2e18 * 1e16 / 1e18) + (1e18 * 1e16 / 1e18) = 5e16
However, as buy()
uses the average sale price to calculate royalty fees for each transfer, the total royalty fee amount becomes:
royaltyFeeAmount = (2 * 15e17 * 1e16 / 1e18) + (15e17 * 1e16 / 1e18) = 4.5e16
Thus, the royalty recipient receives less royalty fees than he is supposed to.
Consider calling _getRoyalty()
with the actual price of each NFT being transferred, instead of the average price.
Note that this vulnerability is also present in the following functions:
sell()
in PrivatePool.sol
.buy()
in EthRouter.sol
.sell()
in EthRouter.sol
.In the buy()
function, _getRoyalty()
is first called in a loop:
// calculate the sale price (assume it's the same for each NFT even if weights differ) uint256 salePrice = (netInputAmount - feeAmount - protocolFeeAmount) / tokenIds.length; uint256 royaltyFeeAmount = 0; 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; } }
As seen above, _getRoyalty()
is called in a loop that transfers each NFT.
Later on, _getRoyalty()
is called again when transferring royalty fees to the recipient:
// 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); } }
However, the second call to _getRoyalty()
might return a different royaltyFee
for a royalty fee implementation that calculates the royalty fee based on the current number of transfers of an NFT. This would result in either:
buy()
function reverting due to an insufficient amount of baseToken
.Consider a royalty fee implementation that increases royalty fees based on the total number of transfers of the NFT collection:
function royaltyInfo(uint256 tokenId, uint256 salePrice) public view override returns (address receiver, uint256 royaltyAmount) { receiver = address(0xbeefbeef); royaltyAmount = salePrice * royaltyFeeRate * totalTransferCount / 1e18; }
Where:
totalTransferCount
- The total number of transfers of the NFT collection.In the buy()
function, when _getRoyalty()
is called for the second time, totalTransferCount
will be larger than the first call _getRoyalty()
, resulting in a larger royaltyFee
being returned.
Consider storing the royalty fee and recipient for each NFT from the first call to _getRoyalty()
, and using these stored values when transferring baseToken
to the recipient later on.
#0 - c4-pre-sort
2023-04-19T09:33:04Z
0xSorryNotSorry marked the issue as high quality report
#1 - c4-pre-sort
2023-04-20T18:37:23Z
0xSorryNotSorry marked the issue as primary issue
#2 - outdoteth
2023-04-22T14:44:39Z
#3 - c4-judge
2023-04-30T15:31:46Z
GalloDaSballo marked the issue as duplicate of #669
#4 - c4-judge
2023-05-01T07:27:11Z
GalloDaSballo marked the issue as satisfactory
#5 - MiloTruck
2023-05-04T04:30:18Z
Answering @GalloDaSballo's question for this issue:
I believe that the underlying issue is the same, each NFT is not computed with it's actual price, but rather an average of the prices
What exactly do you believe is the distinction for the two?
I believe that point 2 is completely different from point 1.
Point 2 is about how the buy()
function might not work correctly for NFTs that increase/decrease the royalty fee after every transfer. This is compliant with EIP-2981:
One more reasonable approach MAY use the number of transfers of an NFT to decide which percentage value is used to calculate the
royaltyAmount
. The idea being that the percentage value could decrease after each transfer of the NFT.
For reference, here's an example of what an NFT that increases the royalty fee after every transfer might look like:
contract RoyaltyNFT is ERC721 { uint256 public totalTransferCount; constructor() ERC721("ExampleNFT", "eNFT") {} function royaltyInfo( uint256 tokenId, uint256 salePrice ) public view override returns (address receiver, uint256 royaltyAmount) { receiver = address(this); royaltyAmount = (salePrice * totalTransferCount) / 1000; } function _beforeTokenTransfer( address from, address to, uint256 firstTokenId, uint256 batchSize ) internal override { if (from != address(0) && to != address(0)) { ++totalTransferCount; } } }
If a user attempts to batch buy a few of these NFTs using the buy()
function, it will revert due to an incorrect royalty fee amount calculation. This will occur even if the prices of all the NFTs are the same. Therefore, it has a different underlying issue from point 1.
This is because of how the buy()
function handles royalty fees:
_getRoyalty()
in a loop that transfers NFTs to the buyer to determine the total royaltyFeeAmount
:// calculate the sale price (assume it's the same for each NFT even if weights differ) uint256 salePrice = (netInputAmount - feeAmount - protocolFeeAmount) / tokenIds.length; uint256 royaltyFeeAmount = 0; 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; } }
It ensures that the user transfers exactly netInputAmount
of payment in (see PrivatePool.sol#L251-L269), which is royaltyFeeAmount
added to the total price of all NFTs being bought.
It calls _getRoyalty()
again in a loop to determine how much royalty fees to pay:
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); } } } }
For NFTs that increase royalty fees after every transfer, _getRoyalty()
in the second for-loop will return a higher royaltyFee
than _getRoyalty()
in the first for-loop, as the transfer of all NFTs occur in the first for-loop. The actual royalty fee amount paid to recipient
will be more than the royaltyFeeAmount
calculated before, causing buy()
to revert due to insufficient funds.
Here's a concrete example:
ExampleNFT
s shown above; each of them have a price of 1 ether
.royaltyFeeAmount
will be:(1 ether * 1 / 1000) + (1 ether * 2 / 1000) = 3000000000000000
(1 ether * 2 / 1000) + (1 ether * 2 / 1000) = 4000000000000000
Royalty fees are calculated using (salePrice * totalTransferCount) / 1000
.
Note that if the NFT used decreases royalty fees after every transfer, the buy()
functions works, but the user will overpay in royalty fees.
#6 - GalloDaSballo
2023-05-04T12:29:49Z
Thank you for the additional info, I believe that:
I believe the finding is properly valued as Med