Snowman Merkle Airdrop

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

Snow::s_earnTimer is a global variable instead of per-user mapping, allowing only one user to earn Snow per week globally

Root + Impact

Description

The Snow contract allows users to earn free Snow tokens once per week via earnSnow(), with a cooldown enforced by s_earnTimer. According to the protocol specification, each user should be able to earn Snow tokens once per week independently.

However, s_earnTimer is declared as a single uint256 state variable (line 30), not a mapping(address => uint256). This means all users share the same timer. When any user calls earnSnow() or buySnow(), the global s_earnTimer is updated to block.timestamp, resetting the cooldown for everyone.

// @> Global variable - shared by ALL users
uint256 private s_earnTimer;
function earnSnow() external canFarmSnow {
// @> Checks global timer - if ANY user earned/bought recently, ALL users are blocked
if (s_earnTimer != 0 && block.timestamp < (s_earnTimer + 1 weeks)) {
revert S__Timer();
}
_mint(msg.sender, 1);
// @> Resets timer for ALL users
s_earnTimer = block.timestamp;
}
function buySnow(uint256 amount) external payable canFarmSnow {
// ... minting logic ...
// @> buySnow ALSO resets the global timer, blocking earnSnow for everyone
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}

This creates two critical problems:

  1. Only one user globally can earn free Snow per week, instead of each user earning independently

  2. Every call to buySnow() also resets the timer, meaning frequent purchases permanently block the free earning mechanism for all users

Risk

Likelihood: High

  • The earnSnow() function is expected to be called by multiple users each week

  • Every buySnow() call also resets the timer, and buying is expected to happen frequently

  • In a protocol with multiple active users, the timer will be reset continuously, making earnSnow() practically unusable

Impact: High

  • Users are denied the core functionality of earning free Snow tokens weekly as advertised

  • A single malicious user can grief all other users by calling buySnow(1) with minimal cost every few days, permanently blocking free earning

  • This forces all users to purchase Snow tokens instead of earning them, fundamentally changing the protocol's economic model

  • Users who cannot afford to buy Snow tokens are completely excluded from the airdrop ecosystem

Proof of Concept

function testGlobalTimerBlocksAllUsers() public {
address alice = makeAddr("alice");
address bob = makeAddr("bob");
// Alice earns Snow successfully
vm.prank(alice);
snow.earnSnow();
assertEq(snow.balanceOf(alice), 1);
// Bob tries to earn Snow immediately after - BLOCKED by Alice's timer
vm.prank(bob);
vm.expectRevert(Snow.S__Timer.selector);
snow.earnSnow(); // Reverts! Bob is blocked because Alice just earned
// Even after 3 days, Bob is still blocked
vm.warp(block.timestamp + 3 days);
vm.prank(bob);
vm.expectRevert(Snow.S__Timer.selector);
snow.earnSnow(); // Still reverts!
// Bob must wait the full week from Alice's earn to use earnSnow
vm.warp(block.timestamp + 4 days + 1); // total 7 days + 1 second
vm.prank(bob);
snow.earnSnow(); // Finally works, but now Alice is blocked again
}
function testBuySnowResetsEarnTimer() public {
address alice = makeAddr("alice");
address buyer = makeAddr("buyer");
// Alice earns Snow
vm.prank(alice);
snow.earnSnow();
// Wait a full week
vm.warp(block.timestamp + 1 weeks + 1);
// A buyer purchases Snow just before Alice tries to earn again
vm.prank(buyer);
snow.buySnow{value: snow.s_buyFee()}(1);
// Alice is now blocked again because buySnow reset the timer
vm.prank(alice);
vm.expectRevert(Snow.S__Timer.selector);
snow.earnSnow(); // Reverts! Timer was reset by the buyer
}

Recommended Mitigation

Change s_earnTimer from a global variable to a per-user mapping:

// Before:
uint256 private s_earnTimer;
// After:
mapping(address => uint256) private s_earnTimers;
function earnSnow() external canFarmSnow {
if (s_earnTimers[msg.sender] != 0 && block.timestamp < (s_earnTimers[msg.sender] + 1 weeks)) {
revert S__Timer();
}
_mint(msg.sender, 1);
s_earnTimers[msg.sender] = block.timestamp;
}

Also remove the s_earnTimer = block.timestamp line from buySnow() (line 87), as purchasing Snow should not affect the free earning cooldown for any user.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 5 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!