Snowman Merkle Airdrop

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

Snow.buySnow silently captures user ETH when msg.value mismatches and the WETH path succeeds; ETH only recoverable by s_collector

Description

Snow.buySnow accepts payment in either native ETH (msg.value == s_buyFee * amount) or WETH (the else branch). The WETH path executes whenever msg.value != s_buyFee * amount — including when msg.value > 0. If a user has approved enough WETH but accidentally sends ETH alongside the call, the WETH is pulled, Snow is minted, and the ETH is silently captured by the contract with no refund and no revert.

// src/Snow.sol
function buySnow(uint256 amount) external payable canFarmSnow {
if (msg.value == (s_buyFee * amount)) { // @> exact ETH path
_mint(msg.sender, amount);
} else { // @> WETH path — catches non-zero msg.value too
i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount);
}
// @> no refund of msg.value when WETH path is taken
}

The captured ETH can only be withdrawn by s_collector via collectFee() — it is not refundable to the user.

Risk

Any user or frontend that accidentally includes ETH in what they intended as a WETH purchase silently loses that ETH. The loss is permanent from the user's perspective — only the protocol's s_collector can recover the funds. This is a silent fund loss footgun in a publicly callable, payment-handling function.

Proof of Concept

function test_BuySnow_StuckETHWhenWETHPathSucceeds() public {
MockWETH freshWeth = new MockWETH();
Snow fresh = new Snow(address(freshWeth), 5, makeAddr("collector"));
address buyer = makeAddr("buyer");
uint256 amount = 1;
uint256 wethCost = 5e18 * amount;
vm.deal(buyer, 1 ether);
freshWeth.mint(buyer, wethCost);
vm.prank(buyer);
freshWeth.approve(address(fresh), wethCost);
// Buyer accidentally sends 0.5 ETH alongside a WETH purchase
vm.prank(buyer);
fresh.buySnow{value: 0.5 ether}(amount);
assertEq(fresh.balanceOf(buyer), amount); // got Snow
assertEq(freshWeth.balanceOf(buyer), 0); // paid WETH
assertEq(buyer.balance, 0.5 ether); // lost 0.5 ETH — BUG
assertEq(address(fresh).balance, 0.5 ether); // ETH captured by contract
}

Result: [PASS] — buyer loses 0.5 ETH silently while still paying WETH.

Recommended Mitigation

Reject the call if the WETH path is taken but msg.value > 0. This forces the user to commit to one payment path and prevents accidental ETH loss:

function buySnow(uint256 amount) external payable canFarmSnow {
if (msg.value == (s_buyFee * amount)) {
_mint(msg.sender, amount);
} else {
+ if (msg.value != 0) revert S__NotAllowed(); // must commit to one path
i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount);
}
...
}

Alternatively, refund msg.value to msg.sender at the end of the WETH branch instead of reverting, to improve UX.

Updates

Lead Judging Commences

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