Snowman Merkle Airdrop

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

ETH overpayment falls through to WETH payment and charges users twice

Root + Impact

Description

  • Normal behavior: users should buy Snow either with exact ETH or with WETH. ETH sent to the WETH purchase path should be rejected or refunded.

  • The issue is that buySnow() selects the ETH path only when msg.value == s_buyFee * amount. Any other msg.value, including a nonzero underpayment or overpayment, falls through to the WETH path. When the caller has WETH allowance, the contract keeps the ETH and also transfers the full WETH fee.

function buySnow(uint256 amount) external payable canFarmSnow {
// @> Only exact ETH takes the ETH path.
if (msg.value == (s_buyFee * amount)) {
_mint(msg.sender, amount);
} else {
// @> Any non-exact ETH value still enters the WETH path.
// @> The nonzero ETH is not refunded and the user is charged WETH too.
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:

  • Users or integrations can accidentally send slightly too much or too little ETH with a WETH-approved purchase transaction.

  • Wallet and frontend mistakes around exact native value are common when a function supports both native ETH and ERC20 payments in the same entry point.

Impact:

  • Buyers lose the accidental ETH value and still pay the full WETH price.

  • The collector can later withdraw both the WETH fee and the accidental ETH via collectFee().

Proof of Concept

The test gives the buyer both WETH allowance and a small amount of native ETH, then calls buySnow{value: 1}(1). Since the native value is not exactly equal to the required ETH price, the function falls through to the WETH branch while keeping the sent ETH.

function testOverpayingEthStillChargesWeth() public {
DeploySnow deployer = new DeploySnow();
Snow snow = deployer.run();
MockWETH weth = deployer.weth();
uint256 fee = deployer.FEE();
address buyer = makeAddr("buyer");
weth.mint(buyer, fee);
deal(buyer, 1);
vm.startPrank(buyer);
weth.approve(address(snow), fee);
snow.buySnow{value: 1}(1);
vm.stopPrank();
assertEq(weth.balanceOf(address(snow)), fee);
assertEq(address(snow).balance, 1);
assertEq(snow.balanceOf(buyer), 1);
}

Recommended Mitigation

Separate ETH and WETH purchase functions, or explicitly require msg.value == 0 for WETH purchases and exact ETH for native purchases.

function buySnow(uint256 amount) external payable canFarmSnow {
+ if (amount == 0) {
+ revert S__ZeroValue();
+ }
if (msg.value == (s_buyFee * amount)) {
_mint(msg.sender, amount);
- } else {
+ } else if (msg.value == 0) {
i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount);
+ } else {
+ revert S__InvalidPayment();
}
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!