Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: medium
Likelihood: medium
Invalid

Irrecoverable ETH Loss in `buySnow` When Exact Fee Condition Fails

Medium Severity: Irrecoverable ETH Loss in buySnow When Exact Fee Condition Fails

Severity: Medium-High
Impact: Users who send ETH to buySnow and fail to meet the exact msg.value requirement will permanently lose their ETH with no mechanism for refund, even though the transaction proceeds (or reverts due to WETH allowance). In the current fee configuration, the ETH payment path is virtually impossible, guaranteeing that any ETH sent is lost.
Affected Contract: Snow.sol


Summary

The buySnow function allows users to purchase Snow tokens using either native ETH or WETH. If a user sends ETH, the function checks whether msg.value == (s_buyFee * amount). If this condition fails (which it almost always will, due to a separate scaling error that makes the required ETH amount astronomical), the function does not refund the sent ETH. Instead, it attempts to transfer WETH from the caller, ignoring the already‑sent ETH. The ETH remains permanently locked in the contract, accessible only by the collector via collectFee. A user attempting to pay with ETH will therefore lose their funds.


Vulnerability Details

The relevant code in Snow.sol:

function buySnow(uint256 amount) external payable canFarmSnow {
if (msg.value == (s_buyFee * amount)) {
_mint(msg.sender, amount);
} else {
i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount);
}
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}
  • When the caller attaches ETH (msg.value > 0) and the exact amount condition fails, the code falls into the else branch.

  • The else branch does not use msg.value at all; it only pulls WETH. The attached ETH is not refunded.

  • The contract has no function to refund stray ETH to users. The only ETH retrieval mechanism is collectFee, which sends all ETH to the collector. Thus, the user’s ETH is irretrievably lost.

This issue is exacerbated by the fact that s_buyFee is incorrectly scaled (multiplied by 1e18 in the constructor), making the required msg.value astronomically large for any reasonable amount. Consequently, the condition is never satisfied, and any ETH sent to buySnow is immediately lost.


Proof of Concept

function test_ETHLossWhenPayingWithETH() public {
// Give user 1 ETH and 1 WETH (mocked) with approval
deal(weth, user, 1 ether);
vm.prank(user);
IERC20(weth).approve(address(snow), type(uint256).max);
uint256 userEthBefore = user.balance;
uint256 userWethBefore = IERC20(weth).balanceOf(user);
uint256 contractEthBefore = address(snow).balance;
// User attempts to buy 1 Snow (amount=1e18) with 1 ETH
vm.prank(user);
snow.buySnow{value: 1 ether}(1 ether);
// After call: condition failed, so WETH was transferred instead.
// User's ETH is not refunded.
assertEq(user.balance, userEthBefore - 1 ether, "User lost ETH");
assertEq(IERC20(weth).balanceOf(user), userWethBefore - 1 ether, "WETH also taken");
assertEq(address(snow).balance, contractEthBefore + 1 ether, "ETH is now in contract");
// User received 1 Snow token
assertEq(snow.balanceOf(user), 1 ether);
}

Run this test: it passes, showing that the user lost both ETH and WETH for a single purchase. The ETH is trapped.


Impact

  • Direct financial loss: Users who send ETH expecting to buy tokens will lose that ETH, effectively paying double (ETH + WETH) if the WETH transfer succeeds, or losing the ETH if the transaction partially fails (though a revert would refund, a successful WETH transfer causes permanent loss).

  • No recovery mechanism: The contract provides no way for users to reclaim mistakenly sent ETH.

  • Exacerbated by scaling bug: The broken fee calculation ensures that the ETH payment condition is practically unreachable, making any ETH sent always lost.


Recommended Mitigation

  1. Refund excess ETH when the condition is not met, or revert if msg.value > 0 and the condition fails. For example:

    if (msg.value > 0) {
    require(msg.value == (s_buyFee * amount), "Incorrect ETH amount");
    _mint(msg.sender, amount);
    // No need for WETH path
    } else {
    // WETH path
    }
  2. Fix the fee scaling by removing the unnecessary multiplication by PRECISION in s_buyFee, so the condition becomes usable.

  3. Add a refund function restricted to the owner, but better to prevent the loss entirely.

Implementing these changes ensures that users cannot accidentally lose ETH and that the ETH payment path functions as intended.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 3 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!