Lister is overpaying during the cancel of his listing on Listings::cancelListings().
Summary
Lister unfairly double pays the tax used while he is cancelling his listing through Listings::cancelListings().
Root Cause
When a user is creating his listing through Listings::createListings(), he is paying upfront the tax that is expected to be used for the whole duration of the listing. However, if he decides to cancel the listing after some time calling Listings::cancelListings(), he will find himself paying again the portion of the tax that has been used until this point. Let's see the Listings::cancelListings() :
function cancelListings(address _collection, uint[] memory _tokenIds, bool _payTaxWithEscrow) public lockerNotPaused {
uint fees;
uint refund;
for (uint i; i < _tokenIds.length; ++i) {
uint _tokenId = _tokenIds[i];
// Read the listing in a single read
Listing memory listing = _listings[_collection][_tokenId];
// Ensure the caller is the owner of the listing
if (listing.owner != msg.sender) revert CallerIsNotOwner(listing.owner);
// We cannot allow a dutch listing to be cancelled. This will also check that a liquid listing has not
// expired, as it will instantly change to a dutch listing type.
Enums.ListingType listingType = getListingType(listing);
if (listingType != Enums.ListingType.LIQUID) revert CannotCancelListingType();
// Find the amount of prepaid tax from current timestamp to prepaid timestamp
// and refund unused gas to the user.
(uint _fees, uint _refund) = _resolveListingTax(listing, _collection, false);
emit ListingFeeCaptured(_collection, _tokenId, _fees);
fees += _fees;
refund += _refund;
// Delete the listing objects
delete _listings[_collection][_tokenId];
// Transfer the listing ERC721 back to the user
locker.withdrawToken(_collection, _tokenId, msg.sender);
}
// cache
ICollectionToken collectionToken = locker.collectionToken(_collection);
// Burn the ERC20 token that would have been given to the user when it was initially created
@> uint requiredAmount = ((1 ether * _tokenIds.length) * 10 ** collectionToken.denomination()) - refund;
@> payTaxWithEscrow(address(collectionToken), requiredAmount, _payTaxWithEscrow);
collectionToken.burn(requiredAmount + refund);
// ...
}As we can see, user is expected to return back the whole 1e18 - refund. It helps to remember that when he created the listing he "took" 1e18 - TAX where, now, TAX = refund + fees. So the user is expected to give back to the protocol 1e18 - refund while he got 1e18 - refund - fees. The difference of what he got at the start and what he is expected to return now :
whatHeGot - whatHeMustReturn = (1e18 - refund - fees) - (1e18 - refund) = -feesSo, now the user has to get out of his pocket and pay again for the fees while, technically, he has paid for them in the start by not ever taking them.
Furthermore, in this way, as we can see from this line, the protocol burns the whole 1e18 without considering the tax that got actually used and shouldn't be burned as it will be deposited to the UniswapV4Implementation :
collectionToken.burn(requiredAmount + refund);Internal pre-conditions
- User creates a
listingfromListings::createListings().
External pre-conditions
- User wants to cancel his
listingbyListings:cancelListings().
Attack Path
- User creates a
listingfromListings::createListings()and takes back ascollectionTokens->1e18 - prepaidTax. - Some time passes by.
- User wants to cancel the
listingby callingListings::cancelListings()and he has to give back1e18 - unusedTax. This mean that he has to give also theusedTaxamount.
Impact
The impact of this serious vulnerability is that the user is forced to double pay the tax that has been used for the duration that his listing was up. He, firstly, paid for it by not taking it and now, when he cancels the listing, he has to pay it again out of his own pocket. This results to unfair loss of funds for whoever tries to cancel his listing.
PoC
No PoC needed.
Mitigation
To mitigate this vulnerability successfully, consider not requiring user to return the fee variable as well :
function cancelListings(address _collection, uint[] memory _tokenIds, bool _payTaxWithEscrow) public lockerNotPaused {
uint fees;
uint refund;
for (uint i; i < _tokenIds.length; ++i) {
// ...
}
// cache
ICollectionToken collectionToken = locker.collectionToken(_collection);
// Burn the ERC20 token that would have been given to the user when it was initially created
- uint requiredAmount = ((1 ether * _tokenIds.length) * 10 ** collectionToken.denomination()) - refund;
+ uint requiredAmount = ((1 ether * _tokenIds.length) * 10 ** collectionToken.denomination()) - refund - fees;
payTaxWithEscrow(address(collectionToken), requiredAmount, _payTaxWithEscrow);
collectionToken.burn(requiredAmount + refund);
// ...
}