Snowman Merkle Airdrop

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

`Snow::earnSnow` uses a single global timer, so only one user per week can earn across the entire protocol

Root + Impact

Description

Per the README, "the Snow token can be earned for free once a week" — a per-user faucet. However, the cooldown is stored in one contract-wide s_earnTimer instead of a per-user mapping, so the moment any single user earns, every other user's earnSnow reverts for a week.

// File: src/Snow.sol — Line 30, Lines 92–99
uint256 private s_earnTimer; // @> global, not mapping(address => uint256)
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; // one user's earn resets everyone's cooldown
}

There is no per-account state anywhere in the earn path: the check and the write both hit the same global slot regardless of msg.sender. The project's own Helper.s.sol demonstrates the constraint — it must vm.warp(block.timestamp + 1 weeks) between each of the five setup users' earns (lines 39–58) or they would revert. System-wide, at most one free earn can execute per week, i.e. roughly 52 earns per year shared by the entire user base, who must race each other in every weekly window. The first transaction to land after each window opens claims the slot; every other user's transaction reverts and must be retried a full week later, with the same race repeating.

Risk

Likelihood:

  • Triggers every time two or more users attempt to earn within the same week — the normal case with more than one active user.

  • The first earner of each weekly window always wins; everyone else reverts, no special conditions needed.

Impact:

  • The free-distribution channel — the protocol's zero-cost onboarding path — is degraded to a single system-wide earn per week.

  • Combined with the purchase path resetting the same timer (separately reported), free distribution can be kept permanently suppressed.

Proof of Concept

function test_PoC_GlobalEarnTimer() public {
vm.warp(block.timestamp + 1 weeks); // timer from the setup earns has elapsed
vm.prank(alice);
snow.earnSnow(); // succeeds — resets the GLOBAL timer
vm.prank(bob);
vm.expectRevert(Snow.S__Timer.selector);
snow.earnSnow(); // bob blocked for a week because ALICE earned
}

Run: add the test to test/TestSnowmanAirdrop.t.sol, then forge test --match-test test_PoC_GlobalEarnTimer -vv

Output (actual run):

[PASS] test_PoC_GlobalEarnTimer() (gas: 46442)
Logs:
FC-0003: bob cannot earn in the same week as alice (global timer)

Recommended Mitigation

Track the cooldown per user.

- 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 19 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!