Snowman Merkle Airdrop

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

Snow.buySnow Double-Charges User by Pulling WETH While Silently Keeping Non-Exact ETH

Root + Impact:
When msg.value is not exactly the fee, the WETH branch still runs (charging WETH) and the sent ETH is neither refunded nor rejected, so the user pays twice and loses the ETH.

Description:
buySnow branches on msg.value == s_buyFee * amount. If not equal, it falls into the else branch which pulls the full WETH fee via safeTransferFrom and mints, while the ETH sent in msg.value is retained by the contract (later swept to the collector by collectFee). There is no check that msg.value is 0 or exactly the fee.

// src/Snow.sol
function buySnow(uint256 amount) external payable canFarmSnow {
if (msg.value == (s_buyFee * amount)) {
_mint(msg.sender, amount);
} else {
@> i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount)); // WETH pulled
_mint(msg.sender, amount);
@> // BUG: msg.value (if non-zero) is neither refunded nor rejected -> ETH lost
}
s_earnTimer = block.timestamp;
}

Risk: Incorrect funds handling / user overpayment loss.

Likelihood: Routinely — a caller attaches ETH that is not exactly s_buyFee * amount (user error or malicious frontend).

Impact: User loses the mis-sent ETH and still pays the WETH fee — effective double payment.

Proof of Concept:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Snow} from "../src/Snow.sol";
import {MockWETH} from "../src/mock/MockWETH.sol";
contract PoC is Test {
Snow snow; MockWETH weth; address attacker;
function setUp() public {
weth = new MockWETH();
snow = new Snow(address(weth), 5, makeAddr("collector"));
attacker = makeAddr("attacker");
}
function test_medium_buySnowDoubleCharge() public {
weth.mint(attacker, 1000e18);
vm.prank(attacker);
weth.approve(address(snow), 1000e18);
uint256 ethBefore = address(snow).balance;
uint256 wethBefore = weth.balanceOf(attacker);
uint256 overpay = 10e18; // intentionally != exact fee (5e18)
vm.prank(attacker);
snow.buySnow{value: overpay}(1);
// Overpaid ETH retained; WETH fee still pulled
assertEq(address(snow).balance, ethBefore + overpay);
assertEq(weth.balanceOf(attacker), wethBefore - snow.s_buyFee());
assertEq(snow.balanceOf(attacker), 1);
}
}

Recommended Mitigation:

// src/Snow.sol
error S__BadPayment();
function buySnow(uint256 amount) external payable canFarmSnow {
uint256 owed = s_buyFee * amount;
if (msg.value == owed) {
_mint(msg.sender, amount);
} else if (msg.value == 0) {
i_weth.safeTransferFrom(msg.sender, address(this), owed);
_mint(msg.sender, amount);
} else {
@> revert S__BadPayment(); // reject ambiguous payments; never keep stray ETH
}
s_earnTimer = block.timestamp;
}
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!