Snowman Merkle Airdrop

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

Global Earn Timer Prevents More Than One User From Earning Free

Root + Impact

Description

Under normal behavior, each user should be able to earn their own free Snow allocation once per week during the farming period. A weekly cooldown should apply to the individual user who earned Snow, not to every user in the protocol.

The issue is that Snow::earnSnow() uses a single global s_earnTimer for the entire contract. Once any user earns Snow, all other users are blocked from earning free Snow until the global one-week cooldown expires, even when those users have never earned Snow before.

uint256 private s_earnTimer;
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;
}

Risk

Likelihood: High

  • This occurs whenever one user successfully calls earnSnow() and another user attempts to earn free Snow during the following one-week cooldown window.

  • This is practical because s_earnTimer is shared globally across all users instead of being tracked separately for each user address.

Impact: Medium

  • Only one user globally can successfully earn free Snow per cooldown window, denying other users their expected weekly free Snow earning opportunity.

  • Users blocked from earning free Snow may be unable to acquire the Snow needed for Snowman NFT eligibility unless they buy Snow instead, weakening the intended free farming distribution mechanism.

Proof of Concept

The following test demonstrates that after Ashley earns Snow, Jerry is blocked from earning Snow even though Jerry has never called earnSnow() before.

Place this test in test/TestSnow.t.sol.

Run with:

forge test --match-test testGlobalEarnTimerBlocksOtherUsers -vvvv
function testGlobalEarnTimerBlocksOtherUsers() public {
// Ashley earns free Snow successfully.
vm.prank(ashley);
snow.earnSnow();
// Ashley received her free Snow.
assert(snow.balanceOf(ashley) == 1);
// Jerry has never earned Snow before.
// Under a per-user weekly cooldown, Jerry should be able to earn now.
// Instead, Jerry is blocked because Ashley reset the global earn timer.
vm.prank(jerry);
vm.expectRevert(Snow.S__Timer.selector);
snow.earnSnow();
// After the global cooldown expires, Jerry can finally earn Snow.
vm.warp(block.timestamp + 1 weeks);
vm.prank(jerry);
snow.earnSnow();
// Jerry only receives Snow after waiting for Ashley's global cooldown.
assert(snow.balanceOf(jerry) == 1);
}

The test passes, proving that the cooldown is global rather than per-user.

Recommended Mitigation

Replace the single global s_earnTimer with a per-user mapping that tracks each user's own last earn time.

- uint256 private s_earnTimer;
+ mapping(address user => uint256 lastEarnedAt) private s_lastEarnedAt;
function earnSnow() external canFarmSnow {
- if (s_earnTimer != 0 && block.timestamp < (s_earnTimer + 1 weeks)) {
+ if (s_lastEarnedAt[msg.sender] != 0 && block.timestamp < (s_lastEarnedAt[msg.sender] + 1 weeks)) {
revert S__Timer();
}
_mint(msg.sender, 1);
- s_earnTimer = block.timestamp;
+ s_lastEarnedAt[msg.sender] = block.timestamp;
}
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;
+ s_lastEarnedAt[msg.sender] = block.timestamp;
emit SnowBought(msg.sender, amount);
}

With this change, a user who earns or buys Snow updates only their own weekly cooldown. Other users remain able to earn their own free weekly Snow and are no longer blocked by an unrelated user's earnSnow() or buySnow() call.

Updates

Lead Judging Commences

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