Snowman Merkle Airdrop

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

Snow.sol::buySnow() partial ETH triggers WETH branch causing unexpected behavior

Description (Root + Impact)

Description:
The buySnow() function checks if msg.value exactly equals the required fee. If not, it falls through to the WETH branch. This means sending partial ETH will NOT use that ETH but instead attempt a WETH transfer. The sent ETH remains stuck in the contract.
Impact:

  • Users who accidentally send wrong ETH amount lose their ETH

  • Confusing payment logic leads to unexpected behavior

  • ETH can become stuck in contract (only collector can withdraw)

  • Poor user experience and potential fund loss

Root Cause (Solidity box)

// @> In Snow.sol:79-85, logic confusion with partial ETH
function buySnow(uint256 amount) external payable canFarmSnow {
// @> Only EXACT match triggers ETH payment path
if (msg.value == (s_buyFee * amount)) {
_mint(msg.sender, amount);
} else {
// @> ANY other msg.value falls here, including partial ETH!
// @> The partial ETH sent is NOT refunded - it stays in contract
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:

  • Occurs when users make typos in ETH amount

  • Common UX mistake, especially with decimals

  • Anyone sending non-exact ETH amount is affected
    Impact:

  • User's ETH stuck in contract (loss of funds)

  • Only collector can retrieve via collectFee()

  • User may also have WETH taken if they have allowance

Proof of Concept (Solidity box)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console2} from "forge-std/Test.sol";
import {Snow} from "../src/Snow.sol";
import {DeploySnow} from "../script/DeploySnow.s.sol";
contract PaymentLogicPOC is Test {
Snow snow;
function setUp() public {
DeploySnow deployer = new DeploySnow();
snow = deployer.run();
}
function testM03_PartialETHTrigersWETHBranch() public {
uint256 buyFee = snow.s_buyFee();
address victim = makeAddr("victim");
// Step 1: Give victim some ETH
deal(victim, buyFee);
console2.log("Step 1 - Victim has", buyFee, "wei ETH");
console2.log(" Required fee:", buyFee, "wei");
// Step 2: Victim sends HALF the required ETH (common mistake)
uint256 partialETH = buyFee / 2;
console2.log("Step 2 - Victim accidentally sends half:", partialETH, "wei");
// Step 3: Call reverts because else branch tries WETH transfer
// but victim has no WETH allowance
vm.prank(victim);
vm.expectRevert(); // ERC20InsufficientAllowance error
snow.buySnow{value: partialETH}(1);
console2.log("Step 3 - Transaction REVERTED (no WETH allowance)");
// Step 4: Demonstrate the LOGIC BUG
console2.log("");
console2.log("BUG ANALYSIS:");
console2.log("- User sent:", partialETH, "wei ETH");
console2.log("- Required: ", buyFee, "wei");
console2.log("- Since partial != exact, else branch executed");
console2.log("- Else branch tried to take WETH (wrong path!)");
console2.log("- If user HAD WETH allowance, their ETH would be stuck!");
}
function testM03_ETHStuckIfWETHSucceeds() public {
// This shows what happens if user has both partial ETH AND WETH allowance
// The partial ETH gets stuck in the contract
uint256 buyFee = snow.s_buyFee();
console2.log("If user sends partial ETH + has WETH allowance:");
console2.log("1. Partial ETH stays in Snow contract (stuck!)");
console2.log("2. WETH is transferred for full amount");
console2.log("3. User effectively pays MORE than intended");
console2.log("4. Only collector can retrieve stuck ETH");
}
}

Steps to reproduce:

  1. User tries to buy Snow with slightly wrong ETH amount

  2. Exact match fails, else branch executes

  3. Else branch tries WETH transfer (unexpected)

  4. If WETH transfer succeeds, original ETH is stuck
    Run command: forge test --match-test testM03 -vvv

Recommended Mitigation (diff box)

contract Snow is ERC20, Ownable {
+ error S__InvalidPayment();
+
function buySnow(uint256 amount) external payable canFarmSnow {
+ uint256 requiredFee = s_buyFee * amount;
+
- if (msg.value == (s_buyFee * amount)) {
+ if (msg.value == requiredFee) {
+ // Path 1: Exact ETH payment
_mint(msg.sender, amount);
- } else {
- i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
+ } else if (msg.value == 0) {
+ // Path 2: Pure WETH payment (no ETH sent)
+ i_weth.safeTransferFrom(msg.sender, address(this), requiredFee);
_mint(msg.sender, amount);
+ } else {
+ // Path 3: Invalid - partial ETH sent
+ revert S__InvalidPayment();
}
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}
}

Mitigation explanation:

  1. Add explicit S__InvalidPayment error for clarity

  2. Path 1: Exact ETH match → accept ETH payment

  3. Path 2: Zero ETH sent (msg.value == 0) → use WETH

  4. Path 3: Any other amount → revert with clear error

  5. This prevents accidental fund loss and improves UX

Updates

Lead Judging Commences

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