Snowman Merkle Airdrop

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

buySnow` exact-match check leaks ETH and double-charges users with non-zero WETH approval

Root + Impact

Description

  • buySnow should accept either a precise ETH payment OR a WETH transfer, never both.

  • The branch decides on exact equality msg.value == s_buyFee * amount. Any other msg.value (including overpay or underpay) falls into the WETH branch, which still pulls the full WETH fee — and the original msg.value of ETH gets trapped in the contract balance.

// src/Snow.sol
function buySnow(uint256 amount) external payable canFarmSnow {
@> if (msg.value == (s_buyFee * amount)) { // @> exact match only
_mint(msg.sender, amount);
} else {
@> i_weth.safeTransferFrom(msg.sender, address(this), s_buyFee * amount); // @> still pulls WETH
_mint(msg.sender, amount);
}
// no refund of msg.value when the else branch is taken
...
}

Risk

Likelihood:

  • Reason 1: Frontends commonly add 1-wei rounding or gas-price-derived dust to msg.value — overpay routinely falls into the WETH branch.

  • Reason 2: Users commonly hold type(uint256).max WETH approvals, so the WETH transferFrom succeeds silently.

Impact:

  • Impact 1: User pays both WETH (fee) and ETH (trapped in the contract) for a single Snow purchase.

  • Impact 2: Underpayment with a WETH approval silently charges WETH instead of reverting, against user intent.

Proof of Concept

The PoC puts Alice in the most realistic possible state for this bug: she has ETH for the fee, she has WETH approved to the Snow contract at type(uint256).max (the default approval pattern most users have for fee tokens), and she calls buySnow with 1 wei too much ETH — a rounding artifact any frontend can produce. The contract's exact-match check fails, the else branch executes, and the WETH transferFrom silently drains her fee in WETH. The three post-state asserts together prove the double-charge: she received the Snow, her WETH is gone, and her ETH is now stuck in the contract balance with no withdrawal path back to her (only the collector can ever retrieve it via collectFee).

function test_overpayDoubleCharges() public {
uint256 fee = snow.s_buyFee();
vm.deal(alice, 10 ether);
weth.mint(alice, fee);
vm.startPrank(alice);
weth.approve(address(snow), type(uint256).max);
snow.buySnow{value: fee + 1}(1); // 1 wei overpay
vm.stopPrank();
assertEq(snow.balanceOf(alice), 1);
assertEq(weth.balanceOf(alice), 0); // WETH drained
assertEq(address(snow).balance, fee + 1); // and ETH trapped
}

Recommended Mitigation

The fix turns the two-branch implicit fall-through into three explicit branches: exact ETH match (pay in ETH), zero msg.value (pay in WETH), or revert. This makes the user's intent unambiguous to the contract — there is no "ambiguous payment" path that silently charges the secondary rail. The revert on mismatch is preferable to a refund because (a) it surfaces frontend bugs immediately rather than masking them, and (b) refunds with .call introduce a reentrancy surface that is unnecessary if the exact-match invariant is enforced.

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

Lead Judging Commences

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