Snowman Merkle Airdrop

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

`Snow::buySnow` overcharges ETH buyers: non-exact `msg.value` triggers a second WETH charge and excess ETH is never refunded

Description

  • Snow::buySnow lets a user mint amount Snow tokens by paying the price s_buyFee * amount, in either native ETH (via msg.value) or WETH. The intended behavior is that a buyer pays the price once, in exactly one of the two assets.

  • The ETH path is only taken when msg.value is exactly equal to s_buyFee * amount. For any other msg.value (including sending slightly too much for safety, or an approximate value), execution falls into the else branch, which pulls the full price again in WETH via safeTransferFrom while the ETH sent in msg.value stays in the contract. The excess/sent ETH is never refunded, and is later swept to the collector by collectFee. A buyer who sends non-exact ETH is therefore charged twice (ETH kept + WETH pulled), or — if they have no WETH allowance — the transaction reverts and their intended ETH purchase silently fails.

// src/Snow.sol
function buySnow(uint256 amount) external payable canFarmSnow {
if (msg.value == (s_buyFee * amount)) { // @> requires EXACT wei match to use ETH
_mint(msg.sender, amount);
} else {
i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount)); // @> charges WETH again
_mint(msg.sender, amount); // @> sent ETH (msg.value) is kept, never refunded
}
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}

Risk

Likelihood: High

  • Hitting the exact-wei ETH price is impractical; users routinely overpay slightly or round, so msg.value != s_buyFee * amount is the common case for ETH buyers.

  • Any buyer who holds a WETH allowance (the standard setup, since the contract advertises WETH payment) and sends non-exact ETH is double-charged on every such purchase.

Impact: High

  • Buyers lose funds: they pay the price in WETH and forfeit the ETH they sent, receiving only a single amount of Snow for a double payment.

  • Even without a WETH allowance, any surplus ETH over the exact price is permanently retained by the contract with no refund, so ETH buyers cannot pay correctly without losing the overage.

Proof of Concept

A buyer intends to pay in ETH but sends 1 wei more than the exact price. They also hold a WETH allowance (as expected for a protocol that accepts WETH). They are charged in WETH and their ETH is kept:

function test_BuySnowDoubleChargesOnNonExactEth() public {
uint256 amount = 1;
uint256 price = snow.s_buyFee() * amount;
// buyer is set up to pay with WETH (normal setup) and also sends ETH
deal(address(weth), buyer, price);
vm.prank(buyer);
weth.approve(address(snow), price);
vm.deal(buyer, price + 1);
uint256 wethBefore = weth.balanceOf(buyer);
// buyer sends 1 wei more than the exact price -> falls into the WETH branch
vm.prank(buyer);
snow.buySnow{value: price + 1}(amount);
// WETH was pulled (full price) AND the ETH stayed in the contract
assertEq(weth.balanceOf(buyer), wethBefore - price); // charged in WETH
assertEq(address(snow).balance, price + 1); // ETH kept, not refunded
assertEq(snow.balanceOf(buyer), amount); // only 1 unit of Snow for a double payment
}

Recommended Mitigation

Make the payment asset explicit and refund/repay correctly. Only pull WETH when no ETH was sent, require sufficient ETH (not exact) and refund any surplus:

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 price = s_buyFee * amount;
+ if (msg.value > 0) {
+ require(msg.value >= price, "insufficient ETH");
+ if (msg.value > price) {
+ (bool ok, ) = payable(msg.sender).call{value: msg.value - price}(""); // refund surplus
+ require(ok, "refund failed");
+ }
+ } else {
+ i_weth.safeTransferFrom(msg.sender, address(this), price);
+ }
+ _mint(msg.sender, amount);
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}
Updates

Lead Judging Commences

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