Snowman Merkle Airdrop

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

`Snow::buySnow` treats any mismatched `msg.value` as a WETH purchase, charging the user in both assets and keeping the ETH with no refund

buySnow infers the payment asset from an exact-equality check, so a mismatched msg.value is silently swallowed on top of the full WETH price

Description

  • Snow can be bought with either native ETH or WETH — the README states the token "can be bought with either WETH or native ETH". A buyer picks one asset, pays the price once, and receives their tokens.

  • buySnow decides which asset was used by testing whether msg.value is exactly the price. Any other value falls through to the WETH branch, which pulls the full price in WETH while the ETH the user attached stays in the contract. The function is payable, has no msg.value == 0 requirement on that branch, and contains no refund path anywhere, so the user is charged twice and cannot recover the difference.

// src/Snow.sol:79-90
function buySnow(uint256 amount) external payable canFarmSnow {
@> if (msg.value == (s_buyFee * amount)) { // @> exact equality is the ONLY thing separating the paths
_mint(msg.sender, amount);
} else {
@> i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
// @> full WETH price pulled, while msg.value is neither refunded nor required to be zero
_mint(msg.sender, amount);
}
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}

The stranded ETH is not merely stuck — it is later swept to the fee collector, so the protocol keeps the user's mistake:

// src/Snow.sol:101-107
function collectFee() external onlyCollector {
uint256 collection = i_weth.balanceOf(address(this));
i_weth.transfer(s_collector, collection);
@> (bool collected,) = payable(s_collector).call{value: address(this).balance}("");
// @> the victim's mismatched ETH is part of address(this).balance and leaves with the fee
require(collected, "Fee collection failed!!!");
}

There is no receive, no withdraw, and no rescue function in Snow, so once the ETH is in the contract the payer has no route back.

The magnitude follows from the deployment parameters. script/DeploySnow.s.sol:13 passes FEE = 5, and the constructor scales it: s_buyFee = _buyFee * PRECISION (src/Snow.sol:73), giving 5 ETH per unit. A single-unit off-by-one therefore costs the buyer 5 ETH on top of the 5 WETH they meant to pay.

Risk

Likelihood:

  • This triggers whenever a buyer attaches ETH while intending a WETH purchase, or miscalculates the amount by any margin — for example when a wallet attaches value by default, when the buyer reads s_buyFee and computes the product off-chain with a stale or rounded value, or when they simply pass the wrong amount.

  • Nothing in the contract warns or reverts. The transaction succeeds, tokens are minted, and an event is emitted, so the buyer receives every signal that the purchase went correctly.

  • No project test covers a mismatched msg.value, so this path has never been exercised.

  • An attacker cannot force the condition, which is why Likelihood is set to Low rather than higher. s_buyFee is written only in the constructor (src/Snow.sol:73) and has no setter, so there is no fee-change front-run that could push a correctly-computed transaction into the wrong branch. The victim must independently hold a live WETH allowance and send a wrong msg.value.

Impact:

  • Direct and unrecoverable loss of user funds — with the deployed parameters, 5 ETH per unit of error, on top of the WETH the user correctly paid.

  • The loss is silent: no revert, no refund, no event distinguishing the double-charge from a normal purchase.

  • The lost ETH is credited to the fee collector through collectFee, so the protocol profits from the user's error rather than merely stranding it.

Proof of Concept

The buyer sends one wei less than the exact price and pays roughly ten ETH for one unit of Snow. Save as test/PoCM03.t.sol and run forge test --match-contract PoCM03 -vv. The fixture uses the same constructor arguments as the project's own script/DeploySnow.s.sol (FEE = 5).

// 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 PoCM03 is Test {
Snow snow;
MockWETH weth;
address collector = makeAddr("collector");
address alice = makeAddr("alice");
uint256 constant FEE = 5; // the same value the project's DeploySnow.s.sol uses
function setUp() public {
weth = new MockWETH();
snow = new Snow(address(weth), FEE, collector);
}
function test_M03_MismatchedEthIsKeptAndWethIsAlsoCharged() public {
weth.mint(alice, 100 ether);
vm.deal(alice, 100 ether);
vm.prank(alice);
weth.approve(address(snow), type(uint256).max);
uint256 ethBefore = alice.balance;
uint256 wethBefore = weth.balanceOf(alice);
vm.prank(alice);
snow.buySnow{value: 5 ether - 1}(1); // one wei short of the exact price
assertEq(weth.balanceOf(alice), wethBefore - 5 ether, "WETH charged in full");
assertEq(alice.balance, ethBefore - (5 ether - 1), "ETH taken as well");
assertEq(address(snow).balance, 5 ether - 1, "and it is stuck in the contract");
assertEq(snow.balanceOf(alice), 1, "for 1 unit of Snow");
// the stranded ETH is not just stuck - it leaves with the fee
vm.prank(collector);
snow.collectFee();
assertEq(collector.balance, 5 ether - 1, "victim's ETH credited to the collector");
assertEq(address(snow).balance, 0);
}
}

Result:

[PASS] test_M03_MismatchedEthIsKeptAndWethIsAlsoCharged() (gas: 285595)
Suite result: ok. 1 passed; 0 failed; 0 skipped

Recommended Mitigation

Make the payment path explicit rather than inferred, and refund any excess instead of retaining it.

function buySnow(uint256 amount) external payable canFarmSnow {
+ if (amount == 0) {
+ revert S__ZeroValue();
+ }
+
+ 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();
+ }
+ _mint(msg.sender, amount);
+ if (msg.value > cost) {
+ (bool refunded,) = payable(msg.sender).call{value: msg.value - cost}("");
+ require(refunded, "Refund failed");
+ }
+ } else {
+ i_weth.safeTransferFrom(msg.sender, address(this), cost);
+ _mint(msg.sender, amount);
+ }
emit SnowBought(msg.sender, amount);
}
Updates

Lead Judging Commences

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