Snowman Merkle Airdrop

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

Imprecise ETH in `buySnow` Triggers Silent WETH Charge — User Pays Twice for One Mint, ETH Stuck in Contract

Description

The intended behavior of buySnow is to let a user pay either with native ETH or with WETH for the same Snow mint. The function picks a payment rail based on msg.value: if the user sent exactly s_buyFee * amount wei, the ETH path is used and no WETH is touched; otherwise, the WETH path is used and the ETH (if any) is silently retained by the contract.

The specific issue is that the branching logic uses an exact equality check (msg.value == s_buyFee * amount) instead of a threshold check. Any user who underpays ETH by even a single wei — because of a UI rounding error, a stale fee cache, or a manual miscalculation — falls through to the WETH branch. The WETH branch charges the full s_buyFee * amount in WETH via safeTransferFrom, but the misplaced ETH is now locked in the contract with no refund path. The only entity that can recover it is the fee collector via collectFee(). The user has paid twice (ETH + WETH) and received one mint.

// Snow.sol — buySnow()
function buySnow(uint256 amount) external payable canFarmSnow {
// @> Exact-equality check: any underpayment by even 1 wei falls through to the WETH branch.
if (msg.value == s_buyFee * amount) {
// ETH path: msg.value is retained as fee.
} else {
// @> WETH path: full fee charged from allowance, but the misplaced ETH is NOT refunded.
i_weth.safeTransferFrom(msg.sender, address(this), s_buyFee * amount);
// @> No `if (msg.value > 0) msg.sender.call{value: msg.value}("")` — ETH is silently stranded.
}
_mint(msg.sender, amount);
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}

Risk

Likelihood:

Reason 1: The condition msg.value == s_buyFee * amount is brittle. The user must compute s_buyFee * amount exactly off-chain and forward precisely that amount of wei. Any wallet that rounds the displayed fee, any UI that caches the fee for more than one block, or any manual calculation error — including being off by a single wei — routes the user into the WETH branch.

Reason 2: The contract does not advertise that the WETH branch can also receive ETH; the function signature buySnow(uint256 amount) payable and the parameter naming suggest the user is choosing one of two payment rails, not that imprecise ETH will be silently absorbed while WETH is also charged. Users who already hold WETH and approve the contract for the fee are especially prone to sending "almost enough" ETH and being double-charged.

Impact:

Impact 1: The user loses the entire msg.value of ETH (locked in the Snow contract, recoverable only by the fee collector via collectFee) AND the full s_buyFee * amount of WETH (transferred to the Snow contract as the actual fee). The user receives only one Snow mint in return — effectively paying twice for the same purchase.

Impact 2: The fee collector gains the stranded ETH, which creates a perverse incentive for the fee collector to discourage refunds of imprecise ETH payments. There is no on-chain mechanism for the user to recover their stranded ETH; recovery depends entirely on the off-chain goodwill of the collector role.

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 M03_DualPayment is Test {
Snow public snow;
MockWETH public weth;
address public collector;
address public carol;
uint256 public buyFee;
function setUp() public {
collector = makeAddr("collector");
weth = new MockWETH();
snow = new Snow(address(weth), 5, collector); // buyFee = 5 (in PRECISION units)
buyFee = snow.s_buyFee();
carol = makeAddr("carol");
// Carol has both WETH and ETH — common for users who don't know which rail they'll use.
weth.mint(carol, buyFee);
vm.prank(carol);
weth.approve(address(snow), buyFee);
deal(carol, buyFee * 2);
}
function test_silentDualPaymentOffByOneWei() public {
uint256 carolEthBefore = carol.balance;
uint256 carolWethBefore = weth.balanceOf(carol);
// Carol sends 1 wei less than the required ETH fee.
uint256 wrongEth = buyFee - 1;
vm.prank(carol);
snow.buySnow{value: wrongEth}(1);
// Carol lost BOTH ETH and WETH.
assertEq(carol.balance, carolEthBefore - wrongEth); // ETH stranded in Snow contract
assertEq(weth.balanceOf(carol), carolWethBefore - buyFee); // WETH also charged
assertEq(snow.balanceOf(carol), 1); // Only 1 Snow minted
// The stranded ETH is recoverable ONLY by the collector.
assertGt(address(snow).balance, 0);
vm.prank(collector);
snow.collectFee();
assertEq(address(snow).balance, 0);
}
function test_exactEthWorksFine() public {
// Baseline: precise ETH payment works correctly (no WETH charged).
vm.prank(carol);
snow.buySnow{value: buyFee}(1);
assertEq(snow.balanceOf(carol), 1);
assertEq(weth.balanceOf(carol), buyFee); // WETH untouched
}
function test_wethOnlyWorksFine() public {
// Baseline: explicit zero ETH + WETH path works correctly.
vm.prank(carol);
snow.buySnow{value: 0}(1);
assertEq(snow.balanceOf(carol), 1);
assertEq(weth.balanceOf(carol), 0);
assertEq(carol.balance, buyFee * 2); // ETH untouched
}
}

Recommended Mitigation

// Snow.sol — buySnow()
function buySnow(uint256 amount) external payable canFarmSnow {
- if (msg.value == s_buyFee * amount) {
- // ETH path
- } else {
- i_weth.safeTransferFrom(msg.sender, address(this), s_buyFee * amount);
- }
+ uint256 required = s_buyFee * amount;
+ if (msg.value >= required) {
+ // ETH path: charge exactly `required`, refund the surplus.
+ uint256 refund = msg.value - required;
+ if (refund > 0) {
+ (bool ok, ) = msg.sender.call{value: refund}("");
+ require(ok, "ETH refund failed");
+ }
+ } else {
+ // WETH path: any ETH sent is refunded in full.
+ i_weth.safeTransferFrom(msg.sender, address(this), required);
+ if (msg.value > 0) {
+ (bool ok, ) = msg.sender.call{value: msg.value}("");
+ require(ok, "ETH refund failed");
+ }
+ }
_mint(msg.sender, amount);
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}

The fix (a) replaces exact-equality with >= so ETH overpayments route to the ETH path with an automatic refund of the surplus, and (b) explicitly refunds any ETH sent in the WETH path. Users can no longer be silently double-charged.

Updates

Lead Judging Commences

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