buySnow writes s_earnTimer, the value earnSnow checks, so any purchase pushes the next earn a week away and the free route to Snow can be kept shutThe README gives two independent ways to get Snow: it "can either be earned for free onece a week, or bought at anytime" during the farming window (README.md:19). earnSnow enforces the weekly limit with s_earnTimer (src/Snow.sol:93-98). But buySnow also writes that timer, on every call (src/Snow.sol:87):
So any purchase pushes the next earn a full week out, whatever its size. That includes buySnow(0) with no ETH: msg.value == s_buyFee * 0 holds, so the call mints nothing, costs nothing but gas, and still resets the timer. No reading of "earned for free once a week" has a purchase delaying an earn.
On the code as written the timer is also contract-wide, which is a separate issue in its own right, so a purchase by anyone delays everyone's next earn. Two consequences only this line causes:
No attacker is needed. Ordinary purchases less than a week apart keep earning shut for everyone.
No race is needed. Anyone can reset the timer before it expires, with buySnow(0) and no ETH, so the free route can be kept shut for the whole farming window at the cost of gas.
With line 87 deleted, only an earn resets the timer, so someone can still earn each week.
Likelihood: High. It happens in normal use, with no attacker: in the PoC one honest buyer purchasing every 6.5 days keeps Alice from ever earning, across 13 purchases in the window (test_faucet_ordinaryPurchasesBlockEarningWithNoAttacker). With an attacker, it costs only gas: an address holding no ETH does it with buySnow(0) (test_F005_oracle_thirdPartyZeroPurchaseDelaysEarn).
Impact: Medium. One of the README's two routes to Snow is closed, so free earning, the protocol's free path to the airdrop, stops working. Buying still works, and no funds are taken. A buyer's own purchase delays their own earn too, on any reading of "once a week" (test_F005_oracle_ownZeroPurchaseDelaysOwnEarn).
CodeHawks' validity guide lists "minor inconveniences or design opinions with no clear loss of funds indication" among findings that may be invalid. This isn't a minor inconvenience: across the whole window nothing is earned at all, so a listed holder who meant to earn their allocation has to buy it.
Context, about the timer's contract-wide scope as well as this line: in the PoC's whole-window test, 13 buySnow(0) calls block all 60 earn attempts by the five listed accounts, which then pay 25 ETH between them to claim, at the deploy script's 5 ETH fee, against 0 in the control (test_F005_oracle_wholeWindowFaucetClosedListPays, test_F005_control_wholeWindowNoPurchasesListPaysNothing). A griefer calling earnSnow at each expiry could take every free unit even without line 87, so that 25 ETH isn't this line's own cost.
Save the file below as test/F005PurchaseResetsEarnTimer.t.sol in the contest repository and run, from the repository root (no fork needed):
It imports only the repository's own src/ files and forge-std. Its setUp deploys the real Snow, Snowman and SnowmanAirdrop the way the repo's deploy scripts do: MockWETH, a fee of 5, and the committed Merkle root. The committed allowlist's names and proofs are copied into the file from script/flakes/output.json, for the whole-window claims. Nothing is mocked or stubbed. Actors:
Attacker: mallory, holding no ETH, calling buySnow(0). The self-delay and honest-purchase tests have no attacker.
Victim: alice, and in the whole-window test the five listed accounts, who want to earn.
Protocol: Snow, with SnowmanAirdrop for the end-of-window claims. bob is an honest buyer.
Output, from a clean copy of the repository at commit 6efcb0f with its own foundry.toml:
One purchase, one delay: a week after Alice earns, her own buySnow(0), a third party's buySnow(0) with no ETH, or an honest paid purchase each makes her due earn revert with S__Timer. After her own buySnow(0) it stays blocked until a full week after the purchase.
No attacker, whole window: one honest buyer purchasing every 6.5 days makes 13 purchases, Alice never earns, and all Snow in existence was bought. The test has Alice try six days after each purchase. Because the purchases are less than a week apart, the timer never expires in between, so no earn could succeed at any other moment either.
Control: with no purchase in between, Alice earns again a week later.
Context, the whole window with resets: 13 resets block 60 earns, and the five listed accounts pay 25e18 wei to claim, against 0 in the no-purchase control.
Delete the write in buySnow, so a purchase doesn't touch the earn timer:
Notes:
Tested: this change was tested on a patched copy of Snow. A zero, third-party or honest purchase no longer delays a due earn. Over the whole window with weekly buySnow(0) resets, all five listed accounts earn and claim for nothing. Buying with ETH and WETH, the weekly earn limit and collectFee still work.
Related: the timer is also a single contract-wide value, so one address's earn blocks everyone else for a week. Deleting this line doesn't change that. It needs a per-address timer, checked and updated only in earnSnow, which is a separate issue.
## Description: The `Snow::buySnow` function contains a critical flaw where it resets a global timer `(s_earnTimer)` to the current block timestamp on every invocation. This timer controls eligibility for free token claims via `Snow::earnSnow()`, which requires 1 week to pass since the last timer reset. As a result: Any token purchase `(via buySnow)` blocks all free claims for all users for 7 days Malicious actors can permanently suppress free claims with micro-transactions Contradicts protocol documentation promising **"free weekly claims per user"** ## Impact: * **Complete Denial-of-Service:** Free claim mechanism becomes unusable * **Broken Protocol Incentives:** Undermines core user acquisition strategy * **Economic Damage:** Eliminates promised free distribution channel * **Reputation Harm:** Users perceive protocol as dishonest ```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); } ``` ## Risk **Likelihood**: • Triggered by normal protocol usage (any purchase) • Requires only one transaction every 7 days to maintain blockage • Incentivized attack (low-cost disruption) **Impact**: • Permanent suppression of core protocol feature • Loss of user trust and adoption • Violates documented tokenomics ## Proof of Concept **Attack Scenario:** Permanent Free Claim Suppression * Attacker calls **buySnow(1)** with minimum payment * **s\_earnTimer** sets to current timestamp (T0) * All **earnSnow()** calls revert for **next 7 days** * On day 6, attacker repeats **buySnow(1)** * New timer reset (T1 = T0+6 days) * Free claims blocked until **T1+7 days (total 13 days)** * Repeat step **4 every 6 days → permanent blockage** **Test Case:** ```solidity // Day 0: Deploy contract snow = new Snow(...); // s_earnTimer = 0 // UserA claims successfully snow.earnSnow(); // Success (first claim always allowed) // Day 1: UserB buys 1 token snow.buySnow(1); // Resets global timer to day 1 // Day 2: UserA attempts claim snow.earnSnow(); // Reverts! Requires day 1+7 = day 8 // Day 7: UserC buys 1 token (day 7 < day 1+7) snow.buySnow(1); // Resets timer to day 7 // Day 8: UserA retries snow.earnSnow(); // Still reverts! Now requires day 7+7 = day 14 ``` ## Recommended Mitigation **Step 1:** Remove Global Timer Reset from `buySnow` ```diff function buySnow(uint256 amount) external payable canFarmSnow { // ... existing payment logic ... - s_earnTimer = block.timestamp; emit SnowBought(msg.sender, amount); } ``` **Step 2:** Implement Per-User Timer in `earnSnow` ```solidity // Add new state variable mapping(address => uint256) private s_lastClaimTime; function earnSnow() external canFarmSnow { // Check per-user timer instead of global if (s_lastClaimTime[msg.sender] != 0 && block.timestamp < s_lastClaimTime[msg.sender] + 1 weeks ) { revert S__Timer(); } _mint(msg.sender, 1); s_lastClaimTime[msg.sender] = block.timestamp; // Update user-specific timer emit SnowEarned(msg.sender, 1); // Add missing event } ``` **Step 3:** Initialize First Claim (Constructor) ```solidity constructor(...) { // Initialize with current timestamp to prevent immediate claims s_lastClaimTime[address(0)] = block.timestamp; } ```
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.