NFT Dealers

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

[M-01] Unnecessary payable modifier on mintNft and buy functions traps native ETH permanently

Author Revealed upon completion

Root + Impact

Description

Normal behavior dictates that a protocol exclusively using ERC20 tokens (like USDC) should not accept native ETH, preventing accidental loss of user funds.

The specific issue is that the mintNft and buy functions are marked with the payable modifier, despite the protocol handling all payments via USDC transferFrom. Because there is no withdraw() function implemented for native ETH in the contract, any ETH accidentally sent by a user or a faulty frontend to these functions will be permanently trapped inside the contract.

Solidity
// @> The 'payable' modifier allows the contract to receive ETH, but there is no mechanism to withdraw it.
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");

// ...

// @> Same issue here
function buy(uint256 _listingId) external payable {
Listing memory listing = s_listings[_listingId];
if (!listing.isActive) revert ListingNotActive(_listingId);
// ...

Risk

Likelihood:

Low/Medium. It requires user error or a frontend bug to attach msg.value to the transaction.

mpact:

Medium. Any native ETH attached to these transactions is permanently lost, as the smart contract lacks a withdrawal mechanism for native currency.

Proof of Concept

Add the following test to test/NFTDealersPoC.t.sol to prove that ETH gets permanently locked.

Solidity
function test_PoC_EthTrappedInContract() public {
vm.startPrank(attacker);
dealers.mintNft();
dealers.list(1, 100e6);
vm.stopPrank();

uint256 contractEthBalanceBefore = address(dealers).balance;
vm.deal(buyer, 1 ether);
vm.prank(buyer);
// Buyer accidentally sends 1 ETH along with the USDC payment
dealers.buy{value: 1 ether}(1);
uint256 contractEthBalanceAfter = address(dealers).balance;
// Asserts the 1 ETH is now stuck in the contract balance forever
assertEq(contractEthBalanceAfter, contractEthBalanceBefore + 1 ether);
}

Recommended Mitigation

Remove the payable modifier from both the mintNft and buy functions. The EVM will naturally revert any transaction that accidentally includes native ETH, protecting user funds.

Diff

  • 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");
    // ...

  • function buy(uint256 _listingId) external payable {

  • function buy(uint256 _listingId) external {
    Listing memory listing = s_listings[_listingId];
    if (!listing.isActive) revert ListingNotActive(_listingId);
    // ...

Support

FAQs

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

Give us feedback!