Snowman Merkle Airdrop

First Flight #42
Beginner FriendlyFoundrySolidityNFT
100 EXP
Submission Details
Impact: high
Likelihood: high

Global Timer Reset in Snow::buySnow Denies Free Claims for All Users

Author Revealed upon completion

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

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:

// 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

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

// 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)

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.