Snowman Merkle Airdrop

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

`buySnow` accepts `msg.value` that does not exactly equal the price — overpaid ETH is stranded in the contract

Root + Impact

Description

  • Normal behavior: A user buying Snow with native ETH should pay exactly s_buyFee * amount and receive amount Snow; anything sent above the price should be refunded or the call should revert, never silently absorbed.

  • The issue: buySnow picks the payment rail by exact equality: only when msg.value == s_buyFee * amount does it take the ETH path. If a user attaches ETH but the value is not exactly the price (an overpay, a rounding error, a gas-cost miscalculation, or sending ETH while WETH is also approved), execution falls into the WETH branch: the full WETH price is pulled from the user via safeTransferFrom and the attached ETH stays in the contract. The only way ETH ever leaves the contract is collectFee(), which sends the entire balance to the fee collector — the overpaying user never gets their ETH back.

```solidity
function buySnow(uint256 amount) external payable canFarmSnow {
@> if (msg.value == (s_buyFee * amount)) { // ETH rail only on EXACT equality
_mint(msg.sender, amount);
} else { // any mismatch -> WETH rail
@> i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount); // msg.value stays locked in the contract
}
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}
function collectFee() external onlyCollector {
uint256 collection = i_weth.balanceOf(address(this));
i_weth.transfer(s_collector, collection);
(bool collected,) = payable(s_collector).call{value: address(this).balance}(""); // ALL ETH -> collector
require(collected, "Fee collection failed!!!");
}
```

Risk

Likelihood:

  • Reason 1 — The loss happens when a user sends ETH whose value is not exactly the computed price while WETH is also approved; this requires a user-side mistake or rounding rather than an active attacker.

  • Reason 2 — The scenario is nevertheless realistic (users commonly attach cost + buffer, and integrators miscalculate exact fee amounts), and when it occurs the funds are permanently unrecoverable by the user.

Impact:

  • Impact 1 — The user pays in both assets: the full WETH price is transferred and the attached ETH is forfeited.

  • Impact 2 — The stranded ETH silently accrues to the fee collector via collectFee() instead of being refunded.

Proof of Concept

Test: `forge test --match-contract TestSnowmanPoCs --match-test testPoc4_OverpaidEthLocked -vv` (see `poc/TestSnowmanPoCs.t.sol`).
```solidity
uint256 cost = snow.s_buyFee(); // fee per token
vm.prank(alice);
snow.buySnow{value: cost + 1 ether}(1); // alice sends cost + 1 ETH overpay
// Result:
// weth.balanceOf(address(snow)) == cost (WETH rail executed)
// address(snow).balance == cost + 1 ether (attached ETH locked)
// alice received 1 Snow but paid cost in WETH AND forfeited cost + 1 ether of ETH.
```
The test passes: the WETH branch executes and the overpaid ETH remains locked in the contract.

Recommended Mitigation

```diff
+ error S__WrongPayment(); // add to the errors block
function buySnow(uint256 amount) external payable canFarmSnow {
if (msg.value == (s_buyFee * amount)) {
_mint(msg.sender, amount);
} else {
+ if (msg.value > 0) revert S__WrongPayment(); // never absorb a stray msg.value
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 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!