Root + Impact
Description
`buySnow` is a `payable` function that accepts ETH. If `msg.value` doesn't exactly equal `s_buyFee * amount`, the function falls through to the `else` branch which charges the user WETH via `safeTransferFrom`. The ETH sent in `msg.value` is not refunded. The user pays both ETH and WETH for a single purchase.
## Vulnerability Details
```solidity
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);
}
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}
Consider a user who wants to buy 5 Snow tokens with a fee of 5e18 (5 WETH/ETH per token):
User sends 24 ETH (msg.value = 24e18) instead of 25 ETH
Check: 24e18 == 5e18 * 5 → 24e18 == 25e18 → false
Else branch: user charged 25e18 WETH via safeTransferFrom
User receives 5 Snow tokens but paid 24 ETH + 25 WETH = 49e18 total
The 24 ETH sits in the contract and is eventually swept by collectFee()
The function should either revert when msg.value > 0 but doesn't match, or explicitly separate the ETH and WETH payment paths.
Risk
Likelihood:
Requires the user to send a non-zero msg.value that doesn't exactly match s_buyFee * amount. This can happen through manual calls, UI bugs, or rounding miscalculations. The function silently accepts and traps the ETH rather than reverting.
Impact:
Proof of Concept
function testExploit_TrappedEth() public {
Snow snow = new Snow(address(weth), 5, collector);
address user = makeAddr("user");
vm.deal(user, 100 ether);
deal(address(weth), user, 100 ether);
vm.prank(user);
weth.approve(address(snow), type(uint256).max);
uint256 ethBefore = user.balance;
uint256 wethBefore = weth.balanceOf(user);
vm.prank(user);
snow.buySnow{value: 24 ether}(5);
assertEq(user.balance, ethBefore - 24 ether, "Lost 24 ETH");
assertEq(weth.balanceOf(user), wethBefore - 25 ether, "Also charged 25 WETH");
assertEq(snow.balanceOf(user), 5, "Got 5 Snow tokens");
}
Output:
ETH lost: 24
WETH charged: 25
Snow received: 5
Total cost: 49 ETH equivalent (should be 25)
Recommendations
Revert if msg.value > 0 but doesn't match, and separate the payment paths clearly:
function buySnow(uint256 amount) external payable canFarmSnow {
+ if (amount == 0) revert S__ZeroValue();
- if (msg.value == (s_buyFee * amount)) {
+ uint256 cost = s_buyFee * amount;
+ if (msg.value > 0) {
+ if (msg.value != cost) revert S__IncorrectPayment();
_mint(msg.sender, amount);
} else {
- i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
+ i_weth.safeTransferFrom(msg.sender, address(this), cost);
_mint(msg.sender, amount);
}
}
// Root cause in the codebase with @> marks to highlight the relevant section
## Risk
**Likelihood**:
* Reason 1
* Reason 2
**Impact**:
* Impact 1
* Impact 2
## Proof of Concept
```solidity
Recommended Mitigation
- remove this code
+ add this code