Caviar Private Pools - MiloTruck's results

A fully on-chain NFT AMM that allows you to trade every NFT in a collection.

General Information

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

Caviar

Findings Distribution

Researcher Performance

Rank: 104/120

Findings: 1

Award: $9.33

🌟 Selected for report: 0

🚀 Solo Findings: 0

Awards

9.3258 USDC - $9.33

Labels

bug
2 (Med Risk)
high quality report
satisfactory
duplicate-669

External Links

Lines of code

https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L235-L249

Vulnerability details

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:

  1. NFTs with different token IDs might have different royalty fees.
  2. Royalty fees might change based on the current number of transfers of an NFT.

However, the way this protocol implements the calculation of royalty fees does not account for the above, making it non-compliant with EIP-2981.

1. NFTs with different token IDs might have different royalty fees.

Vulnerability Details

In the buy() function of the PrivatePool contract, the royalty fee amount is calculated as such:

PrivatePool.sol#L235-L249

// 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:

  1. The royalty fee implementation has different royalty fees for NFTs with different token IDs,
  2. 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.

Proof of Concept

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.

2. Royalty fees might change based on the current number of transfers of an NFT.

Vulnerability Details

In the buy() function, _getRoyalty() is first called in a loop:

PrivatePool.sol#L235-L249

// 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:

PrivatePool.sol#L273-L283

// 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:

  1. The buy() function reverting due to an insufficient amount of baseToken.
  2. The royalty recipient receiving less royalty fees than intended.

Proof of Concept

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:

  1. It calls _getRoyalty() in a loop that transfers NFTs to the buyer to determine the total royaltyFeeAmount:

PrivatePool.sol#L235-L249

        // 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;
            }
        }
  1. 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.

  2. It calls _getRoyalty() again in a loop to determine how much royalty fees to pay:

PrivatePool.sol#L235-L249

        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:

  • Assume the user buys two ExampleNFTs shown above; each of them have a price of 1 ether.
  • In the first for-loop, the royaltyFeeAmount will be:
(1 ether * 1 / 1000) + (1 ether * 2 / 1000) = 3000000000000000
  • However, in the second for-loop, the total royalty fees will be higher:
(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:

  • Substantially speaking the issue is the incorrect weight
  • The second issue around royalties changing mid-tx is arguably a QA finding, the royalty changing would be predictable and a feature of the NFT, meaning that when simulating the tx any user would be able to solve for it

I believe the finding is properly valued as Med

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter