Snow can be earned for free once a week per user, as stated in the README, and earnSnow() enforces that cooldown through s_earnTimer.
However, s_earnTimer is a single contract-wide slot rather than a mapping(address => uint256), so one user's mint sets the cooldown for everybody. buySnow() resets the same slot, and buySnow(0) with msg.value == 0 satisfies msg.value == s_buyFee * amount, refreshing the timer for the cost of gas alone while minting nothing.
Likelihood:
Every week of the 12-week window, an attacker sends one buySnow{value: 0}(0) transaction — roughly 30k gas, no capital, no approval — and the free mint is unavailable to everyone until the next reset. There is no owner function to reset the timer or pause, so the team cannot respond.
The protocol also misbehaves with no attacker: the first address to call earnSnow() in a given week consumes the single global free mint. The project's own Helper script has to vm.warp(1 weeks) between each of its five users, which demonstrates the bug.
Impact:
The free-farming mechanism, the intended on-ramp to the airdrop, is denied to all users; everyone who does not buy Snow is permanently excluded from ever becoming eligible for a Snowman NFT.
At most one address in the world earns free Snow per week instead of one mint per address per week, so nearly every honest user is denied for the whole farming window.
The attacker holds no Snow, sends no value, and gains nothing — the call exists purely to touch the shared timer. bob is an ordinary user whose weekly mint is due and who is unrelated to the attacker.
A second test shows the same lockout when alice simply earns honestly, confirming the bug needs no attacker at all.
Make the cooldown per address so one user's activity cannot affect another, stop buySnow from touching it at all, and reject a zero amount to close the free-call path.
With the mapping in place each user has an independent weekly schedule, and removing the write from buySnow means a purchase can no longer delay anyone's free mint. earnSnow also never emits the declared SnowEarned event, fixed in the same change.
## 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.