Snowman Merkle Airdrop

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

`Snow::buySnow` treats any inexact `msg.value` as a WETH purchase, so it keeps the ETH the buyer attached and additionally charges the full price in WETH with no refund

Description

buySnow supports two payment methods: pay the exact price in native ETH, or pay in WETH.

The branch distinguishing them tests only for the exact ETH price. Any other msg.value — including a near miss — falls into the else branch, which pulls the full price in WETH and mints. The ETH attached to that same call is never refunded and never credited; it simply stays in the contract. The buyer pays twice and receives the normal amount.

// src/Snow.sol
function buySnow(uint256 amount) external payable canFarmSnow {
@> if (msg.value == (s_buyFee * amount)) { // exact match only
_mint(msg.sender, amount);
} else {
@> i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount);
}
@> // msg.value is never refunded or credited
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}

Risk

Likelihood:

  • Every time a buyer attaches a msg.value that is not exactly s_buyFee * amount. The function is payable and silently accepts any value, so there is no guard rail.

  • The price is a 1e18-scaled product (s_buyFee = _buyFee * PRECISION, so 5 ETH per unit at the deploy script's FEE = 5), which makes an off-by-a-factor miscalculation a routine integration and front-end error rather than an exotic one.

  • It also triggers on the reverse mistake: a user intending to pay in WETH who leaves a non-zero msg.value in the transaction loses it.

Impact:

  • Direct, unrecoverable loss of the buyer's ETH. There is no refund path and no owner-callable rescue — the only way the ETH ever leaves is through collectFee, which sends it to the fee collector.

  • The buyer pays roughly double for the same quantity of Snow, with no error and no event distinguishing the two payment paths.

Proof of Concept

Paste the harness and test function(s) into test/PoC.t.sol, placing test functions inside contract PoC. Any additional helper contracts go outside contract PoC. Run forge test --match-path test/PoC.t.sol -vv.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console2} from "forge-std/Test.sol";
import {Snow} from "../src/Snow.sol";
import {Snowman} from "../src/Snowman.sol";
import {SnowmanAirdrop} from "../src/SnowmanAirdrop.sol";
import {MockWETH} from "../src/mock/MockWETH.sol";
contract PoC is Test {
Snow snow;
Snowman nft;
MockWETH weth;
address collector = makeAddr("collector");
address attacker = makeAddr("attacker");
address victim = makeAddr("victim");
function setUp() public {
weth = new MockWETH();
snow = new Snow(address(weth), 1, collector);
nft = new Snowman("ipfs://snowman");
}
function _leaf(address who, uint256 amount) internal pure returns (bytes32) {
return keccak256(bytes.concat(keccak256(abi.encode(who, amount))));
}
// Insert the test function below here.
}

The buyer sends 1 ETH toward a 2-ETH purchase and ends down 1 ETH and 2 WETH, holding only the ordinary amount of Snow.

function test_PartialEthPaymentIsKeptAndWethIsAlsoCharged() public {
address buyer = makeAddr("buyer");
uint256 amount = 2; // correct price = 2 ether
vm.deal(buyer, 10 ether);
weth.mint(buyer, 10 ether);
vm.prank(buyer);
weth.approve(address(snow), type(uint256).max);
uint256 ethBefore = buyer.balance;
uint256 wethBefore = weth.balanceOf(buyer);
vm.prank(buyer);
snow.buySnow{value: 1 ether}(amount);
assertEq(buyer.balance, ethBefore - 1 ether, "buyer's ETH was taken");
assertEq(weth.balanceOf(buyer), wethBefore - 2 ether, "buyer ALSO paid full price in WETH");
assertEq(address(snow).balance, 1 ether, "contract silently kept the stray ETH");
assertEq(snow.balanceOf(buyer), amount, "but received only the normal amount");
}

Recommended Mitigation

Treat a non-zero msg.value as an explicit intent to pay in ETH and reject it when it is not exact, rather than falling through to the WETH path.

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);
- }
+ uint256 cost = s_buyFee * amount;
+ if (msg.value > 0) {
+ if (msg.value != cost) {
+ revert S__ZeroValue(); // or a dedicated S__IncorrectPayment()
+ }
+ } else {
+ i_weth.safeTransferFrom(msg.sender, address(this), cost);
+ }
+ _mint(msg.sender, amount);
Updates

Lead Judging Commences

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