Snowman Merkle Airdrop

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

Snow.buySnow() double-charges users (WETH + ETH) whenever msg.value does not exactly equal the fee

Root + Impact

Description

  • Snow.buySnow() is meant to charge the buyer in either native ETH (when msg.value matches the price) or WETH (otherwise).

  • The else branch fires whenever msg.value != s_buyFee * amount and pulls the full WETH price from the caller without refunding any ETH that was attached. A buyer who sends a non-zero amount of ETH that does not exactly equal the expected fee is therefore charged twice: once in WETH and once in ETH (the ETH stays in the contract).

// src/Snow.sol
function buySnow(uint256 amount) external payable canFarmSnow {
if (msg.value == (s_buyFee * amount)) {
_mint(msg.sender, amount);
} else {
@> // @> any non-zero msg.value that isn't an exact match falls here.
@> i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount)); // pulls WETH
@> _mint(msg.sender, amount);
@> // @> msg.value is silently retained by the contract, no refund
}
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}

Risk

Likelihood:

  • Any buyer that overshoots, undershoots, or simply forwards a stale gas estimate ends up with msg.value that is not equal to s_buyFee * amount (even by 1 wei), which routes them into the WETH branch.

  • Common UX flows (front-end rounding, slippage on s_buyFee updates, sending ETH while planning to pay in WETH) all reach this branch on real users.

Impact:

  • The user pays the full price in WETH and additionally loses the entire msg.value they attached.

  • The lost ETH ends up under the control of s_collector via collectFee(), so the funds are not just frozen — they are redirected to the protocol/collector at the user's expense.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console2} from "forge-std/Test.sol";
import {Snow} from "../src/Snow.sol";
import {MockWETH} from "../src/mock/MockWETH.sol";
contract PoC_BuySnowEthLost is Test {
Snow snow;
MockWETH weth;
address user = makeAddr("user");
address collector = makeAddr("collector");
function setUp() public {
weth = new MockWETH();
snow = new Snow(address(weth), 1, collector);
vm.deal(user, 10 ether);
weth.mint(user, 100 ether);
vm.prank(user); weth.approve(address(snow), type(uint256).max);
}
function test_user_overpays_eth_charged_in_weth_too() public {
// expected fee for amount=1 is 1 ether. User sends 2 ether by mistake.
vm.prank(user);
snow.buySnow{value: 2 ether}(1);
assertEq(snow.balanceOf(user), 1);
assertEq(weth.balanceOf(user), 99 ether, "1 WETH was pulled");
assertEq(address(snow).balance, 2 ether, "user's 2 ETH stuck in contract");
assertEq(user.balance, 8 ether, "user lost 2 ETH on top of WETH");
}
}

forge test --match-contract PoC_BuySnowEthLost -vv[PASS].

Recommended Mitigation

Make ETH and WETH paths mutually exclusive based on msg.value: charge ETH only when msg.value > 0 (and refund any excess), and only fall back to WETH when no ETH was attached. This removes the double-charge and gives buyers deterministic settlement.

function buySnow(uint256 amount) external payable canFarmSnow {
+ uint256 cost = s_buyFee * amount;
- if (msg.value == (s_buyFee * amount)) {
- _mint(msg.sender, amount);
- } else {
- i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
- _mint(msg.sender, amount);
- }
+ if (msg.value > 0) {
+ if (msg.value < cost) revert S__ZeroValue();
+ if (msg.value > cost) {
+ (bool ok, ) = msg.sender.call{value: msg.value - cost}("");
+ require(ok, "refund failed");
+ }
+ } else {
+ i_weth.safeTransferFrom(msg.sender, address(this), cost);
+ }
+ _mint(msg.sender, amount);
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 9 days 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!