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: 27/120
Findings: 3
Award: $202.63
π Selected for report: 1
π Solo Findings: 0
5.9827 USDC - $5.98
https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L733
In the changeFeeQuote function, the variable exponent is calculated based on the number of decimals in the base token, which is either 18 (for ETH) or retrieved using the decimals() function of an ERC20 token. This exponent is then used to calculate the feePerNft variable by multiplying changeFee by 10 to the power of the exponent.
The issue with using tokens like Gemini USD, a 2 decimal token, as the base token in this function is that subtracting 4 from 2 will result in a negative number (-2), which will cause underflow when calculating exponent. In Solidity version 0.8.0 and later, underflowing an unsigned integer will cause a revert, so attempting to execute this function with these types of tokens as the base token will result in a revert.
The impact of this issue is that the changeFeeQuote function will not work as expected when the base token is Gemini USD, which will prevent users from using this function to calculate fee amounts. Gemini USD is a well-known token and is likely to be used in a pool at some point. There is nothing preventing these pools from being created. However, an important function change() would not be usable in these pools.
The easiest way to test is to add this code snippet to ShibaInu.sol
file: ShibaInu.sol contract GUSD is ERC20 { constructor() ERC20("Gemini USD", "gUSD", 2) {} }
And add this snippet to Fixture.sol
file: fixture.sol GUSD public gUSD = new GUSD();
And then run this in change
file: change.t.sol // use mock gUSD contract address for base token // run: forge test --match-test test_SmallDecimals --ffi function test_SmallDecimals() public { privatePool = new PrivatePool( address(factory), address(royaltyRegistry), address(stolenNftOracle) ); privatePool.initialize( address(gUSD), nft, virtualBaseTokenReserves, virtualNftReserves, changeFee, feeRate, merkleRoot, true, false ); inputTokenIds.push(0); inputTokenIds.push(1); inputTokenIds.push(2); outputTokenIds.push(3); outputTokenIds.push(4); outputTokenIds.push(5); vm.expectRevert(); privatePool.changeFeeQuote(outputTokenIds.length * 1e18); }
Manual Analysis, Foundry
I would recommend implementing a check in the create() function in Factory.sol that checks if _basetoken has more than 4 decimals and reverts if it doesn't. This way, you do not need to change the implementation of your PrivatePool contract.
#0 - c4-pre-sort
2023-04-20T10:06:07Z
0xSorryNotSorry marked the issue as high quality report
#1 - c4-pre-sort
2023-04-20T15:22:33Z
0xSorryNotSorry marked the issue as duplicate of #858
#2 - c4-judge
2023-05-01T07:14:38Z
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
12.1235 USDC - $12.12
https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L236
Recipients of NFTs who accept royalties will not get their fair share of royalties. This is because royalties is calculated by dividing the sales price equally amongst all sold NFT's in that purchase. The issue with this is that it assumes all NFT's cost the same amount when it comes time to deal out royalties. If NFT's cost differnt amounts then they should be getting a amount of royalties based on that weight relative to the other NFT's. the impact of this is that Royalties will not be distributed evenly at the expense of the more expensive NFT. Meaning that recipients of the expensive NFT will always recieve less then they are owed. And the cheaper ones will get more than owed. In short the is a loss of funds or misdistribution of funds.
The easyiest way to test this will to be add this snippet into Milady.sol
Using this to have access to ERC2981's setRoyaltyInfo()
file: Milady.sol function setRoyaltyInfo( uint256 _royaltyFeeRate, address _royaltyRecipient ) public { royaltyFeeRate = _royaltyFeeRate; royaltyRecipient = _royaltyRecipient; } function supportsInterface( bytes4 interfaceId ) public view override(ERC2981, ERC721) returns (bool) { return super.supportsInterface(interfaceId); } function royaltyInfo( uint256 id, uint256 salePrice ) public view override returns (address, uint256) { return super.royaltyInfo(id, salePrice); } function setRoyaltyInfo(uint256 id, address reciever, uint96 fee) public { super._setTokenRoyalty(id, reciever, fee); } }
Then add this snippet to Fixture.sol
file: Fixture.sol GodsUnchained public gu = new GodsUnchained();
Then add this snippet to token-weights.json
Changing the weights to represent the two NFT's nbeing bought in this case
[ [ 1, 1 ], [ 2, 10 ] ]
Lastly to test this you need to add this test to Buy.t.sol
// forge test --match-test test_unevenRoyalties --ffi function test_unevenRoyalties() public { // arrange privatePool = new PrivatePool( address(factory), address(royaltyRegistry), address(stolenNftOracle) ); privatePool.initialize( baseToken, address(gu), virtualBaseTokenReserves, 12e18, changeFee, feeRate, generateMerkleRoot(), true, true ); //> owner of nft's address user1 = address(0xbeefbeef); address user2 = address(0xfeebfeeb); //> mint and push nft's one is 1x one is 10x gu.mint(address(privatePool), 1); tokenIds.push(1); tokenWeights.push(1e18); gu.mint(address(privatePool), 2); tokenIds.push(2); tokenWeights.push(10e18); //> set fees. 1% for one user, 10% for the other gu.setRoyaltyInfo(1, user1, 100); gu.setRoyaltyInfo(2, user2, 1000); //> set up proofs = generateMerkleProofs(tokenIds, tokenWeights); uint256 weightSum = privatePool.sumWeightsAndValidateProof( tokenIds, tokenWeights, proofs ); (uint256 netInputAmount, , ) = privatePool.buyQuote(weightSum); //> buy privatePool.buy{value: netInputAmount * 2}( tokenIds, tokenWeights, proofs ); //> assert that users got equal reserves. with different weights and royalty fees users should NOT be getting the same amount of royalties //> the royalty fee of user2 is 10 times greater than user one. This shows that user2 is getting 10 times as much royaltys when user 2 should be getting 100 times as much as user one. //> user 1: 1% of 1 eth = 0.01 eth //> user 2: 10% of 10 eth = 1 eth //> 0.01 eth * 100 = 1eth. //> user 2 should be getting 100 times more royalties than user 1 but is only getting 10 times the amount. assertEq(user1.balance * 10, address(user2).balance); }
Manual Analysis, Foundry
To address this issue, it is recommended that the weight of NFTs relative to other NFTs being purchased should be taken into consideration when calculating royalties.
#0 - c4-pre-sort
2023-04-17T12:28:03Z
0xSorryNotSorry marked the issue as high quality report
#1 - c4-pre-sort
2023-04-20T17:29:55Z
0xSorryNotSorry marked the issue as primary issue
#2 - c4-sponsor
2023-04-21T16:47:06Z
outdoteth marked the issue as sponsor acknowledged
#3 - c4-sponsor
2023-04-21T16:47:23Z
outdoteth marked the issue as disagree with severity
#4 - outdoteth
2023-04-22T13:28:33Z
it is commented in the code that it's assumed all nfts in the purchase are of the same price: https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L334
assuming that the recipient is the same for each NFTs royalty payment (which it almost always is in practice) then this makes sense.
NFT 1 is worth 1 ETH NFT 2 is worth 2 ETH
(1 + 2) / 2 = 1.5 ETH 1 / 2 + 2 / 2 = 1.5 ETH
the output is the same. the additional complexity of individually calculating each price is not worth it.
#5 - c4-judge
2023-04-30T15:31:49Z
GalloDaSballo marked the issue as selected for report
#6 - GalloDaSballo
2023-04-30T15:34:11Z
I believe that the finding is valid because the EIP specifies that each NFT may have a different royalty, the contract is still fetching the specific royalty for each NFT id, leading me to believe that this will cause incorrect royalty payouts in specific cases in which a collection has different royalties based on the NFT id.
As a developer, I agree with the Sponsor with a nofix and believe in practice that this should not be an issue. As a Judge, I believe the finding meets the requirements of improperly implementing an EIP, which can cause a loss of yield, for this reason I think Medium Severity to be appropriate
#7 - c4-judge
2023-04-30T15:34:23Z
GalloDaSballo changed the severity to 2 (Med Risk)
184.5341 USDC - $184.53
https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L423
Some ERC20 tokens revert when 0 value is transferred. Tokens like LEND have this additional restriction and can cause the pool's functionality to break. When fees are set to 0, users cannot access the Change() function. This broken functionality can limit what users can do in the pool. Having a token like LEND in a private pool is reasonable as the token is relatively well-known, but its extra functionality can cause a key function in the Private pool to revert.
The easiest way to test this to is to add this code snippet to ShibaInu.sol
file: ShibaInu.sol contract LEND is ERC20 { mapping(address => uint256) balances; constructor() ERC20("LEND", "lend", 18) {} function transferFrom( address src, address dst, uint wad ) public override returns (bool) { require(balances[dst] + wad > balances[dst]); return super.transferFrom(src, dst, wad); } function transfer(address dst, uint wad) public override returns (bool) { require(balances[dst] + wad > balances[dst]); return super.transfer(dst, wad); } }
Then add this snippet to Fixture.sol
file: Fixture.sol LEND public lend = new LEND();
Lastly add and run this test in Change.t.sol
file: Change.t.sol // run: forge test --match-test test_changeWithZeroFee --ffi function test_changeWithZeroFee() public { privatePool = new PrivatePool( address(factory), address(royaltyRegistry), address(stolenNftOracle) ); privatePool.initialize( address(lend), nft, virtualBaseTokenReserves, virtualNftReserves, 0, feeRate, merkleRoot, true, false ); milady.setApprovalForAll(address(privatePool), true); lend.approve(address(privatePool), type(uint256).max); inputTokenIds.push(0); inputTokenIds.push(1); inputTokenIds.push(2); milady.mint(address(privatePool), 10); milady.mint(address(privatePool), 11); milady.mint(address(privatePool), 12); outputTokenIds.push(10); outputTokenIds.push(11); outputTokenIds.push(12); (uint256 feeAmount, ) = privatePool.changeFeeQuote( outputTokenIds.length * 1e18 ); deal(address(lend), address(this), feeAmount); vm.expectRevert(); privatePool.change( inputTokenIds, inputTokenWeights, inputProof, stolenNftProofs, outputTokenIds, outputTokenWeights, outputProof ); }
Manual Analysis, Foundry
Before transferring feeAmount from msg.sender to the pool in the change function, there should be a check to confirm that the fee amount is greater than 0. This would prevent any zero transfers from occurring and allow the change() function to work as intended with tokens that revert on zero transfers.
#0 - c4-pre-sort
2023-04-20T17:53:52Z
0xSorryNotSorry marked the issue as duplicate of #278
#1 - c4-judge
2023-05-01T07:16:58Z
GalloDaSballo marked the issue as satisfactory