pragma solidity ^0.8.34;
import {Test, console} from "forge-std/Test.sol";
import {NFTDealers} from "../src/NFTDealers.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
function mint(address to, uint256 amount) external {
balanceOf[to] += amount;
totalSupply += amount;
}
function transfer(address to, uint256 amount) external returns (bool) {
require(balanceOf[msg.sender] >= amount, "insufficient balance");
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
return true;
}
function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount;
return true;
}
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
require(allowance[from][msg.sender] >= amount, "allowance exceeded");
require(balanceOf[from] >= amount, "insufficient balance");
allowance[from][msg.sender] -= amount;
balanceOf[from] -= amount;
balanceOf[to] += amount;
return true;
}
}
contract CollectFeesBug_PoC is Test {
NFTDealers public marketplace;
MockUSDC public usdc;
address public owner = makeAddr("owner");
address public alice = makeAddr("alice");
address public bob = makeAddr("bob");
address public charlie = makeAddr("charlie");
uint256 constant LOCK_AMOUNT = 20e6;
uint256 constant LISTING_PRICE = 1000e6; /
uint256 constant EXPECTED_FEE_1PCT = 10e6;
function setUp() public {
usdc = new MockUSDC();
marketplace = new NFTDealers(
owner,
address(usdc),
"Test Collection",
"TEST",
"ipfs://base/",
LOCK_AMOUNT
);
usdc.mint(alice, 10000e6);
usdc.mint(bob, 10000e6);
usdc.mint(charlie, 10000e6);
vm.startPrank(owner);
marketplace.whitelistWallet(alice);
marketplace.whitelistWallet(bob);
marketplace.whitelistWallet(charlie);
marketplace.revealCollection();
vm.stopPrank();
}
function test_FeeTransferIsNoOp() public {
console.log("=== PoC: Fee Transfer to address(this) is No-Op ===\n");
vm.startPrank(alice);
usdc.approve(address(marketplace), LOCK_AMOUNT);
marketplace.mintNft();
uint256 tokenId = 1;
vm.stopPrank();
console.log("✓ Alice minted tokenId=%d, locked %s USDC collateral",
tokenId, _fmt(LOCK_AMOUNT));
vm.startPrank(alice);
marketplace.list(tokenId, uint32(LISTING_PRICE));
vm.stopPrank();
console.log("✓ Alice listed tokenId=%d for %s USDC\n",
tokenId, _fmt(LISTING_PRICE));
vm.startPrank(bob);
usdc.approve(address(marketplace), LISTING_PRICE);
marketplace.buy(tokenId);
vm.stopPrank();
uint256 contractBalanceAfterBuy = usdc.balanceOf(address(marketplace));
console.log("✓ Bob bought tokenId=%d", tokenId);
console.log(" Contract USDC balance after buy: %s USDC\n",
_fmt(contractBalanceAfterBuy));
uint256 balanceBeforeCollect = usdc.balanceOf(address(marketplace));
uint256 feesBefore = marketplace.totalFeesCollected();
vm.startPrank(alice);
marketplace.collectUsdcFromSelling(tokenId);
vm.stopPrank();
uint256 balanceAfterCollect = usdc.balanceOf(address(marketplace));
uint256 feesAfter = marketplace.totalFeesCollected();
uint256 aliceBalance = usdc.balanceOf(alice);
console.log("✓ Alice called collectUsdcFromSelling()");
console.log(" Contract balance before: %s USDC", _fmt(balanceBeforeCollect));
console.log(" Contract balance after: %s USDC", _fmt(balanceAfterCollect));
console.log(" Balance change: %s USDC (should be -%s for fees)",
_fmt(balanceBeforeCollect - balanceAfterCollect),
_fmt(EXPECTED_FEE_1PCT));
console.log(" totalFeesCollected before: %s USDC", _fmt(feesBefore));
console.log(" totalFeesCollected after: %s USDC", _fmt(feesAfter));
console.log(" Alice received: %s USDC (proceeds + collateral)\n",
_fmt(aliceBalance - LOCK_AMOUNT));
assertEq(feesAfter - feesBefore, EXPECTED_FEE_1PCT, "Fees should be recorded");
uint256 expectedBalanceDrop = LISTING_PRICE - EXPECTED_FEE_1PCT + LOCK_AMOUNT;
assertEq(
balanceBeforeCollect - balanceAfterCollect,
expectedBalanceDrop,
"BUG: Contract balance dropped by seller proceeds ONLY, fees not segregated"
);
console.log("🚨 BUG CONFIRMED:");
console.log(" - totalFeesCollected increased by %s USDC ✓", _fmt(EXPECTED_FEE_1PCT));
console.log(" - But contract balance did NOT retain those fees ✗");
console.log(" - Fees exist in accounting but not in actual segregated balance\n");
vm.startPrank(bob);
usdc.approve(address(marketplace), LOCK_AMOUNT);
marketplace.mintNft();
uint256 tokenId2 = 2;
marketplace.list(tokenId2, uint32(LISTING_PRICE));
vm.stopPrank();
vm.startPrank(charlie);
usdc.approve(address(marketplace), LISTING_PRICE);
marketplace.buy(tokenId2);
vm.stopPrank();
vm.startPrank(bob);
marketplace.collectUsdcFromSelling(tokenId2);
vm.stopPrank();
uint256 finalContractBalance = usdc.balanceOf(address(marketplace));
uint256 totalFeesRecorded = marketplace.totalFeesCollected();
console.log("✓ Second sale completed (Bob→Charlie)");
console.log(" Final contract balance: %s USDC", _fmt(finalContractBalance));
console.log(" totalFeesCollected: %s USDC", _fmt(totalFeesRecorded));
console.log(" Expected fees (2 sales): %s USDC", _fmt(EXPECTED_FEE_1PCT * 2));
vm.startPrank(owner);
uint256 ownerBalanceBefore = usdc.balanceOf(owner);
try marketplace.withdrawFees() {
uint256 ownerBalanceAfter = usdc.balanceOf(owner);
uint256 withdrawn = ownerBalanceAfter - ownerBalanceBefore;
console.log("\n⚠️ withdrawFees() succeeded, withdrew: %s USDC", _fmt(withdrawn));
if (withdrawn > totalFeesRecorded) {
console.log("🚨 CRITICAL: Owner withdrew MORE than recorded fees!");
console.log(" This drains funds that belong to sellers/collateral!");
}
} catch Error(string memory reason) {
console.log("\n🚨 CRITICAL: withdrawFees() reverted: %s", reason);
console.log(" Owner fees are permanently locked!");
}
vm.stopPrank();
console.log("\n=== FINAL STATE ===");
console.log("Contract USDC balance: %s", _fmt(usdc.balanceOf(address(marketplace))));
console.log("totalFeesCollected: %s (accounting)", _fmt(marketplace.totalFeesCollected()));
console.log("\n💥 CONCLUSION: Fee accounting is decoupled from actual token flows.");
console.log(" The contract cannot guarantee correct fee withdrawals.");
}
function _fmt(uint256 amount) internal pure returns (string memory) {
uint256 whole = amount / 1e6;
uint256 decimals = amount % 1e6;
return string.concat(
vm.toString(whole), ".",
decimals < 100000 ? "0" : "",
decimals < 10000 ? "0" : "",
decimals < 1000 ? "0" : "",
decimals < 100 ? "0" : "",
decimals < 10 ? "0" : "",
vm.toString(decimals)
);
}
}