Snowman Merkle Airdrop

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

`Snow.buySnow()` uses `==` for the ETH payment check, so a 1-wei miscalculation charges the user full WETH and permanently sweeps their ETH to the collector

Root + Impact

Description

  • Users buy Snow either by sending exact ETH (msg.value == s_buyFee * amount) or via the WETH branch otherwise; collectFee() lets the collector sweep the contract's full ETH balance.

  • The payment check is exact equality, so over/underpaying by a single wei silently routes the call to the WETH branch: the user is charged the full WETH price, and their entire msg.value stays in the contract with no refund — later swept by the collector. The user pays twice.

function buySnow(uint256 amount) external payable canFarmSnow {
if (msg.value == (s_buyFee * amount)) { // @> exact equality: off by 1 wei falls through
_mint(msg.sender, amount);
} else {
i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount)); // @> full WETH price charged anyway
_mint(msg.sender, amount);
} // @> msg.value is never refunded in either branch
...
}
​
function collectFee() external onlyCollector {
...
(bool collected,) = payable(s_collector).call{value: address(this).balance}(""); // @> sweeps ALL trapped ETH

Risk

Likelihood:

  • s_buyFee is _buyFee * 1e18 (e.g. 5000000000000000000 per token) — any frontend rounding or manual input off by 1 wei hits the WETH branch while still sending ETH.

  • For users who hold both WETH and an approval to Snow (common for DeFi users), the tx succeeds and tokens arrive — the loss is silent.

Impact:

  • Double payment: the user pays the full WETH price and forfeits their entire msg.value. Even buySnow{value: 1 ether}(0) traps 1 ETH while minting nothing.

  • The collector can sweep all trapped ETH at any time; users have no refund path.

Proof of Concept

forge test --mt test_03_ethMispaySweptByCollector -vvv

// 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 AuditPoC_M1 is Test {
Snow snow;
MockWETH weth;
address collector = makeAddr("collector");
uint256 fee;
​
function setUp() public {
weth = new MockWETH();
snow = new Snow(address(weth), 5, collector);
fee = snow.s_buyFee(); // 5e18
}
​
function test_03_ethMispaySweptByCollector() public {
address u1 = makeAddr("overpayer");
weth.mint(u1, fee);
deal(u1, fee + 1);
vm.startPrank(u1);
weth.approve(address(snow), fee);
snow.buySnow{value: fee + 1}(1); // intends ETH, overpays by 1 wei -> WETH branch
vm.stopPrank();
​
assertEq(weth.balanceOf(address(snow)), fee); // full WETH pulled
assertEq(address(snow).balance, fee + 1); // AND all ETH trapped
assertEq(u1.balance, 0); // user paid twice, silently
​
vm.prank(collector);
snow.collectFee(); // collector sweeps everything
assertEq(collector.balance, fee + 1);
assertEq(weth.balanceOf(collector), fee);
}
}

Recommended Mitigation

function buySnow(uint256 amount) external payable canFarmSnow {
- if (msg.value == (s_buyFee * amount)) {
+ if (msg.value >= (s_buyFee * amount)) {
_mint(msg.sender, amount);
+ uint256 excess = msg.value - (s_buyFee * amount);
+ if (excess > 0) {
+ (bool refunded,) = payable(msg.sender).call{value: excess}("");
+ require(refunded, "Refund failed");
+ }

Also add if (amount == 0) revert S__ZeroValue(); to block no-op value-trapping calls.

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!