Snowman Merkle Airdrop

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

buySnow Payment Validation Allows Unintended ETH Acceptance

Description

  • The buySnow() function is intended to accept either exact ETH payment OR WETH transfer for purchasing Snow tokens.

  • The current implementation uses an if-else structure that mints tokens when msg.value matches the expected fee, and falls through to WETH transfer otherwise. This means any non-matching msg.value (including partial payments) will be accepted and kept by the contract while also charging WETH.

// src/Snow.sol:79-90
function buySnow(uint256 amount) external payable canFarmSnow {
// @> Only checks for exact match - any other msg.value falls through
if (msg.value == (s_buyFee * amount)) {
_mint(msg.sender, amount);
} else {
// @> User's ETH is kept, AND they're charged WETH
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:

  • Users may accidentally send partial ETH along with their transaction (wallet defaults, user error)

  • Front-end bugs or incorrect gas estimations could cause non-zero msg.value to be sent unintentionally

  • Users unfamiliar with the dual-payment system may send some ETH expecting partial payment

Impact:

  • Users lose both their sent ETH AND their WETH when msg.value doesn't exactly match

  • Sent ETH becomes trapped in the contract (only collector can withdraw)

  • Double-charging users for Snow tokens

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 PaymentFallthroughPoC is Test {
Snow snow;
MockWETH weth;
address alice = makeAddr("alice");
function setUp() public {
weth = new MockWETH();
// buyFee = 1, so s_buyFee = 1e18 wei per token
snow = new Snow(address(weth), 1, address(this));
}
function testPartialEthPaymentDoubleCharges() public {
uint256 buyFee = snow.s_buyFee();
// Give Alice some ETH and WETH
vm.deal(alice, 1 ether);
weth.mint(alice, 2 ether);
vm.startPrank(alice);
weth.approve(address(snow), type(uint256).max);
uint256 aliceEthBefore = alice.balance;
uint256 aliceWethBefore = weth.balanceOf(alice);
// Alice accidentally sends 0.5 ETH (not exact amount)
// Expecting to pay in ETH, but it falls through to WETH
snow.buySnow{value: 0.5 ether}(1);
vm.stopPrank();
// Alice lost 0.5 ETH
assertEq(alice.balance, aliceEthBefore - 0.5 ether);
// Alice ALSO lost 1 WETH (the full buy fee)
assertEq(weth.balanceOf(alice), aliceWethBefore - buyFee);
// Alice paid 1.5x the intended price
// 0.5 ETH is stuck in contract
assertEq(address(snow).balance, 0.5 ether);
}
}

Recommended Mitigation

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

Add new error:

error S__NotAllowed();
error S__ZeroAddress();
error S__ZeroValue();
error S__Timer();
error S__SnowFarmingOver();
+ error S__InvalidPayment();
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!