NFT Dealers

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

mintNft() and buy() Are payable but Lack ETH Handling, Permanently Locking Sent ETH

Author Revealed upon completion

Root + Impact

Description

  • mintNft() and buy() are payment functions that should only accept USDC as the payment token. Any ETH sent alongside these calls should either be rejected or refunded to the caller.

  • Both functions are marked payable but neither use msg.value nor refund it. The contract has no receive(), no fallback(), and no withdrawEth() function. Any ETH sent by a user is permanently trapped inside the contract with no recovery path.

@> function buy(uint256 _listingId) external payable {
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);
require(success, "USDC transfer failed");
_safeTransfer(listing.seller, msg.sender, listing.tokenId, "");
s_listings[_listingId].isActive = false;
emit NFT_Dealers_Sold(msg.sender, listing.price);
}
@> function mintNft() external payable 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");
tokenIdCounter++;
collateralForMinting[tokenIdCounter] = lockAmount;
_safeMint(msg.sender, tokenIdCounter);
}

Risk

Likelihood:

  • This occurs every time a user interacts with mintNft() or buy() through a frontend or script that mistakenly forwards ETH alongside the call, as both functions are declared payable and the EVM will silently accept the value without reverting.

Impact:

  • Any ETH sent to mintNft() or buy() is permanently locked in the contract with no withdrawal mechanism, causing irreversible loss of user funds.

Proof of Concept

This test uses the same setup as the NFTDealersTest file, extended with ETH funding for both users via vm.deal. It proves that both mintNft and buy are marked payable and can therefore accept ETH, but the contract provides no withdrawal function, locking any sent ETH permanently.

function testEthSentToMintNftAndBuyAreLockedForever() public revealed {
uint256 ethToSend = 1 ether;
// fund users with ETH
vm.deal(userWithEvenMoreCash, 10 ether);
vm.deal(userWithCash, 10 ether);
// whitelist and fund with USDC for mint
vm.prank(owner);
nftDealers.whitelistWallet(userWithEvenMoreCash);
usdc.mint(userWithEvenMoreCash, 20e6);
// mintNft() called with ETH
uint256 contractEthBefore = address(nftDealers).balance;
vm.startPrank(userWithEvenMoreCash);
usdc.approve(address(nftDealers), 20e6);
nftDealers.mintNft{value: ethToSend}(); // ETH sent but never used
vm.stopPrank();
assertEq(address(nftDealers).balance, contractEthBefore + ethToSend, "ETH locked after mintNft");
// buy() called with ETH
uint32 price = 500e6; // 500 USDC
vm.startPrank(userWithEvenMoreCash);
nftDealers.list(1, price);
vm.stopPrank();
usdc.mint(userWithCash, price);
vm.startPrank(userWithCash);
usdc.approve(address(nftDealers), price);
nftDealers.buy{value: ethToSend}(1); // ETH sent but never used
vm.stopPrank();
uint256 contractEthAfter = address(nftDealers).balance;
assertEq( contractEthAfter, contractEthBefore + ethToSend * 2, "ETH locked after buy");
}

Recommended Mitigation

Remove the payable modifier from functions that are not intended to receive ETH.

- 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");
activeListingsCounter--;
bool success = usdc.transferFrom(msg.sender, address(this), listing.price);
require(success, "USDC transfer failed");
_safeTransfer(listing.seller, msg.sender, listing.tokenId, "");
s_listings[_listingId].isActive = false;
emit NFT_Dealers_Sold(msg.sender, listing.price);
}
- 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");
tokenIdCounter++;
collateralForMinting[tokenIdCounter] = lockAmount;
_safeMint(msg.sender, tokenIdCounter);
}

Support

FAQs

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

Give us feedback!