Snowman Merkle Airdrop

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

Snow::buySnow() traps ETH when user sends incorrect msg.value, falling through to WETH path without refunding

Root + Impact

Description

The Snow::buySnow() function allows users to purchase Snow tokens using either native ETH or WETH. The function uses an if/else pattern to determine the payment method based on msg.value:

function buySnow(uint256 amount) external payable canFarmSnow {
// @> If msg.value exactly matches, pay with ETH
if (msg.value == (s_buyFee * amount)) {
_mint(msg.sender, amount);
} else {
// @> Otherwise, fall through to WETH payment
// @> BUT any ETH sent with msg.value is NOT refunded!
i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount);
}
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}

The problem occurs when a user sends ETH (msg.value > 0) but the amount does not exactly match s_buyFee * amount. In this scenario:

  1. The if condition fails because msg.value != s_buyFee * amount

  2. Execution falls through to the else branch, which attempts a WETH safeTransferFrom

  3. If the user has approved WETH, the WETH transfer succeeds and Snow tokens are minted

  4. The ETH sent with msg.value remains trapped in the contract with no way to recover it

The user effectively pays twice: once in ETH (which is lost) and once in WETH. The Snow contract has no receive() or fallback() function for accepting ETH, and collectFee() only transfers the contract's ETH balance to the collector, meaning trapped user ETH goes to the protocol fee collector rather than being refunded.

Additionally, even when a user intends to pay with ETH and sends the exact correct amount, there is no require statement or explicit revert if the ETH branch is not taken. A slight rounding error or wrong amount parameter would silently fall through to WETH, trapping the ETH.

Risk

Likelihood: Medium

  • Users interacting directly with the contract (not through a well-designed front-end) can easily send a slightly wrong ETH amount

  • The s_buyFee is stored as _buyFee * PRECISION (1e18), making manual calculation error-prone

  • Wallet UIs may auto-suggest gas amounts that interfere with exact ETH matching

Impact: Medium

  • Users lose the ETH they sent, which is permanently trapped in the contract

  • Users are additionally charged WETH for the same transaction, resulting in double payment

  • There is no mechanism for users to recover their trapped ETH

  • The collectFee() function will sweep the trapped ETH to the collector, not back to the user

Proof of Concept

function testETHTrappedOnWrongAmount() public {
address user = makeAddr("user");
uint256 buyFee = snow.s_buyFee();
uint256 amount = 2;
uint256 correctETH = buyFee * amount;
uint256 wrongETH = correctETH - 1; // Off by 1 wei
// Give user ETH and WETH
vm.deal(user, wrongETH);
deal(address(weth), user, correctETH);
vm.startPrank(user);
weth.approve(address(snow), correctETH);
// User sends slightly wrong ETH amount
// The if check fails, falls through to WETH path
snow.buySnow{value: wrongETH}(amount);
vm.stopPrank();
// User got their Snow tokens
assertEq(snow.balanceOf(user), amount);
// BUT: user paid WETH (correct amount deducted)
assertEq(weth.balanceOf(user), 0);
// AND: user's ETH is trapped in the contract (not refunded)
assertEq(address(snow).balance, wrongETH);
// User paid BOTH wrongETH in native ETH AND correctETH in WETH
}

Recommended Mitigation

Use explicit checks instead of silent fallthrough. Either require exact ETH payment when ETH is sent, or explicitly handle the WETH-only case:

function buySnow(uint256 amount) external payable canFarmSnow {
uint256 totalCost = s_buyFee * amount;
if (msg.value > 0) {
// User intends to pay with ETH - require exact amount
require(msg.value == totalCost, "Incorrect ETH amount");
_mint(msg.sender, amount);
} else {
// User intends to pay with WETH - no ETH should be sent
i_weth.safeTransferFrom(msg.sender, address(this), totalCost);
_mint(msg.sender, amount);
}
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 5 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!