Snowman Merkle Airdrop

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

Strict equality ETH check in `buySnow()` silently traps overpayments with no refund

Root + Impact

Description

  • buySnow() allows users to purchase Snow tokens by paying either exact ETH or by pulling WETH from their balance.

  • The ETH payment logic uses strict equality (==). If a user sends more ETH than the exact required fee, the condition is false, the else branch executes pulling WETH from the user AND keeping the sent ETH — the user is double-charged and their ETH is permanently locked. If the user underpays, the condition is also false — ETH stays in the contract while WETH is pulled instead. s_buyFee is stored as fee * PRECISION (a large number), making exact ETH computation error-prone and triggering this path frequently without tooling.

function buySnow(uint256 amount) external payable canFarmSnow {
if (msg.value == (s_buyFee * amount)) {
_mint(msg.sender, amount);
} else {
// falls through to WETH path — does NOT refund excess ETH
i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount);
}
// ...
}

Risk

Likelihood:

  • Users who slightly overpay when calling buySnow() with ETH — a common occurrence when computing exact amounts manually or with imprecise tooling — permanently lose the difference.

  • Frontend implementations that pad ETH amounts for gas estimation or use slightly stale fee values trigger this path automatically.

Impact:

  • Any user who sends more ETH than the exact required fee permanently loses the excess to the contract — collectFee() sends ETH to the collector, not back to the user, providing no recovery path.

  • Users who accidentally underpay lose their ETH silently while being charged again via WETH.

Proof of Concept

Place this test in test/ and run forge test --match-test testOverpaymentIsLost. The test demonstrates that buySnow() uses a strict equality check for msg.value — overpayment does NOT revert; instead it silently falls through to the WETH else-branch, double-charging the user (ETH is kept by the contract AND WETH is pulled from the user).

contract OverpaymentPoC is Test {
function testOverpaymentIsLost() public {
// Snow.sol has no getBuyFee() getter — s_buyFee is private; read it directly or pass the
// constructor value. Here we use the known constructor value (fee * PRECISION) for 1 token.
uint256 exactFee = snow.s_buyFee() * 1; // fee for 1 Snow (s_buyFee is public)
uint256 overpayment = exactFee + 1 wei;
uint256 balanceBefore = alice.balance;
vm.prank(alice);
snow.buySnow{value: overpayment}(1);
// Overpayment bypasses the ETH branch; else-branch fires, pulling WETH from alice
// AND keeping the sent ETH — alice is double-charged
// ETH was kept by the contract (not refunded)
assertLt(alice.balance, balanceBefore - overpayment + 1 wei);
// WETH was also pulled from alice — proves the double-charge
assertEq(weth.balanceOf(alice), 0);
}
}

Recommended Mitigation

Change the fee check from msg.value == totalFee to msg.value >= totalFee and refund any excess msg.value - totalFee back to the caller to prevent accidental ETH loss.

+ error S__InvalidPayment();
+
function buySnow(uint256 amount) external payable canFarmSnow {
uint256 totalFee = s_buyFee * amount;
- if (msg.value == (s_buyFee * amount)) {
+ if (msg.value >= totalFee) {
+ if (msg.value > totalFee) {
+ (bool refunded,) = payable(msg.sender).call{value: msg.value - totalFee}("");
+ require(refunded, "ETH refund failed");
+ }
_mint(msg.sender, amount);
} else {
+ if (msg.value > 0) revert S__InvalidPayment(); // prevent accidental ETH loss
i_weth.safeTransferFrom(msg.sender, address(this), totalFee);
_mint(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!