NFT Dealers

First Flight #58
Beginner FriendlyFoundry
100 EXP
Submission Details
Impact: low
Likelihood: low

`buy()` and `mintNft()` are marked `payable` but only use USDC -- ETH sent is permanently locked

Author Revealed upon completion

Root + Impact

Description

Both mintNft() (L114) and buy() (L141) are marked payable, but the protocol exclusively uses USDC for all payments. Neither function references msg.value, and the contract has no receive() function, no fallback() function, and no ETH withdrawal mechanism of any kind.

The payable modifier allows these functions to accept ETH alongside the call. Since the functions only process USDC via usdc.transferFrom (L119, L148), any ETH attached to the transaction is silently accepted and permanently locked in the contract with no recovery path.

function mintNft() external payable onlyWhenRevealed onlyWhitelisted { // @audit L114: payable but only uses USDC
if (msg.sender == address(0)) revert InvalidAddress();
require(tokenIdCounter < MAX_SUPPLY, "Max supply reached");
require(msg.sender != owner, "Owner can't mint NFTs");
require(usdc.transferFrom(msg.sender, address(this), lockAmount), "USDC transfer failed"); // @audit L119: only USDC
// msg.value is never checked or used
tokenIdCounter++;
collateralForMinting[tokenIdCounter] = lockAmount;
_safeMint(msg.sender, tokenIdCounter);
}
function buy(uint256 _listingId) external payable { // @audit L141: payable but only uses USDC
Listing memory listing = s_listings[_listingId];
if (!listing.isActive) revert ListingNotActive(_listingId);
require(listing.seller != msg.sender, "Seller cannot buy their own NFT");
activeListingsCounter--;
bool success = usdc.transferFrom(msg.sender, address(this), listing.price); // @audit L148: only USDC
require(success, "USDC transfer failed");
// msg.value is never checked or used
_safeTransfer(listing.seller, msg.sender, listing.tokenId, "");
s_listings[_listingId].isActive = false;
emit NFT_Dealers_Sold(msg.sender, listing.price);
}

The contract defines no ETH handling anywhere:

  • No receive() or fallback() function

  • No ETH withdrawal function

  • withdrawFees() (L195-201) only transfers USDC via usdc.safeTransfer

  • The owner has no mechanism to recover trapped ETH

Risk

Likelihood:

  • Both mintNft() (L114) and buy() (L141) accept ETH via the unnecessary payable modifier

  • A user who mistakenly sends ETH alongside a USDC marketplace transaction will permanently lose it

  • The contract has no receive(), fallback(), or ETH withdrawal function to recover trapped funds

Impact:

  • ETH sent to either function is permanently locked in the contract with no recovery mechanism

  • The owner cannot withdraw the trapped ETH -- withdrawFees() (L195-201) only handles USDC

  • While this requires user error (sending ETH to a USDC-based protocol), the unnecessary payable modifier creates an avoidable footgun that could be prevented at the compiler level

Proof of Concept

Two Foundry tests demonstrate that ETH sent via both functions is permanently trapped. Run with forge test --match-contract L02PoCTest -vv:

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.34;
import {Test, console} from "forge-std/Test.sol";
import {NFTDealers} from "../src/NFTDealers.sol";
import {MockUSDC} from "../src/MockUSDC.sol";
contract L02PoCTest is Test {
NFTDealers public nftDealers;
MockUSDC public usdc;
address public owner = makeAddr("owner");
address public seller = makeAddr("seller");
address public buyer = makeAddr("buyer");
uint256 constant LOCK_AMOUNT = 20e6; // 20 USDC
function setUp() public {
usdc = new MockUSDC();
nftDealers = new NFTDealers(owner, address(usdc), "NFTDealers", "NFTD", "ipfs://test/", LOCK_AMOUNT);
vm.startPrank(owner);
nftDealers.revealCollection();
nftDealers.whitelistWallet(seller);
vm.stopPrank();
usdc.mint(seller, 1000e6);
usdc.mint(buyer, 1000e6);
vm.prank(seller);
usdc.approve(address(nftDealers), type(uint256).max);
vm.prank(buyer);
usdc.approve(address(nftDealers), type(uint256).max);
}
function test_mintNftTrapsEth() public {
// Contract starts with 0 ETH
assertEq(address(nftDealers).balance, 0);
// Seller sends 0.5 ETH alongside mintNft() -- accepted due to payable
vm.deal(seller, 1 ether);
vm.prank(seller);
nftDealers.mintNft{value: 0.5 ether}();
// ETH is permanently locked -- no withdrawal mechanism exists
assertEq(address(nftDealers).balance, 0.5 ether);
console.log("ETH trapped in contract after mintNft:", address(nftDealers).balance);
}
function test_buyTrapsEth() public {
// Seller mints and lists
vm.prank(seller);
nftDealers.mintNft();
vm.prank(seller);
nftDealers.list(1, uint32(100e6));
// Contract starts with only USDC (seller's 20 USDC collateral)
assertEq(address(nftDealers).balance, 0);
// Buyer sends 1 ETH alongside buy() -- accepted due to payable
vm.deal(buyer, 2 ether);
vm.prank(buyer);
nftDealers.buy{value: 1 ether}(1);
// ETH is permanently locked
assertEq(address(nftDealers).balance, 1 ether);
console.log("ETH trapped in contract after buy:", address(nftDealers).balance);
}
}

Recommended Mitigation

Remove the payable modifier from both functions. The protocol uses USDC exclusively -- without payable, Solidity rejects any transaction that includes ETH at the compiler level, preventing accidental loss.

-function mintNft() external payable onlyWhenRevealed onlyWhitelisted {
+function mintNft() external onlyWhenRevealed onlyWhitelisted {
if (msg.sender == address(0)) revert InvalidAddress();
require(tokenIdCounter < MAX_SUPPLY, "Max supply reached");
require(msg.sender != owner, "Owner can't mint NFTs");
require(usdc.transferFrom(msg.sender, address(this), lockAmount), "USDC transfer failed");
...
}
-function buy(uint256 _listingId) external payable {
+function buy(uint256 _listingId) external {
Listing memory listing = s_listings[_listingId];
if (!listing.isActive) revert ListingNotActive(_listingId);
require(listing.seller != msg.sender, "Seller cannot buy their own NFT");
...
}

This fix:

  1. Prevents ETH from being accepted by either function -- Solidity automatically reverts transactions with msg.value > 0 on non-payable functions

  2. Has no side effects -- neither function uses msg.value for any logic

  3. Aligns the function signatures with the USDC-only payment model

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!