Snowman Merkle Airdrop

First Flight #42
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

Overpayment Bug Allows Double Charging of ETH and WETH

[H-01] Overpayment Bug Allows Double Charging of ETH and WETH

Description

When a user overpays using ETH and has already approved WETH, the buySnow() logic enters the else branch and pulls WETH, while still accepting the full ETH payment. The function lacks a refund mechanism for overpayment, leading to unnecessary token loss

function buySnow(uint256 amount) external payable canFarmSnow {
if (msg.value == (s_buyFee * amount)) { // <@ this condition only checks if the user amount is equal to the total amount but does not account for over payments
_mint(msg.sender, amount);
} else {
i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount);
}

Risk

Likelihood:

  • This issue will occur when a user sends more ETH than required, either due to UI miscalculation, gas estimation overbuffering, or manual error — all of which are common real-world behaviors. In these cases, the exact msg.value == totalCost check will fail, and the function will default to the WETH path, triggering an unexpected transferFrom() call.

  • It will also occur any time the user has an active WETH allowance set for the contract, which is typical for protocols accepting ERC-20 payments. The combination of overpayment + allowance is highly probable, especially among power users or those interacting via aggregators and custom scripts.

Impact:

  • It punishes normal user behavior — users often overpay ETH unintentionally (e.g. due to UIs rounding up).

  • It can silently drain user balances without triggering any reverts or warnings.

  • It undermines user trust and protocol reliability.

Proof of Concept

This test demonstrates a realistic scenario where a user mistakenly overpays using ETH (e.g., they miscalculate gas or front-end overestimates the fee), and has also approved WETH. The current buySnow() logic does not stop after receiving ETH. Instead, if the ETH amount doesn't match the exact expected value, it silently falls through to the WETH path — causing both tokens to be pulled, resulting in double payment.

function testOverpayingEthAndApprovingWeth() public {
uint256 required = FEE * 1;
uint256 overpayment = required + 1 ether;
// Fund the user
deal(victory, overpayment);
deal(address(weth), victory, overpayment);
// Approve WETH and send extra ETH
vm.startPrank(victory);
weth.approve(address(snow), required);
snow.buySnow{value: overpayment}(1);
vm.stopPrank();
assertEq(weth.balanceOf(address(snow)), required, "WETH was pulled");
assertEq(address(snow).balance, overpayment, "ETH was accepted");
assertEq(snow.balanceOf(victory), 1, "User received correct Snow tokens");
assertEq(weth.balanceOf(victory), overpayment - required, "WETH reduced");
assertEq(victory.balance, 0, "ETH not refunded");
}

Recommended Mitigation

Upadate the buySnow() function to gracefully handle overpayment, my solution was to include a refund mechanism.

function buySnow(uint256 amount) external payable canFarmSnow {
- if (msg.value == (s_buyFee * amount)) {
-_mint(msg.sender, amount);
-}
+ uint256 totalCost = s_buyFee * amount;
+ if (msg.value > 0) {
+ if (msg.value < totalCost) revert S__InsufficientETH();
+ if (msg.value > totalCost) {
+ // Refund excess
+ (bool success, ) = msg.sender.call{value: msg.value - totalCost}("");
+ require(success, "Refund failed");
}
+ } else {
+ i_weth.safeTransferFrom(msg.sender, address(this), totalCost);
+ }
_mint(msg.sender, amount);
+ s_lastEarned[msg.sender] = block.timestamp; // better per-user cooldown
emit SnowBought(msg.sender, amount);
+}
Updates

Lead Judging Commences

yeahchibyke Lead Judge
3 months ago
yeahchibyke Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Appeal created

0x00t1 Submitter
3 months ago
yeahchibyke Lead Judge
3 months ago
yeahchibyke Lead Judge 2 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.