Snowman Merkle Airdrop

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

Free `buySnow(0)` permanently DoS-es the weekly `earnSnow` for everyone

Root + Impact

Description

Normal behavior: earnSnow() is the free weekly faucet: once per week each user can mint 1 Snow token, which is the amount whitelisted airdrop recipients need to fund their merkle claim. buySnow is the paid purchase path. Neither should ever be able to block the other for third parties.

The issue: earnSnow is gated on a single global timer (s_earnTimer) that every buySnow resets — including buySnow(0), which costs s_buyFee * 0 = 0 in both the ETH and the WETH branch. Any address can therefore call buySnow(0) for free at any time and push s_earnTimer forward. Repeating the call once per week blocks earnSnow() for every user indefinitely (each legit earner reverts with S__Timer because someone else's activity restarted the window). Regular paid purchases by any participant have the same side effect.

Root cause in src/Snow.sol:

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; // every buy resets the GLOBAL earn window
emit SnowBought(msg.sender, amount);
}
function earnSnow() external canFarmSnow {
if (s_earnTimer != 0 && block.timestamp < (s_earnTimer + 1 weeks)) {
revert S__Timer(); // one user's buy blocks all users
}
_mint(msg.sender, 1);
s_earnTimer = block.timestamp;
}

Risk

Likelihood:

  • Reason 1 — buySnow(0) is callable by any address, at any time, with zero ETH and zero WETH (the amount = 0 branch executes and mints 0); there is no caller restriction and no cost.

  • Reason 2 — The attack recurs trivially: one free buySnow(0) per week is enough to keep the timer perpetually fresh, and even non-malicious purchase traffic continuously postpones the free-earn window for the whole user base.

Impact:

  • Impact 1 — The only free way to obtain Snow is permanently disabled for all users, and whitelisted recipients who rely on earnSnow to reach the amount = 1 merkle balance can never claim their NFT — a permanent exclusion from the airdrop.

  • Impact 2 — A core advertised protocol feature (weekly free Snow) becomes unusable for the protocol's entire lifetime, at zero cost to the attacker.

Proof of Concept

Test: forge test --match-contract TestSnowmanPoCs --match-test testPoc3_BuyZeroFreezesEarn -vv (see poc/TestSnowmanPoCs.t.sol).

// t0: alice earns her weekly Snow.
// t0: attacker calls buySnow(0) — free, resets s_earnTimer to now.
// t0 + 6 days: alice calls earnSnow() -> reverts S__Timer()
// (6 of the 7 required days have passed, but the attacker's reset pushed her out).
// attacker repeats buySnow(0) once per week: alice can never earn again.

The test passes: Alice's earnSnow() reverts 6 days after a free buySnow(0) reset.

Recommended Mitigation

+ // track the earn window per user, and do not let purchases move it:
+ mapping(address => uint256) private s_lastEarn;
function buySnow(uint256 amount) external payable canFarmSnow {
+ if (amount == 0) revert S__ZeroValue();
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);
}
function earnSnow() external canFarmSnow {
- if (s_earnTimer != 0 && block.timestamp < (s_earnTimer + 1 weeks)) {
+ if (s_lastEarn[msg.sender] != 0 && block.timestamp < (s_lastEarn[msg.sender] + 1 weeks)) {
revert S__Timer();
}
_mint(msg.sender, 1);
- s_earnTimer = block.timestamp;
+ s_lastEarn[msg.sender] = block.timestamp;
}
Updates

Lead Judging Commences

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