Snowman Merkle Airdrop

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

# `Snow::buySnow` does not refund ETH when `msg.value` mismatches the fee, causing double payment and permanent ETH loss

Description

  • The Snow::buySnow function accepts both native ETH and WETH for payment. When msg.value exactly equals the fee, the user pays with ETH. Otherwise, the function falls through to the else branch and charges the user in WETH via safeTransferFrom.

  • When a user sends ETH with the call but the amount does not exactly match the fee, the ETH is accepted by the payable function and remains trapped in the contract, while the user is simultaneously charged the full fee in WETH. The user pays twice for a single Snow token.

// src/Snow.sol:79-89
function buySnow(uint256 amount) external payable canFarmSnow {
if (msg.value == (s_buyFee * amount)) {
_mint(msg.sender, amount);
} else {
// @> When msg.value > 0 but != fee, the ETH is NOT refunded
// @> AND the user is charged in WETH — double payment
i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount);
}
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}

Risk

Likelihood:

  • A user who has pre-approved the Snow contract for WETH and calls buySnow with a non-exact ETH amount triggers the double payment

  • Sending any msg.value that does not precisely equal s_buyFee * amount activates the WETH branch while keeping the ETH

Impact:

  • User loses the ETH sent with the transaction — stuck permanently in the Snow contract

  • User is also charged the full fee in WETH — paying twice for one Snow token

  • The trapped ETH can only be recovered by the fee collector via collectFee(), not by the victim

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_H04 is Test {
Snow snow;
MockWETH weth;
address victim;
address collector;
function setUp() public {
weth = new MockWETH();
collector = makeAddr("collector");
snow = new Snow(address(weth), 5, collector);
victim = makeAddr("victim");
}
// H-04: buySnow does not refund ETH when msg.value does not match fee.
// If msg.value > 0 but != fee*amount, the ETH stays in the contract
// and the user is ALSO charged the full fee in WETH — double payment.
function test_H04_BuySnowEthLoss() public {
uint256 fee = snow.s_buyFee(); // 5e18
deal(victim, 1 ether);
weth.mint(victim, fee);
console2.log("Victim ETH balance before:", victim.balance);
console2.log("Victim WETH balance before:", weth.balanceOf(victim));
console2.log("Buy fee:", fee);
// Victim approves WETH (normal pre-buy step), then calls buySnow with wrong ETH amount
vm.startPrank(victim);
weth.approve(address(snow), fee);
snow.buySnow{value: 1 ether}(1); // 1 ETH != 5e18 fee → falls to else branch
vm.stopPrank();
console2.log("Victim ETH balance after:", victim.balance);
console2.log("Victim WETH balance after:", weth.balanceOf(victim));
console2.log("Snow contract ETH balance:", address(snow).balance);
assertEq(victim.balance, 0, "Victim lost all ETH");
assertEq(weth.balanceOf(victim), 0, "Victim also paid WETH");
assertEq(address(snow).balance, 1 ether, "ETH stuck in Snow contract");
assertEq(snow.balanceOf(victim), 1, "Only got 1 Snow token for double payment");
console2.log("RESULT: Victim paid 1 ETH + 5e18 WETH for 1 Snow token (should only cost one)");
}
}

Output:

[PASS] test_H04_BuySnowEthLoss() (gas: 209467)
Logs:
Victim ETH balance before: 1000000000000000000
Victim WETH balance before: 5000000000000000000
Buy fee: 5000000000000000000
Victim ETH balance after: 0
Victim WETH balance after: 0
Snow contract ETH balance: 1000000000000000000
RESULT: Victim paid 1 ETH + 5e18 WETH for 1 Snow token (should only cost one)

Recommended Mitigation

Add an explicit else if (msg.value == 0) guard for the WETH payment path, and a final else branch that reverts with S__InvalidPayment() when ETH is sent but does not match the exact fee. This prevents the function from silently accepting ETH while simultaneously charging WETH, ensuring users can only pay through one method per call.

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

Lead Judging Commences

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