Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: low
Valid

Global `Snow::s_earnTimer` and a free `buySnow(0)` call let anyone permanently block weekly `Snow` farming for every user

Root + Impact

Description

  • 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.

@> uint256 private s_earnTimer; // one global slot, not per-address
function buySnow(uint256 amount) external payable canFarmSnow {
@> if (msg.value == (s_buyFee * amount)) { // amount = 0 -> 0 == 0 -> free call
_mint(msg.sender, amount); // mints nothing
} else { ... }
@> s_earnTimer = block.timestamp; // resets the cooldown for ALL users
}
function earnSnow() external canFarmSnow {
@> if (s_earnTimer != 0 && block.timestamp < (s_earnTimer + 1 weeks)) revert S__Timer();

Risk

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.

Proof of Concept

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.

vm.warp(block.timestamp + 1 weeks);
vm.prank(attacker);
snow.buySnow{value: 0}(0); // costs nothing, mints nothing
assertEq(snow.balanceOf(attacker), 0);
vm.prank(bob);
vm.expectRevert(Snow.S__Timer.selector); // bob is locked out by a stranger
snow.earnSnow();
for (uint256 i = 0; i < 5; i++) { // repeatable every week, forever
vm.warp(block.timestamp + 1 weeks);
vm.prank(attacker);
snow.buySnow{value: 0}(0);
vm.prank(bob);
vm.expectRevert(Snow.S__Timer.selector);
snow.earnSnow();
}

Recommended Mitigation

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.

- uint256 private s_earnTimer;
+ mapping(address => uint256) private s_earnTimer;
function buySnow(uint256 amount) external payable canFarmSnow {
+ if (amount == 0) revert S__ZeroValue();
...
- s_earnTimer = block.timestamp;
}
function earnSnow() external canFarmSnow {
- if (s_earnTimer != 0 && block.timestamp < (s_earnTimer + 1 weeks)) {
+ if (s_earnTimer[msg.sender] != 0 && block.timestamp < (s_earnTimer[msg.sender] + 1 weeks)) {
revert S__Timer();
}
_mint(msg.sender, 1);
- s_earnTimer = block.timestamp;
+ s_earnTimer[msg.sender] = block.timestamp;
+ emit SnowEarned(msg.sender, 1);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[L-02] Global Timer Reset in Snow::buySnow Denies Free Claims for All Users

## 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; } ```

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!