Snowman Merkle Airdrop

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

`Snow::buySnow` strands ETH and double-charges in WETH when `msg.value` is not exactly the price

Snow::buySnow strands ETH and double-charges in WETH when msg.value is not exactly the price

Description

  • buySnow is meant to accept payment in either native ETH or WETH.

  • It only treats the payment as ETH when msg.value is exactly s_buyFee * amount. Any other msg.value (including slightly too much) falls through to the WETH branch, which charges the buyer again in WETH while keeping the ETH they sent.

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

Risk

Likelihood: Low

  • Sending an inexact ETH amount is a common user mistake; amount == 0 also passes silently and just updates state.

Impact: Medium

  • The buyer is double-charged (WETH on top of the ETH already sent) and the sent ETH is stranded in the contract, recoverable only by the collector via collectFee — a direct loss of user funds.

Proof of Concept

Scenario: a user is set up to pay in WETH (funded + approved) but also sends ETH that is not exactly s_buyFee * amount (here, off by 1 wei). Because the if requires an exact match, execution falls into the else branch: the contract pulls the full price in WETH and keeps the ETH the user sent. The user pays twice for one token, and the ETH is stranded (only collectFee can ever move it).

Drop this into test/PoC_Lows.t.sol (uses the project's MockWETH):

function test_L1_buySnowStrandsEthAndDoubleCharges() public {
MockWETH weth = new MockWETH();
Snow snow = new Snow(address(weth), FEE, collector); // FEE = 5
uint256 price = snow.s_buyFee() * 1; // price for 1 token
// User is funded and approved to pay in WETH.
weth.mint(user, price);
vm.prank(user);
weth.approve(address(snow), price);
// User also sends ETH, but not the EXACT price (off by 1 wei).
vm.deal(user, price + 1);
vm.prank(user);
snow.buySnow{value: price + 1}(1);
assertEq(snow.balanceOf(user), 1); // received 1 token
assertEq(weth.balanceOf(user), 0); // charged the FULL price in WETH
assertEq(weth.balanceOf(address(snow)), price); // WETH pulled into contract
assertEq(address(snow).balance, price + 1); // ...and the ETH is stranded
}

Run: forge test --mt test_L1_buySnowStrandsEthAndDoubleCharges -vv

Result:

[PASS] test_L1_buySnowStrandsEthAndDoubleCharges() (gas: 2478982)

The user paid price WETH and price + 1 ETH for a single token; the ETH is locked in the contract.

Recommended Mitigation

Make the payment path explicit: if any ETH is sent, require it to equal the exact price and use the ETH path; otherwise use WETH. Reject zero amounts.

function buySnow(uint256 amount) external payable canFarmSnow {
+ if (amount == 0) revert S__ZeroValue();
- 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 > 0) {
+ require(msg.value == s_buyFee * amount, "incorrect ETH amount");
+ } else {
+ i_weth.safeTransferFrom(msg.sender, address(this), s_buyFee * amount);
+ }
+ _mint(msg.sender, amount);
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}
Updates

Lead Judging Commences

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