Closed code423n4 closed 1 year ago
0xSorryNotSorry marked the issue as primary issue
outdoteth marked the issue as sponsor disputed
No exploit is given here. The reserves are updated in such a way that the xyk invariant holds. That is intentional.
X * Y Math is applied Total is computed Invariants are updated Royalties are applied Transfers are done
I think the finding is invalid but will double check
GalloDaSballo marked the issue as unsatisfactory: Invalid
I'd like to provide some specific example on what the issue addresses as an incorrect calculation.
I'll show one example for buy()
and ERC20 tokens. I'll try to point how virtualBaseTokenReserves
differs from the result of the actual transfers when payRoyalties
is false
.
The same applies for sell()
. The proof would be slightly different, but the root cause is the same.
virtualBaseTokenReserves
will always be calculated considering feeAmount
:
230: virtualBaseTokenReserves += uint128(netInputAmount - feeAmount - protocolFeeAmount);
The corresponding transfers will only be for netInputAmount
and protocolFeeAmount
when payRoyalties == false
.
256: ERC20(baseToken).safeTransferFrom(msg.sender, address(this), netInputAmount);
259: if (protocolFeeAmount > 0) ERC20(baseToken).safeTransfer(address(factory), protocolFeeAmount);
If payRoyalties == false
, ERC20(baseToken).safeTransfer(recipient, royaltyFee);
won't be called because of the if
statement. So, it will never transfer any amount related to the feeAmount
that was previously considered.
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); // @audit this won't be called when `payRoyalties == false`
} else {
recipient.safeTransferETH(royaltyFee); // @audit this won't be called when `payRoyalties == false`
}
}
}
}
Point 1
So, for each buy
or sell
operation, it will miscalculate virtualBaseTokenReserves
with an error of feeAmount
, as its value won't match the results of the actual transfers.
Point 2
In addition, if payRoyalties == true
, royaltyFee
should be used to calculate virtualBaseTokenReserves
instead of feeAmount
, as that is the amount that will be transferred.
I'd like to provide some specific example on what the issue addresses as an incorrect calculation.
I'll show one example for
buy()
and ERC20 tokens. I'll try to point howvirtualBaseTokenReserves
differs from the result of the actual transfers whenpayRoyalties
isfalse
.The same applies for
sell()
. The proof would be slightly different, but the root cause is the same.Proof of Concept
virtualBaseTokenReserves
will always be calculated consideringfeeAmount
:230: virtualBaseTokenReserves += uint128(netInputAmount - feeAmount - protocolFeeAmount);
The corresponding transfers will only be for
netInputAmount
andprotocolFeeAmount
whenpayRoyalties == false
.256: ERC20(baseToken).safeTransferFrom(msg.sender, address(this), netInputAmount); 259: if (protocolFeeAmount > 0) ERC20(baseToken).safeTransfer(address(factory), protocolFeeAmount);
If
payRoyalties == false
,ERC20(baseToken).safeTransfer(recipient, royaltyFee);
won't be called because of theif
statement. So, it will never transfer any amount related to thefeeAmount
that was previously considered.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); // @audit this won't be called when `payRoyalties == false` } else { recipient.safeTransferETH(royaltyFee); // @audit this won't be called when `payRoyalties == false` } } } }
Point 1
So, for each
buy
orsell
operation, it will miscalculatevirtualBaseTokenReserves
with an error offeeAmount
, as its value won't match the results of the actual transfers.Point 2
In addition, if
payRoyalties == true
,royaltyFee
should be used to calculatevirtualBaseTokenReserves
instead offeeAmount
, as that is the amount that will be transferred.
Thank you for taking the time to clarify your report @0xJuancito
I don't quite get what you mean, both calculations are irrespective of Royalties, meaning they are idempotent and will result to:
payRoyaties
is true or falseCan you please clarify which invariant is broken?
Thanks for reviewing the comment.
My assumption is that the change in the tokens balance should be the same as the change in the virtual reserves after a buy
operation.
If that should not hold, please disregard my comment. I appreciate your time invested.
In case the assumption is correct, here is a test that should pass, but fails.
The virtual reserves is always subtracting feeAmount
, but it that does not perform any tokens transfer for that amount in this case.
Add to test/Buy.t.sol
and run forge test -m "test_BuyBalance"
:
function test_BuyBalance_WithProtocolFee() public {
// based on `test_PaysProtocolFeeWithBaseToken()`
// arrange
privatePool = new PrivatePool(address(factory), address(royaltyRegistry), address(stolenNftOracle));
privatePool.initialize(
address(shibaInu),
nft,
virtualBaseTokenReserves,
virtualNftReserves,
changeFee,
4000, // @audit set `feeRate` to 4%
merkleRoot,
true,
false // @audit `payRoyalties`
);
factory.setProtocolFeeRate(1000); // set to 1%
// set up nfts
for (uint256 i = 10; i < 13; i++) {
tokenIds.push(i);
milady.mint(address(privatePool), i);
}
// set up tokens
(uint256 netInputAmount, uint256 feeAmount, uint256 protocolFeeAmount) =
privatePool.buyQuote(tokenIds.length * 1e18);
deal(address(shibaInu), address(this), netInputAmount);
shibaInu.approve(address(privatePool), netInputAmount);
// Track virtual reserves and token balance before `buy`
uint256 shibaInuBalanceBefore = shibaInu.balanceOf(address(privatePool));
uint256 virtualReservesBefore = privatePool.virtualBaseTokenReserves();
// act
privatePool.buy(tokenIds, tokenWeights, proofs);
// Track virtual reserves and token balance after `buy`
uint256 shibaInuBalanceAfter = shibaInu.balanceOf(address(privatePool));
uint256 virtualReservesAfter = privatePool.virtualBaseTokenReserves();
uint256 shibaInuBalanceDiff = shibaInuBalanceAfter - shibaInuBalanceBefore;
uint256 virtualReservesDiff = virtualReservesAfter - virtualReservesBefore;
// @audit This will fail
// It is not true when `feeRate != 0` as in this case
assertEq(shibaInuBalanceDiff, virtualReservesDiff);
}
the change in the tokens balance should be the same as the change in the virtual reserves
That assumption is incorrect. The change in virtual reserves should be independent of protocolFees, royalties, and pool fees.
Lines of code
https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L230 https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L323 https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L689-L724
Vulnerability details
virtualBaseTokenReserves
is recalculated every time abuy
orsell
operation is performed. The calculation is done incorrectly, so the next time a sale is made the price will be updated incorrectly.Impact
buy
andsell
operations will be performed with incorrect prices, leading to private pools selling NFTs for less than expected.Proof of Concept
virtualBaseTokenReserves
should be calculated regarding the outcoming or incoming assets. That's not the case in neitherbuy
, orsell
, as they are usingfeeAmount
instead ofroyaltyFee
, and also omiting ifpayRoyalties
is enabled.virtualBaseTokenReserves
inbuy
function:Link to code
virtualBaseTokenReserves
insell
function:Link to code
In neither case
feeAmount
should be used, but insteadroyaltyFee
(buy) orroyaltyFeeAmount
(sell), as this is the value that will be transfered, and only ifpayRoyalties
is enabled.The price of the NFTs rely on
buyQuote
andsellQuote
, which are directly dependant ofvirtualBaseTokenReserves
:Link to code
Tools Used
Manual review
Recommended Mitigation Steps
Update the
virtualBaseTokenReserves
onbuy
andsell
consideringroyaltyFeeAmount
androyaltyFee
.