Snowman Merkle Airdrop

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

[M-01] `buySnow()` silently traps ETH when `msg.value` doesn't exactly match the fee

Root + Impact

Description

  • Describe the normal behavior in one or more sentences

  • Explain the specific issue or problem in one or more sentences

`buySnow` is a `payable` function that accepts ETH. If `msg.value` doesn't exactly equal `s_buyFee * amount`, the function falls through to the `else` branch which charges the user WETH via `safeTransferFrom`. The ETH sent in `msg.value` is not refunded. The user pays both ETH and WETH for a single purchase.
## Vulnerability Details
```solidity
// src/Snow.sol, lines 79-89
function buySnow(uint256 amount) external payable canFarmSnow {
if (msg.value == (s_buyFee * amount)) { // @> strict equality check
_mint(msg.sender, amount);
} else {
// @> if msg.value > 0 but != fee*amount, ETH is TRAPPED and WETH is also charged
i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount);
}
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}

Consider a user who wants to buy 5 Snow tokens with a fee of 5e18 (5 WETH/ETH per token):

  1. User sends 24 ETH (msg.value = 24e18) instead of 25 ETH

  2. Check: 24e18 == 5e18 * 524e18 == 25e18 → false

  3. Else branch: user charged 25e18 WETH via safeTransferFrom

  4. User receives 5 Snow tokens but paid 24 ETH + 25 WETH = 49e18 total

  5. The 24 ETH sits in the contract and is eventually swept by collectFee()

The function should either revert when msg.value > 0 but doesn't match, or explicitly separate the ETH and WETH payment paths.

Risk

Likelihood:

  • Requires the user to send a non-zero msg.value that doesn't exactly match s_buyFee * amount. This can happen through manual calls, UI bugs, or rounding miscalculations. The function silently accepts and traps the ETH rather than reverting.

Impact:

  • Direct fund loss. The user's ETH is not refunded and the collector receives it via collectFee(). The user effectively pays double for their Snow tokens.

Proof of Concept

function testExploit_TrappedEth() public {
Snow snow = new Snow(address(weth), 5, collector);
address user = makeAddr("user");
vm.deal(user, 100 ether);
// Give user WETH approval
deal(address(weth), user, 100 ether);
vm.prank(user);
weth.approve(address(snow), type(uint256).max);
uint256 ethBefore = user.balance;
uint256 wethBefore = weth.balanceOf(user);
// User sends 24 ETH but fee * amount = 25 ETH
vm.prank(user);
snow.buySnow{value: 24 ether}(5);
// User paid BOTH 24 ETH AND 25 WETH
assertEq(user.balance, ethBefore - 24 ether, "Lost 24 ETH");
assertEq(weth.balanceOf(user), wethBefore - 25 ether, "Also charged 25 WETH");
assertEq(snow.balanceOf(user), 5, "Got 5 Snow tokens");
// Total cost: 49 ETH equivalent instead of 25
}

Output:

ETH lost: 24
WETH charged: 25
Snow received: 5
Total cost: 49 ETH equivalent (should be 25)

Recommendations

Revert if msg.value > 0 but doesn't match, and separate the payment paths clearly:

function buySnow(uint256 amount) external payable canFarmSnow {
+ if (amount == 0) revert S__ZeroValue();
- if (msg.value == (s_buyFee * amount)) {
+ uint256 cost = s_buyFee * amount;
+ if (msg.value > 0) {
+ if (msg.value != cost) revert S__IncorrectPayment();
_mint(msg.sender, amount);
} else {
- i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
+ i_weth.safeTransferFrom(msg.sender, address(this), cost);
_mint(msg.sender, amount);
}
}

// Root cause in the codebase with @> marks to highlight the relevant section

## Risk
**Likelihood**:
* Reason 1 // Describe WHEN this will occur (avoid using "if" statements)
* Reason 2
**Impact**:
* Impact 1
* Impact 2
## Proof of Concept
```solidity

Recommended Mitigation

- remove this code
+ add this code
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!