Snowman Merkle Airdrop

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

ETH Overpayments Are Silently Accepted And Trapped In WETH Purchase Path

Root + Impact

Description

  • The intended behavior is that users can buy Snow either by paying the exact ETH price or by paying with WETH through transferFrom. For ETH purchases, the amount of ETH sent should match the configured buy fee multiplied by the requested Snow amount.

  • The issue is that buySnow only treats the call as an ETH purchase when msg.value exactly equals s_buyFee * amount. Any other msg.value, including accidental overpayment, falls into the WETH branch. In that branch, the contract pulls WETH from the user but also keeps the ETH sent with the transaction.

// Root cause in the codebase with @> marks to highlight the relevant section
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);
}
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}

Risk

Likelihood: Medium

  • Users commonly overpay ETH by mistake when interacting through scripts, custom frontends, or manual transactions.

  • Calls with non-exact msg.value are routed to the WETH branch instead of reverting.

  • Users with existing WETH approval lose WETH and also leave their sent ETH in the contract.

Impact: Medium

  • Users can be charged twice: once in WETH and once through the accidental ETH value.

  • Extra ETH remains in the contract until the collector withdraws it through collectFee.

  • Purchase behavior is surprising and can cause avoidable user fund loss.


Proof of Concept

The following PoC shows a user trying to buy 1 Snow while sending more ETH than required. Since the ETH amount is not exactly equal to FEE, the function enters the WETH branch. Because the user has approved WETH, the contract transfers WETH and also keeps the accidental ETH.

function testOverpaymentChargesWethAndKeepsEth() public {
address buyer = makeAddr("buyer");
weth.mint(buyer, FEE);
deal(buyer, FEE + 1 ether);
vm.startPrank(buyer);
weth.approve(address(snow), FEE);
snow.buySnow{value: FEE + 1 ether}(1);
vm.stopPrank();
assertEq(snow.balanceOf(buyer), 1);
assertEq(weth.balanceOf(buyer), 0);
assertEq(weth.balanceOf(address(snow)), FEE);
assertEq(address(snow).balance, FEE + 1 ether);
}

This demonstrates that an overpaid ETH purchase does not revert or refund. Instead, the buyer pays WETH while the ETH is retained by the contract.


Recommended Mitigation

Separate the ETH and WETH purchase paths explicitly. ETH purchases should require the exact ETH amount. WETH purchases should require msg.value == 0.

function buySnow(uint256 amount) external payable canFarmSnow {
+ if (amount == 0) {
+ revert S__ZeroValue();
+ }
+
+ uint256 cost = s_buyFee * amount;
+
- if (msg.value == (s_buyFee * amount)) {
+ if (msg.value > 0) {
+ if (msg.value != cost) {
+ revert S__ZeroValue();
+ }
_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);
}
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 4 days 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!