Snowman Merkle Airdrop

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

`buySnow` uses strict equality for ETH check — any imprecise ETH payment permanently locks funds and also charges WETH

Root + Impact

Description

The protocol allows users to buy Snow tokens by sending exactly s_buyFee * amount in ETH or by having WETH pulled from their wallet. The function is designed so that ETH payment is used when provided and WETH is used as fallback.

The condition msg.value == (s_buyFee * amount) uses strict equality. Any msg.value that is non-zero but does not match the fee exactly (e.g., off by 1 wei due to gas estimation, rounding, or front-end bugs) causes the ETH to be silently trapped in the contract while the else branch also charges the full fee in WETH — the user pays twice and recovers nothing.

// src/Snow.sol
function buySnow(uint256 amount) external payable canFarmSnow {
@> if (msg.value == (s_buyFee * amount)) {// == means any non-exact ETH value falls to else and locks the ETH
_mint(msg.sender, amount);
} else {
@> i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
@> // ETH already received is stuck; WETH is also charged in full
_mint(msg.sender, amount);
}
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}

Risk

Likelihood:

  • Occurs whenever a user sends any ETH amount that is off by even 1 wei — a common outcome of gas estimation, front-end rounding, or direct contract interaction.

  • Users with active WETH approvals (set during a prior purchase attempt) unknowingly trigger both ETH retention and WETH transfer when they retry with a wrong amount

Impact:

  • User ETH is permanently locked in the Snow contract, recoverable only by the s_collector address via collectFee() — the user has no self-recovery path

  • Users with WETH approval active are charged up to double the required fee for a single Snow token purchase

Proof of Concept

The test gives Alice exactly correctFee - 1 wei in ETH — one wei short of the required amount, a realistic outcome of front-end rounding or gas estimation truncation. Alice also has a full WETH approval active from a prior interaction with the contract. After calling buySnow with the short ETH value, the test checks three state transitions: Alice's ETH is now held inside the Snow contract with no refund path, Alice's WETH balance has been reduced by the full correctFee, and Alice received her Snow token. The net result is that Alice paid approximately double the required fee — both her ETH and WETH — for a single Snow token, with the ETH permanently locked in the contract and recoverable only by the s_collector address through collectFee.

To run: forge test --match-test test_WrongEthLocksUserFundsAndPullsWeth -vvvv

function test_WrongEthLocksUserFundsAndPullsWeth() public {
uint256 correctFee = snow.s_buyFee(); // e.g. 5e18
uint256 wrongFee = correctFee - 1 wei; // 1 wei short — common mistake
vm.deal(alice, correctFee);
deal(address(weth), alice, correctFee);
vm.prank(alice);
weth.approve(address(snow), type(uint256).max);
uint256 aliceEthBefore = alice.balance;
uint256 aliceWethBefore = weth.balanceOf(alice);
// Alice sends 1 wei short — not exact, falls to WETH branch
vm.prank(alice);
snow.buySnow{value: wrongFee}(1);
// Alice's ETH is locked in the Snow contract — not refunded
assertEq(address(snow).balance, wrongFee);
assertEq(alice.balance, 0);
// Alice also paid the full WETH fee — double charged
assertEq(weth.balanceOf(alice), aliceWethBefore - correctFee);
// Snow token minted — but Alice paid ~2x
assertEq(snow.balanceOf(alice), 1);
}

Recommended Mitigation

Add explicit reverts for non-zero, incorrect ETH values, and add a refund for any overpayment:

+ error S__WrongETHAmount();
+ error S__ZeroAmount();
function buySnow(uint256 amount) external payable canFarmSnow {
+ if (amount == 0) revert S__ZeroAmount();
+ uint256 requiredFee = s_buyFee * amount;
- if (msg.value == (s_buyFee * amount)) {
+ if (msg.value > 0) {
+ if (msg.value != requiredFee) revert S__WrongETHAmount();
_mint(msg.sender, amount);
} else {
- i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
+ i_weth.safeTransferFrom(msg.sender, address(this), requiredFee);
_mint(msg.sender, amount);
}
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}
Updates

Lead Judging Commences

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