Snowman Merkle Airdrop

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

buySnow() payment branching logic charges WETH in full and keeps any sent ETH when msg.value doesn't match the exact fee, causing double payment

Root + Impact

Description

buySnow() is designed to accept either native ETH or WETH. The implementation checks for an exact ETH match and falls to WETH otherwise:

function buySnow(uint256 amount) external payable canFarmSnow {
// @> Only exact ETH is accepted; any other msg.value falls through
if (msg.value == (s_buyFee * amount)) {
_mint(msg.sender, amount);
} else {
// @> Pulls full WETH fee regardless of whether msg.value > 0
i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount);
}
// ...
}

Risk

Likelihood:

  • Any user who sends ETH with a tiny rounding error (common in frontend integrations, scripts, or manual transactions) triggers this silently.

  • The condition requires only that msg.value > 0 and msg.value != s_buyFee * amount, which is extremely easy to satisfy accidentally.

  • Since s_buyFee is scaled by 1e18, any off-by-one in the frontend fee calculation causes this.

Impact:

  • The user loses all sent ETH (no refund) and is also charged the full WETH amount — effectively double-paying.

  • If the user doesn't have sufficient WETH approved, the transaction reverts after the ETH is already deducted from their balance (ETH loss with no tokens received — but ETH is not actually taken until after the revert... wait, ETH IS sent with the tx so it stays in contract regardless of revert behavior. On revert ETH IS returned. But if safeTransferFrom succeeds, both are taken.)

  • At 5 ETH per token, a user attempting to buy 10 tokens could lose 50 ETH to the contract while also having 50 ETH in WETH pulled — a 100 ETH double-charge scenario.

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 {DeploySnow} from "../script/DeploySnow.s.sol";
import {MockWETH} from "../src/mock/MockWETH.sol";
contract DoubleChargePoC is Test {
Snow snow;
MockWETH weth;
uint256 FEE;
function setUp() public {
DeploySnow deployer = new DeploySnow();
snow = deployer.run();
weth = deployer.weth();
FEE = deployer.FEE(); // 5e18 per token
}
function testDoubleChargeOnWrongEthAmount() public {
address victim = makeAddr("victim");
// Victim has ETH and WETH
deal(victim, FEE + 1); // 1 wei too much ETH
weth.mint(victim, FEE);
vm.startPrank(victim);
weth.approve(address(snow), FEE);
uint256 ethBefore = victim.balance; // FEE + 1
uint256 wethBefore = weth.balanceOf(victim); // FEE
// Sends slightly wrong ETH amount — off by 1 wei
snow.buySnow{value: FEE + 1}(1);
uint256 ethAfter = victim.balance;
uint256 wethAfter = weth.balanceOf(victim);
vm.stopPrank();
// ETH was NOT refunded (contract kept it)
assertEq(ethAfter, 0);
// WETH was ALSO pulled in full
assertEq(wethAfter, 0);
// Victim paid FEE+1 ETH AND FEE WETH for a single Snow token
// Total cost: 2*FEE + 1 instead of FEE
assertEq(snow.balanceOf(victim), 1);
}
}

Recommended Mitigation

Separate ETH and WETH paths explicitly. Require exact ETH for the ETH path and zero ETH for the WETH path, and refund any surplus ETH:

function buySnow(uint256 amount) external payable canFarmSnow {
uint256 totalFee = s_buyFee * amount;
if (msg.value > 0) {
// ETH payment path — require exact amount
if (msg.value != totalFee) {
revert S__IncorrectEthAmount();
}
_mint(msg.sender, amount);
} else {
// WETH payment path — require no ETH was sent
i_weth.safeTransferFrom(msg.sender, address(this), totalFee);
_mint(msg.sender, amount);
}
emit SnowBought(msg.sender, amount);
}
Updates

Lead Judging Commences

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