Snowman Merkle Airdrop

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

Snow.buySnow treats any msg.value other than the exact price as a WETH purchase and keeps the ETH, so a buyer with a standing WETH allowance is charged the full price in WETH on top of the ETH they sent

buySnow falls through to pulling WETH whenever msg.value isn't the exact price, and never refunds the ETH, so a buyer pays in both assets or pays for nothing

Description

buySnow accepts payment in ETH or WETH, but it decides which by comparing msg.value with the exact price. Any other value, too little or too much, takes the WETH branch, which pulls the full price in WETH from the buyer. The ETH that came with the call is neither used nor returned. Snow has no receive, no refund and no withdrawal other than collectFee, which sends everything to the collector (src/Snow.sol:101-107).

// src/Snow.sol
function buySnow(uint256 amount) external payable canFarmSnow {
@> if (msg.value == (s_buyFee * amount)) { // :80, ETH path only on an exact match
_mint(msg.sender, amount);
} else {
@> i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount)); // :83, full price in WETH, whatever ETH was sent
_mint(msg.sender, amount); // the ETH sent stays in Snow
}
...
}
...
function collectFee() external onlyCollector {
...
@> (bool collected,) = payable(s_collector).call{value: address(this).balance}(""); // :105, the ETH's only way out
...
}

So a buyer who has a standing WETH allowance to Snow, and sends a msg.value that doesn't match the price exactly, pays the full price in WETH and loses all the ETH they sent:

A standing allowance is ordinary: any earlier WETH buyer who approved more than one purchase's price, or approved the maximum, has one. With no allowance, a non-zero purchase with the wrong value reverts and the buyer keeps the ETH.

A second case needs no allowance at all. buySnow(0) with ETH attached takes the WETH branch, pulls 0 WETH, mints nothing and keeps the ETH.

Risk

Likelihood: Low. It needs the buyer's own mistake, or a frontend's: a wrong amount, a buffer added to the value, or a stale price. For a non-zero amount, it also needs a standing WETH allowance. No third party can cause it or profit from it, and the fee is fixed, so nobody can move the price under a buyer.

Impact: High. Funds are directly lost. The buyer's ETH and WETH are both taken for one purchase, and the ETH can never be returned to them. In the PoC, at the deploy script's 5 ETH fee:

The contract keeps money it has no use for, when one equality check would prevent it.

Not an accidental send. CodeHawks' validity guide lists users sending ETH to a contract by accident among findings that may be invalid. The main loss here isn't that. In the off-by-one and one-wei cases, the buyer calls the contract's own purchase function, and the contract's payment logic then pulls the full price in WETH, which the buyer never sent for this purchase, on top of the ETH that came with the call. The double charge comes from buySnow itself. The zero-amount case, where ETH goes in and nothing comes out, is the one that resembles an accidental send, and it's listed here second for that reason.

Proof of Concept

Save the file below as test/F004DoubleCharge.t.sol in the contest repository and run, from the repository root (no fork needed):

forge test --match-path test/F004DoubleCharge.t.sol -vvv

It imports only the repository's own src/ files and forge-std. Its setUp deploys the real Snow the way script/DeploySnow.s.sol does: with MockWETH, which the repo's deploy script uses as WETH, a fee of 5, and a collector. Nothing is stubbed. Actors:

In the off-by-one test, Bob's standing allowance comes from a real earlier WETH purchase with a three-unit approval.

MockWETH and real WETH9. The PoC uses MockWETH, an OpenZeppelin ERC20, because that's what the repo's deploy script gives Snow. Real WETH9 behaves the same on these paths, by reading its source (WETH9.sol); this wasn't run. Its transferFrom requires balanceOf[src] >= wad and, for a spender without an unlimited allowance, allowance[src][msg.sender] >= wad, then returns true. So a zero-amount transferFrom passes without any allowance, as in the zero-amount case, and the non-zero cases use a real allowance and pull the full price the same way. safeTransferFrom accepts the true return.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
​
// Save as test/F004DoubleCharge.t.sol in the contest repository, then run:
// forge test --match-path test/F004DoubleCharge.t.sol -vvv
// It imports only the repository's own src/ files and forge-std.
​
import {Test, console2} from "forge-std/Test.sol";
import {Snow} from "../src/Snow.sol";
import {MockWETH} from "../src/mock/MockWETH.sol";
​
/// @title F-004 proof: Snow.buySnow (Snow.sol:79-90) takes the ETH path only when msg.value == s_buyFee * amount.
/// Any other msg.value falls through to pulling the full price in WETH, and the ETH sent is kept, never refunded.
/// @notice Snow is the real contract from src/, deployed in setUp the way script/DeploySnow.s.sol does (MockWETH,
/// a fee of 5, a collector). Nothing is stubbed.
/// Actors:
/// - Attacker: none. Every loss here comes from the buyer's own call.
/// - Victim: bob, an ordinary buyer holding ETH and WETH.
/// - Protocol: Snow, and the collector, an honest trusted role who receives swept fees.
/// Reachability:
/// - vm.warp in setUp sets the deployment time; the 12-week farming window is open throughout.
/// - vm.deal gives bob ETH, and weth.mint gives him WETH (MockWETH, which the repo's own deploy script uses as WETH;
/// on mainnet bob would get WETH by WETH9.deposit). A buyer holding both is ordinary.
/// - vm.prank(bob) is bob's own call to approve / buySnow, both open to anyone.
/// - The standing allowance: bob approves more than one purchase's price, as an earlier WETH buyer with an
/// "approve max" or multi-purchase approval would. Scenario 1 builds it through a real earlier WETH purchase.
contract F004DoubleChargeTest is Test {
uint256 internal constant START = 1_700_000_000; // the deployment time this test uses
uint256 internal constant BUY_FEE = 5; // the fee DeploySnow.s.sol uses
​
MockWETH internal weth; // Protocol: the WETH Snow is deployed with
Snow internal snow; // Protocol
address internal collector; // Protocol: the fee collector
​
address private bob; // Victim: the buyer
uint256 private fee; // Protocol: Snow's price per unit
​
function setUp() public {
vm.warp(START); // the deployment time
collector = makeAddr("collector"); // Protocol: the collector
weth = new MockWETH(); // Protocol: the WETH, as DeploySnow.s.sol deploys it
snow = new Snow(address(weth), BUY_FEE, collector); // Protocol: Snow, with a fee of 5
bob = makeAddr("bob"); // Victim: a fresh address
fee = snow.s_buyFee(); // 5e18, the scripts' BUY_FEE of 5 times PRECISION
}
​
/// @dev What bob spent in total, across both assets, since the two snapshots.
function _paid(uint256 ethBefore, uint256 wethBefore) private view returns (uint256) {
return (ethBefore - bob.balance) + (wethBefore - weth.balanceOf(bob)); // ETH spent plus WETH spent
}
​
/// @dev Bob earlier bought 1 Snow in WETH and approved enough for three. Later he sends 5 ETH (the price of one)
/// but passes amount = 2. He pays the full 10 WETH AND the 5 ETH: 15 paid for 2 units priced at 10.
function test_F004_oracle_offByOneAmountPaysInBothAssets() public {
vm.deal(bob, fee); // Victim: 5 ETH, the price of one unit
weth.mint(bob, 3 * fee); // Victim: 15 WETH
vm.prank(bob); // Victim calls
weth.approve(address(snow), 3 * fee); // approved for three units up front
vm.prank(bob); // Victim calls
snow.buySnow(1); // msg.value 0 != fee, so the WETH path: a legitimate WETH purchase
assertEq(weth.allowance(bob, address(snow)), 2 * fee, "standing allowance left by the earlier purchase"); // 10 WETH left
​
uint256 e0 = bob.balance; // snapshot: bob's ETH (5)
uint256 w0 = weth.balanceOf(bob); // snapshot: bob's WETH (10)
uint256 snowEth0 = address(snow).balance; // snapshot: Protocol's ETH
uint256 snowWeth0 = weth.balanceOf(address(snow)); // snapshot: Protocol's WETH
uint256 snow0 = snow.balanceOf(bob); // snapshot: bob's Snow
​
vm.prank(bob); // Victim calls
snow.buySnow{value: fee}(2); // the mistake: ETH for one unit, amount of two
​
uint256 paid = _paid(e0, w0); // what this one call cost bob, in both assets
console2.log("bob ETH before / after:", e0, bob.balance); // log: ETH
console2.log("bob WETH before / after:", w0, weth.balanceOf(bob)); // log: WETH
console2.log("price of 2 units:", 2 * fee); // log: the price
console2.log("total paid:", paid); // log: what was paid
assertEq(snow.balanceOf(bob) - snow0, 2, "2 units minted"); // bob got two units
assertEq(bob.balance, 0, "bob's ETH reached zero"); // all the ETH sent is gone
assertEq(weth.balanceOf(bob), 0, "bob's WETH reached zero"); // and the full price was pulled in WETH
assertEq(paid, 3 * fee, "15 paid"); // 15 for a price of 10
assertGt(paid, 2 * fee, "S1 broken: paid more than s_buyFee * amount"); // the invariant the finding breaks
assertEq(address(snow).balance - snowEth0, fee, "the 5 ETH is kept by Snow"); // Protocol kept the ETH
assertEq(weth.balanceOf(address(snow)) - snowWeth0, 2 * fee, "and the full price taken in WETH"); // and took the WETH
}
​
/// @dev Bob sends fee + 1 wei for 1 unit with a standing allowance: pays fee in WETH plus fee + 1 in ETH.
function test_F004_oracle_oneWeiOverDoublesTheCost() public {
vm.deal(bob, fee + 1); // Victim: one wei more than the price
weth.mint(bob, fee); // Victim: one unit's price in WETH
vm.prank(bob); // Victim calls
weth.approve(address(snow), type(uint256).max); // standing "approve max" allowance
uint256 e0 = bob.balance; // snapshot: bob's ETH
uint256 w0 = weth.balanceOf(bob); // snapshot: bob's WETH
​
vm.prank(bob); // Victim calls
snow.buySnow{value: fee + 1}(1); // the mistake: one wei over
​
uint256 paid = _paid(e0, w0); // what this call cost bob
console2.log("price of 1 unit:", fee); // log: the price
console2.log("total paid:", paid); // log: what was paid
assertEq(snow.balanceOf(bob), 1); // one unit minted
assertEq(paid, 2 * fee + 1, "double the price plus one wei"); // price in WETH plus all the ETH
assertGt(paid, fee, "S1 broken: paid more than s_buyFee * amount"); // the invariant the finding breaks
assertEq(address(snow).balance, fee + 1, "all the ETH kept"); // Protocol kept every wei of ETH
}
​
/// @dev buySnow{value: 3 ether}(0): no WETH and no allowance needed, nothing minted, 3 ETH kept.
function test_F004_oracle_zeroAmountKeepsEthWithNoAllowance() public {
vm.deal(bob, 3 ether); // Victim: 3 ETH
assertEq(weth.balanceOf(bob), 0, "bob holds no WETH"); // no WETH at all
assertEq(weth.allowance(bob, address(snow)), 0, "and has approved nothing"); // and no allowance
uint256 supply0 = snow.totalSupply(); // snapshot: Snow supply
​
vm.prank(bob); // Victim calls
snow.buySnow{value: 3 ether}(0); // 3 ether != 0, WETH path transfers 0, succeeds
​
console2.log("bob ETH after:", bob.balance); // log: bob's ETH
console2.log("snow ETH after:", address(snow).balance); // log: Protocol's ETH
assertEq(snow.balanceOf(bob), 0, "nothing minted"); // bob got nothing
assertEq(snow.totalSupply(), supply0, "supply unchanged"); // no unit exists for it
assertEq(address(snow).balance, 3 ether, "3 ETH kept for nothing"); // Protocol kept the ETH
assertEq(bob.balance, 0, "bob lost all 3 ETH"); // and bob has none left
}
​
/// @dev Snow has no refund path; the only way ETH leaves is collectFee, to the collector.
function test_F004_oracle_strayEthEndsWithCollector() public {
vm.deal(bob, 3 ether); // Victim: 3 ETH
vm.prank(bob); // Victim calls
snow.buySnow{value: 3 ether}(0); // the zero-unit purchase from oracle 3
​
uint256 c0 = collector.balance; // snapshot: the collector's ETH
vm.prank(collector); // collectFee is onlyCollector; the honest collector collecting fees
snow.collectFee(); // Protocol: sweep all ETH and WETH to the collector
assertEq(collector.balance - c0, 3 ether, "bob's stray ETH went to the collector"); // the ETH left Snow
assertEq(bob.balance, 0, "nothing came back to bob"); // and none of it reached bob
}
​
/// @dev Exact ETH: bob pays exactly fee * amount in ETH, WETH untouched.
function test_F004_control_exactEthPaysExactPrice() public {
vm.deal(bob, fee); // Victim: holds exactly one unit's price in ETH
weth.mint(bob, 10 * fee); // Victim: also holds WETH
vm.prank(bob); // Victim calls
weth.approve(address(snow), type(uint256).max); // standing allowance, as in the oracle tests
uint256 e0 = bob.balance; // snapshot: bob's ETH
uint256 w0 = weth.balanceOf(bob); // snapshot: bob's WETH
vm.prank(bob); // Victim calls
snow.buySnow{value: fee}(1); // exact ETH for one unit: the ETH path
assertEq(snow.balanceOf(bob), 1); // one unit minted
assertEq(_paid(e0, w0), fee, "exact ETH pays exactly the price"); // total paid equals the price
assertEq(weth.balanceOf(bob), w0, "WETH untouched"); // no WETH pulled
}
​
/// @dev No allowance: an inexact value reverts and bob keeps his ETH.
function test_F004_control_inexactEthWithoutAllowanceReverts() public {
vm.deal(bob, fee + 1); // Victim: one wei more than the price
weth.mint(bob, fee); // Victim: holds WETH but has approved none
vm.prank(bob); // Victim calls
vm.expectRevert(); // ERC20InsufficientAllowance from MockWETH via SafeERC20
snow.buySnow{value: fee + 1}(1); // inexact value: the WETH path, which fails without an allowance
assertEq(bob.balance, fee + 1, "ETH returned by the revert"); // the revert gives the ETH back
assertEq(address(snow).balance, 0); // Protocol kept nothing
}
}

Output, from a clean copy of the repository at commit 6efcb0f with its own foundry.toml:

[PASS] test_F004_oracle_offByOneAmountPaysInBothAssets() (gas: 520874)
Logs:
bob ETH before / after: 5000000000000000000 0
bob WETH before / after: 10000000000000000000 0
price of 2 units: 10000000000000000000
total paid: 15000000000000000000
[PASS] test_F004_oracle_oneWeiOverDoublesTheCost() (gas: 344573)
Logs:
price of 1 unit: 5000000000000000000
total paid: 10000000000000000001
[PASS] test_F004_oracle_zeroAmountKeepsEthWithNoAllowance() (gas: 183840)
Logs:
bob ETH after: 0
snow ETH after: 3000000000000000000
[PASS] test_F004_oracle_strayEthEndsWithCollector() (gas: 162942)
[PASS] test_F004_oracle_zeroAmountKeepsEthWithNoAllowance() (gas: 183840)
Logs:
bob ETH after: 0
snow ETH after: 3000000000000000000
[PASS] test_F004_control_exactEthPaysExactPrice() (gas: 298699)
[PASS] test_F004_control_inexactEthWithoutAllowanceReverts() (gas: 138911)
[PASS] test_F004_oracle_offByOneAmountPaysInBothAssets() (gas: 520874)
Logs:
bob ETH before / after: 5000000000000000000 0
bob WETH before / after: 10000000000000000000 0
price of 2 units: 10000000000000000000
total paid: 15000000000000000000
[PASS] test_F004_control_inexactEthWithoutAllowanceReverts() (gas: 138911)
[PASS] test_F004_oracle_offByOneAmountPaysInBothAssets() (gas: 520874)
Logs:
bob ETH before / after: 5000000000000000000 0
bob WETH before / after: 10000000000000000000 0
price of 2 units: 10000000000000000000
total paid: 15000000000000000000

In the off-by-one test, Bob's ETH goes from 5e18 to 0 and his WETH from 10e18 to 0: he paid 15e18 for two units priced at 10e18. In the one-wei test he paid 10000000000000000001 for one unit priced at 5e18. In the zero-amount test Snow keeps 3e18 and Bob gets nothing, and the stray-ETH test shows that ETH can only leave through collectFee, to the collector. The controls show the exact ETH price is charged correctly, and that without an allowance an inexact value reverts and Bob keeps his ETH.

Recommended Mitigation

Choose the asset explicitly and reject anything else: pay in WETH when no ETH is sent, accept ETH only for the exact price, and revert otherwise. Also reject zero-unit purchases:

+ error S__WrongPayment();
+
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);
- }
+ if (amount == 0) {
+ revert S__ZeroValue();
+ }
+ uint256 cost = s_buyFee * amount;
+ if (msg.value == 0) {
+ i_weth.safeTransferFrom(msg.sender, address(this), cost);
+ } else if (msg.value != cost) {
+ revert S__WrongPayment();
+ }
+ _mint(msg.sender, amount);
​
s_earnTimer = block.timestamp;

Notes:

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 18 minutes 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!