Snowman Merkle Airdrop

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

s_earnTimer is a global variable, allowing one user to block all others from earning Snow tokens

Root + Impact

Description

  • The Snow token contract allows users to earn 1 Snow token per week by calling earnSnow(). The function uses s_earnTimer to enforce the 1-week cooldown.

  • The issue is that s_earnTimer is a single global variable shared across all users. When any user calls earnSnow(), the timer resets for everyone. This means one user can permanently block all other users from earning Snow by calling earnSnow() every week, resetting the global timer right before others can claim.

function earnSnow() external canFarmSnow {
if (s_earnTimer != 0 && block.timestamp < (s_earnTimer + 1 weeks)) {
revert S__Timer();
}
_mint(msg.sender, 1);
@> s_earnTimer = block.timestamp; // @> global timer — resets for ALL users
}

Risk

Likelihood:

  • This occurs every time any user calls earnSnow(), which is the intended normal usage.

  • No malicious intent is required — even honest usage blocks other users.

Impact:

  • Users cannot earn Snow tokens independently of each other.

  • A single user or bot can grief the entire protocol by calling earnSnow() every week, preventing all others from ever claiming.

  • Users who depend on Snow tokens to participate in the airdrop are permanently blocked.

Proof of Concept

  1. Alice calls earnSnow() at timestamp T → mints 1 Snow, s_earnTimer = T\

  2. Bob tries to call earnSnow() at T+1 → reverts with S__Timer (must wait until T + 1 weeks)\

  3. At T + 1 weeks - 1 second, Alice calls earnSnow() again → resets s_earnTimer
    4. Bob must now wait another full week
    5. Alice repeats every week → Bob can never earn Snow tokens

Proof of Concept
Add this test and run forge test --match-test testGlobalTimerBlocksUsers:
function testGlobalTimerBlocksUsers() public {
address alice = makeAddr("alice");
address bob = makeAddr("bob");
// Alice earns Snow at time T - sets global timer
vm.prank(alice);
snow.earnSnow();
assertEq(snow.balanceOf(alice), 1);
// Bob tries to earn 1 second later - reverts due to global timer
vm.warp(block.timestamp + 1);
vm.prank(bob);
vm.expectRevert(); // S__Timer
snow.earnSnow();
assertEq(snow.balanceOf(bob), 0); // Bob blocked
// Just before 1 week ends, Alice earns again and resets timer for everyone
vm.warp(block.timestamp + 1 weeks - 2);
vm.prank(alice);
snow.earnSnow();
// Bob still cannot earn - timer reset again
vm.prank(bob);
vm.expectRevert(); // S__Timer
snow.earnSnow();
assertEq(snow.balanceOf(bob), 0); // Bob permanently blocked
}

Recommended Mitigation

Replace the global timer with a per-address mapping:

- uint256 private s_earnTimer;
+ mapping(address => uint256) private s_earnTimer;
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;
}
Updates

Lead Judging Commences

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