buySnow selects its payment rail by strict equality on msg.value and never refunds, so any inexact ETH amount is kept by the contract while the full price is also taken in WETHbuySnow is documented as accepting either native ETH or WETH. A payable function that accepts a native payment normally either refunds any excess or reverts when the amount is wrong, so that a buyer cannot pay more than the quoted price.
buySnow has no parameter expressing which rail the caller intends to use. It infers the intent from a strict equality test on msg.value, and treats any value other than the exact price as a WETH purchase. The else branch pulls the full price in WETH and mints, but nothing ever returns the ETH the caller attached. The contract is payable, so that ETH is simply retained. A buyer who is wrong by a single wei pays the full price twice, once in ETH and once in WETH, and there is no path to recover the ETH leg.
The retained ETH is not stuck in the contract. collectFee forwards address(this).balance to the fee collector, so the overpayment is silently converted into protocol revenue rather than being returned to the buyer.
There is a second, wider path to the same loss that needs no WETH and no approval at all. With amount of zero, the WETH leg becomes a zero-value safeTransferFrom, which succeeds regardless of allowance, so the call does not revert. Any ETH attached is kept and nothing is minted:
Likelihood:
Only one exact value selects the ETH rail. Every other value routes to WETH, so the function punishes the ordinary defensive habit of attaching a small margin to a payable call, which most contracts refund.
The price is s_buyFee * amount where s_buyFee is 5e18 for the deployment in script/DeploySnow.s.sol, so the exact figure a buyer must attach is a large non-obvious number computed from a private-looking scaling factor. Getting it wrong is the expected outcome of a manual interaction rather than an unusual one.
Wallets commonly hold a standing max approval to a contract they have used before, which is the state in which the double charge occurs.
The zero-amount variant requires no WETH balance and no approval whatsoever, so it applies to every caller.
Impact:
Direct loss of user funds. The buyer pays the full quoted price twice for a single purchase, once in ETH and once in WETH, and receives only one purchase worth of Snow.
The loss is unbounded by the protocol: it is whatever the caller attached, and nothing in the contract caps or returns it.
The lost ETH is transferred to the fee collector by collectFee, so it becomes protocol revenue taken from a user who never agreed to pay it.
In the zero-amount case the caller receives nothing at all in exchange.
Snow is deployed here directly with the project's own FEE = 5 from script/DeploySnow.s.sol, because Snow exposes no getter for its WETH address and the deploy script wires an instance the Helper does not return.
Part 1 - off by one wei, charged twice. The test closes with the counterfactual: the same purchase with the exact amount is charged once, so the double charge is caused by the inexact value and not by the fixture.
Part 2 - ETH kept with no WETH and no approval.
Results:
In part 1 the buyer paid 5.000000000000000001 ETH plus 5 WETH, a little over ten ETH of value, for a single base unit of Snow priced at five.
Scope note, stated rather than left for a judge to find: this is not attacker-triggerable. msg.value is chosen by the caller, s_buyFee is fixed at construction and has no setter, so no third party can manoeuvre a victim into the wrong branch. The finding is that the contract converts an ordinary user mistake into an uncapped, unrecoverable loss, on a payable function where refunding is the near-universal convention.
Take the payment rail as an explicit parameter rather than inferring it, and reject any ETH that is not exactly the price. Refunding is also acceptable; rejecting is simpler and leaves no partial-payment state.
If the external signature must be preserved, keep the inference but refund the difference on the WETH branch and reject a nonzero remainder on the ETH branch, so that no path can retain ETH the buyer did not owe.
This is a payment-handling defect in buySnow and is independent of the previously reported issues. It is separate from the report on s_earnTimer, which concerns the same function only insofar as buySnow writes that shared timer: that report's fix removes the timer write and changes nothing about payment, and this report's fix corrects the payment handling and changes nothing about the timer. The two touch the same function for unrelated reasons and require different changes.
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.