Snowman Merkle Airdrop

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

Mismatched ETH Payment Gets Permanently Stuck in buySnow()

Description

buySnow() decides the payment path using strict equality:

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));
_mint(msg.sender, amount);
}
...
}

If msg.value matches the fee exactly, payment is taken in ETH. Any other value — including overpaying by even 1 wei — routes to the else branch, which pulls payment in WETH via safeTransferFrom instead. Whatever ETH was sent alongside that call is never checked, never used, and never refunded — it simply accumulates in the contract's ETH balance.

Risk

Likelihood: Medium. This repo ships no frontend, so callers interact either directly (e.g. via a block explorer's "Write Contract" tab, where the value field is filled in manually with no validation) or through third-party scripts computing msg.value themselves. A mismatch doesn't require any special exploit setup — it's the natural result of a fat-fingered value field or a rounding bug in a caller's script — but it does require the user to have both ETH and approved WETH available at once, which narrows how often it occurs in practice.

Impact: Medium. Funds are directly lost, but the loss is bounded per-transaction to whatever excess ETH was sent — this isn't a protocol-wide drain, and there's no way for an attacker to force another user into this state (it requires the victim's own transaction to misconfigure msg.value). Still, a well-designed payable function should never silently retain unaccounted-for funds with no recovery path, regardless of how the mismatch occurs.

Proof of Concept

function test_M1_MismatchedEthGetsStuck() public {
MockWETH localWeth = new MockWETH();
Snow localSnow = new Snow(address(localWeth), 5, makeAddr("collector"));
uint256 fee = localSnow.s_buyFee();
address buyer = makeAddr("buyer");
deal(buyer, fee + 1);
localWeth.mint(buyer, fee);
vm.prank(buyer);
localWeth.approve(address(localSnow), fee);
uint256 contractBalanceBefore = address(localSnow).balance;
vm.prank(buyer);
localSnow.buySnow{value: fee + 1}(1);
uint256 contractBalanceAfter = address(localSnow).balance;
assertEq(contractBalanceAfter, contractBalanceBefore + fee + 1);
assertEq(localSnow.balanceOf(buyer), 1);
}

Result: PASS. Buyer sends fee + 1 wei of ETH, still gets their Snow token (paid for via WETH), and the extra 1 wei of ETH remains permanently in the contract with no way to retrieve it.

Recommended Mitigation

Either reject any mismatched msg.value outright:

if (msg.value > 0 && msg.value != (s_buyFee * amount)) revert S__InvalidPayment();

or explicitly refund the excess ETH before or after the WETH transfer path executes.

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!