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).
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);
}
...
}
...
function collectFee() external onlyCollector {
...
@> (bool collected,) = payable(s_collector).call{value: address(this).balance}("");
...
}
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.
pragma solidity ^0.8.24;
import {Test, console2} from "forge-std/Test.sol";
import {Snow} from "../src/Snow.sol";
import {MockWETH} from "../src/mock/MockWETH.sol";
contract F004DoubleChargeTest is Test {
uint256 internal constant START = 1_700_000_000;
uint256 internal constant BUY_FEE = 5;
MockWETH internal weth;
Snow internal snow;
address internal collector;
address private bob;
uint256 private fee;
function setUp() public {
vm.warp(START);
collector = makeAddr("collector");
weth = new MockWETH();
snow = new Snow(address(weth), BUY_FEE, collector);
bob = makeAddr("bob");
fee = snow.s_buyFee();
}
function _paid(uint256 ethBefore, uint256 wethBefore) private view returns (uint256) {
return (ethBefore - bob.balance) + (wethBefore - weth.balanceOf(bob));
}
function test_F004_oracle_offByOneAmountPaysInBothAssets() public {
vm.deal(bob, fee);
weth.mint(bob, 3 * fee);
vm.prank(bob);
weth.approve(address(snow), 3 * fee);
vm.prank(bob);
snow.buySnow(1);
assertEq(weth.allowance(bob, address(snow)), 2 * fee, "standing allowance left by the earlier purchase");
uint256 e0 = bob.balance;
uint256 w0 = weth.balanceOf(bob);
uint256 snowEth0 = address(snow).balance;
uint256 snowWeth0 = weth.balanceOf(address(snow));
uint256 snow0 = snow.balanceOf(bob);
vm.prank(bob);
snow.buySnow{value: fee}(2);
uint256 paid = _paid(e0, w0);
console2.log("bob ETH before / after:", e0, bob.balance);
console2.log("bob WETH before / after:", w0, weth.balanceOf(bob));
console2.log("price of 2 units:", 2 * fee);
console2.log("total paid:", paid);
assertEq(snow.balanceOf(bob) - snow0, 2, "2 units minted");
assertEq(bob.balance, 0, "bob's ETH reached zero");
assertEq(weth.balanceOf(bob), 0, "bob's WETH reached zero");
assertEq(paid, 3 * fee, "15 paid");
assertGt(paid, 2 * fee, "S1 broken: paid more than s_buyFee * amount");
assertEq(address(snow).balance - snowEth0, fee, "the 5 ETH is kept by Snow");
assertEq(weth.balanceOf(address(snow)) - snowWeth0, 2 * fee, "and the full price taken in WETH");
}
function test_F004_oracle_oneWeiOverDoublesTheCost() public {
vm.deal(bob, fee + 1);
weth.mint(bob, fee);
vm.prank(bob);
weth.approve(address(snow), type(uint256).max);
uint256 e0 = bob.balance;
uint256 w0 = weth.balanceOf(bob);
vm.prank(bob);
snow.buySnow{value: fee + 1}(1);
uint256 paid = _paid(e0, w0);
console2.log("price of 1 unit:", fee);
console2.log("total paid:", paid);
assertEq(snow.balanceOf(bob), 1);
assertEq(paid, 2 * fee + 1, "double the price plus one wei");
assertGt(paid, fee, "S1 broken: paid more than s_buyFee * amount");
assertEq(address(snow).balance, fee + 1, "all the ETH kept");
}
function test_F004_oracle_zeroAmountKeepsEthWithNoAllowance() public {
vm.deal(bob, 3 ether);
assertEq(weth.balanceOf(bob), 0, "bob holds no WETH");
assertEq(weth.allowance(bob, address(snow)), 0, "and has approved nothing");
uint256 supply0 = snow.totalSupply();
vm.prank(bob);
snow.buySnow{value: 3 ether}(0);
console2.log("bob ETH after:", bob.balance);
console2.log("snow ETH after:", address(snow).balance);
assertEq(snow.balanceOf(bob), 0, "nothing minted");
assertEq(snow.totalSupply(), supply0, "supply unchanged");
assertEq(address(snow).balance, 3 ether, "3 ETH kept for nothing");
assertEq(bob.balance, 0, "bob lost all 3 ETH");
}
function test_F004_oracle_strayEthEndsWithCollector() public {
vm.deal(bob, 3 ether);
vm.prank(bob);
snow.buySnow{value: 3 ether}(0);
uint256 c0 = collector.balance;
vm.prank(collector);
snow.collectFee();
assertEq(collector.balance - c0, 3 ether, "bob's stray ETH went to the collector");
assertEq(bob.balance, 0, "nothing came back to bob");
}
function test_F004_control_exactEthPaysExactPrice() public {
vm.deal(bob, fee);
weth.mint(bob, 10 * fee);
vm.prank(bob);
weth.approve(address(snow), type(uint256).max);
uint256 e0 = bob.balance;
uint256 w0 = weth.balanceOf(bob);
vm.prank(bob);
snow.buySnow{value: fee}(1);
assertEq(snow.balanceOf(bob), 1);
assertEq(_paid(e0, w0), fee, "exact ETH pays exactly the price");
assertEq(weth.balanceOf(bob), w0, "WETH untouched");
}
function test_F004_control_inexactEthWithoutAllowanceReverts() public {
vm.deal(bob, fee + 1);
weth.mint(bob, fee);
vm.prank(bob);
vm.expectRevert();
snow.buySnow{value: fee + 1}(1);
assertEq(bob.balance, fee + 1, "ETH returned by the revert");
assertEq(address(snow).balance, 0);
}
}
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: