Root + Impact
Description
-
Users buy Snow either by sending exact ETH (msg.value == s_buyFee * amount) or via the WETH branch otherwise; collectFee() lets the collector sweep the contract's full ETH balance.
-
The payment check is exact equality, so over/underpaying by a single wei silently routes the call to the WETH branch: the user is charged the full WETH price, and their entire msg.value stays in the contract with no refund — later swept by the collector. The user pays twice.
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}("");
Risk
Likelihood:
-
s_buyFee is _buyFee * 1e18 (e.g. 5000000000000000000 per token) — any frontend rounding or manual input off by 1 wei hits the WETH branch while still sending ETH.
-
For users who hold both WETH and an approval to Snow (common for DeFi users), the tx succeeds and tokens arrive — the loss is silent.
Impact:
-
Double payment: the user pays the full WETH price and forfeits their entire msg.value. Even buySnow{value: 1 ether}(0) traps 1 ETH while minting nothing.
-
The collector can sweep all trapped ETH at any time; users have no refund path.
Proof of Concept
forge test --mt test_03_ethMispaySweptByCollector -vvv
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Snow} from "../src/Snow.sol";
import {MockWETH} from "../src/mock/MockWETH.sol";
contract AuditPoC_M1 is Test {
Snow snow;
MockWETH weth;
address collector = makeAddr("collector");
uint256 fee;
function setUp() public {
weth = new MockWETH();
snow = new Snow(address(weth), 5, collector);
fee = snow.s_buyFee();
}
function test_03_ethMispaySweptByCollector() public {
address u1 = makeAddr("overpayer");
weth.mint(u1, fee);
deal(u1, fee + 1);
vm.startPrank(u1);
weth.approve(address(snow), fee);
snow.buySnow{value: fee + 1}(1);
vm.stopPrank();
assertEq(weth.balanceOf(address(snow)), fee);
assertEq(address(snow).balance, fee + 1);
assertEq(u1.balance, 0);
vm.prank(collector);
snow.collectFee();
assertEq(collector.balance, fee + 1);
assertEq(weth.balanceOf(collector), fee);
}
}
Recommended Mitigation
function buySnow(uint256 amount) external payable canFarmSnow {
- if (msg.value == (s_buyFee * amount)) {
+ if (msg.value >= (s_buyFee * amount)) {
_mint(msg.sender, amount);
+ uint256 excess = msg.value - (s_buyFee * amount);
+ if (excess > 0) {
+ (bool refunded,) = payable(msg.sender).call{value: excess}("");
+ require(refunded, "Refund failed");
+ }
Also add if (amount == 0) revert S__ZeroValue(); to block no-op value-trapping calls.