Snowman Merkle Airdrop

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

buySnow keeps mismatched non-zero ETH with no refund while also pulling full WETH

Description

buySnow lets a user pay either in native ETH (exact fee) or in WETH. The branch is chosen by comparing msg.value to the exact ETH price:

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)); // @> pulls full WETH
_mint(msg.sender, amount); // @> and keeps any ETH sent
}
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}

The else branch is reached by any msg.value that is not exactly s_buyFee * amount — including a non-zero but wrong ETH amount. In that branch the contract pulls the full WETH price and silently keeps whatever ETH the caller sent. There is no refund path; the stray ETH can only ever be swept out later by the fee collector via collectFee. So a user who sends a non-matching, non-zero ETH value while also having WETH approved both pays in WETH and loses the ETH they attached.

Risk

Likelihood: Low — requires the specific combination of (a) sending a non-exact, non-zero msg.value and (b) having a WETH allowance set, i.e. a user mistake rather than something an attacker triggers for others.

Impact: Medium — the user suffers a direct, unrecoverable (to them) loss of the ETH they attached, on top of paying the full WETH price.

Impact

A user can be double-charged and lose funds: they pay the full WETH price and forfeit the ETH they mistakenly attached, with no refund and no user-accessible recovery (only the collector can withdraw it).

Proof of Concept

The following Foundry test passes. The user has WETH approved and mistakenly attaches 0.5 ETH (not equal to the exact fee), so the else branch runs: the full WETH is pulled and the 0.5 ETH is stranded in the contract with no refund.

function test_F4_mismatchedEthIsLockedNoRefund() public {
MockWETH mweth = new MockWETH();
Snow snow2 = new Snow(address(mweth), 1, makeAddr("collector2")); // s_buyFee = 1e18
address user = makeAddr("user");
​
uint256 amount = 2;
uint256 wethCost = 1e18 * amount;
mweth.mint(user, wethCost);
vm.prank(user);
mweth.approve(address(snow2), wethCost);
​
uint256 strayEth = 0.5 ether; // != wethCost, and non-zero -> else branch
vm.deal(user, strayEth);
​
vm.prank(user);
snow2.buySnow{value: strayEth}(amount);
​
assertEq(snow2.balanceOf(user), amount);
assertEq(mweth.balanceOf(user), 0); // full WETH pulled
assertEq(address(snow2).balance, strayEth); // stray ETH locked in contract
assertEq(user.balance, 0); // no refund
}

Observed log: F4 CONFIRMED: user paid WETH and lost stray ETH (wei) = 500000000000000000.

Recommended Mitigation

Only take the WETH path when no ETH is attached, and reject/refund any non-matching ETH:

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__IncorrectPayment();
}
...
}
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!