Snowman Merkle Airdrop

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

Snow::buySnow traps ETH when msg.value doesn't exactly match fee, causing permanent fund loss

Root + Impact

Description

  • The buySnow function is payable and uses an if/else to decide between ETH and WETH payment. The condition checks msg.value == (s_buyFee * amount) — if this is exactly true, ETH is used. Otherwise, the else branch pulls WETH via safeTransferFrom. The problem is that any ETH sent in the else branch is silently absorbed by the contract with no refund mechanism.

// Root cause in Snow.sol lines 79-90
function buySnow(uint256 amount) external payable canFarmSnow {
if (msg.value == (s_buyFee * amount)) {
_mint(msg.sender, amount);
} else {
// @> If msg.value > 0 but != s_buyFee * amount, the ETH is TRAPPED here
// @> The WETH is ALSO pulled — user pays double
i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount);
}
s_earnTimer = block.timestamp;
}

Risk

Likelihood:

  • This occurs when a user sends any non-zero ETH amount that doesn't exactly equal s_buyFee * amount — e.g., sending slightly too much ETH, or mistakenly sending ETH while also having WETH approved.

  • The s_buyFee is 5 * 10^18 (5 ETH). Users attempting to send ETH with rounding or UI errors will trigger the else branch.

Impact:

  • Users permanently lose their ETH — there is no withdrawal or refund function for trapped ETH beyond collectFee.

  • Users may also pay WETH on top of the lost ETH, resulting in double payment for a single purchase.

Proof of Concept

This test demonstrates that when a user sends slightly more ETH than required (e.g., fee + 1 wei), the if condition fails and execution falls to the else branch. The WETH is pulled via safeTransferFrom, while the ETH sent with the transaction is silently trapped in the contract. The user ends up paying both ETH and WETH for a single Snow token purchase.

function testH04_EthTrappedOnMismatch() public {
address victim = makeAddr("victim");
uint256 fee = snow.s_buyFee();
deal(victim, fee + 1); // Slightly more than required
weth.mint(victim, fee);
vm.startPrank(victim);
weth.approve(address(snow), fee);
// Victim sends slightly too much ETH — falls to else branch
snow.buySnow{value: fee + 1}(1);
vm.stopPrank();
// Victim lost ETH (trapped in contract) AND paid WETH
assert(address(snow).balance == fee + 1); // ETH trapped
assert(weth.balanceOf(address(snow)) == fee); // WETH also taken
}

Recommended Mitigation

Replace the exact-match if with a check on whether msg.value > 0, and add explicit validation that the correct amount was sent. This separates the ETH and WETH payment paths cleanly and prevents any ETH from being silently trapped when a user intends to pay with WETH.

function buySnow(uint256 amount) external payable canFarmSnow {
+ uint256 cost = s_buyFee * amount;
- if (msg.value == (s_buyFee * amount)) {
+ if (msg.value > 0) {
+ require(msg.value == cost, "Incorrect ETH amount");
_mint(msg.sender, amount);
} else {
- i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
+ i_weth.safeTransferFrom(msg.sender, address(this), cost);
_mint(msg.sender, amount);
}
Updates

Lead Judging Commences

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