Snowman Merkle Airdrop

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

`Snow::buySnow` allows ETH to be sent alongside WETH payments, permanently locking the excess ETH

Root + Impact

Description

The Snow::buySnow function at Snow.sol line 79 is marked payable and uses an if/else branch to decide the payment method. When msg.value equals exactly s_buyFee * amount,
the user pays with ETH. Otherwise, the else branch executes and pulls WETH via safeTransferFrom.

The problem: when a user sends ETH that does not match the exact fee (msg.value != s_buyFee * amount), the function accepts the ETH (because payable), but also pulls the
full WETH amount. The user pays twice — once in ETH, once in WETH — and only receives Snow tokens for one payment. The ETH is permanently trapped inside the contract with
no dedicated withdrawal mechanism for the user.

// Snow.sol, lines 79-89

function buySnow(uint256 amount) external payable canFarmSnow {
if (msg.value == (s_buyFee * amount)) {
// @note Only enters here on EXACT match
_mint(msg.sender, amount);
} else {
// @audit If msg.value > 0 but != fee, ETH is accepted AND WETH is also pulled
// User pays twice: ETH trapped + WETH transferred
i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount);
}
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}

Risk

Likelihood:

  • Any user who sends ETH with an amount that doesn't perfectly match s_buyFee * amount triggers this path — a rounding mistake, a wrong amount parameter, or simply not
    knowing the exact fee triggers double payment.

  • Wallet UIs and scripts that set msg.value based on estimates rather than the exact on-chain s_buyFee will hit the else branch consistently.

Impact:

  • The user loses the ETH sent with the transaction — it stays locked in the contract and cannot be recovered by the user.

  • The user also pays the full WETH fee, effectively paying double for the same Snow tokens.

  • While collectFee sweeps the contract's ETH balance, this goes to the collector — not back to the overpaying user.

Proof of Concept

The test demonstrates that a user who sends 1 wei of ETH alongside a WETH purchase loses that ETH permanently while also paying the full WETH fee. The user ends up paying
double — both ETH and WETH — for the same Snow tokens.

function test_ethTrappedWithWethPayment() public {
// Setup: give user both ETH and WETH
address user = makeAddr("user");
uint256 ethToSend = 1 wei; // Any amount != s_buyFee * amount
weth.mint(user, FEE);
deal(user, ethToSend);
// Record balances before
uint256 userEthBefore = user.balance;
uint256 userWethBefore = weth.balanceOf(user);
// User calls buySnow sending 1 wei of ETH (not the exact fee)
// They expect to pay with WETH only, but accidentally attach ETH
vm.startPrank(user);
weth.approve(address(snow), FEE);
snow.buySnow{value: ethToSend}(1);
vm.stopPrank();
// User got their Snow tokens
assert(snow.balanceOf(user) == 1);
// BUT: user paid BOTH ETH and WETH
// ETH is gone — trapped in the contract
assert(user.balance == 0); // Lost 1 wei ETH
// WETH was also transferred — full fee taken
assert(weth.balanceOf(user) == 0); // Lost full WETH fee
// Contract holds both the ETH and the WETH
assert(address(snow).balance == ethToSend); // ETH trapped
assert(weth.balanceOf(address(snow)) == FEE); // WETH also taken
// The user paid ethToSend + FEE for tokens worth only FEE
// The trapped ETH goes to the collector via collectFee, not back to the user
}

How to run: forge test --mt test_ethTrappedWithWethPayment -vvvv

Recommended Mitigation

Add a check in the else branch to ensure no ETH was sent:

} else {
+ if (msg.value != 0) { revert S__IncorrectETHAmount(); }
i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount);
}

Updates

Lead Judging Commences

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