Snowman Merkle Airdrop

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

Snow::s_earnTimer is a single global slot written by both earnSnow and buySnow, so any caller can deny the free weekly Snow to every user for the entire farming period at zero cost

s_earnTimer is one global slot rather than a per-user record, so any caller can deny the free Snow mechanism to every user for the whole farming period at zero cost

Description

  • The README states that Snow "can be earned for free once a week". That is a per-user entitlement: each address should be able to call earnSnow once per week, independently of what anyone else does.

  • s_earnTimer is a single uint256 storage slot shared by the entire contract, not a mapping(address => uint256). earnSnow compares block.timestamp against that one slot and then overwrites it, so the cooldown applies globally: whoever calls first sets the clock for everybody. buySnow writes the same slot as well, which widens the problem from an accounting mistake into a cheap denial of service, because buySnow can be called with zero cost.

@> uint256 private s_earnTimer; // one slot for all users, not a per-user mapping
function buySnow(uint256 amount) external payable canFarmSnow {
@> if (msg.value == (s_buyFee * amount)) { // with amount = 0 this is 0 == 0, so it passes with no payment
_mint(msg.sender, amount); // mints zero
} else {
i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount);
}
@> s_earnTimer = block.timestamp; // buySnow resets the shared earn cooldown
emit SnowBought(msg.sender, amount);
}
function earnSnow() external canFarmSnow {
@> if (s_earnTimer != 0 && block.timestamp < (s_earnTimer + 1 weeks)) {
revert S__Timer(); // fires for every user, not just the one who earned
}
_mint(msg.sender, 1);
@> s_earnTimer = block.timestamp;
}

There is no zero-amount guard on buySnow, so buySnow(0) with msg.value of zero takes the equality branch (0 == s_buyFee * 0), mints nothing, transfers nothing, and still writes s_earnTimer. An attacker therefore resets everyone's cooldown for the price of gas alone, and can repeat it indefinitely at intervals shorter than a week.

The sponsor's own deployment helper contains the workaround for the first half of this. script/Helper.s.sol sets up five users and inserts vm.warp(block.timestamp + 1 weeks) between each one, because otherwise only the first of the five can obtain any Snow:

vm.prank(alice);
snow.earnSnow();
aliceSB = snow.balanceOf(alice);
vm.warp(block.timestamp + 1 weeks); // required only because the timer is global
vm.prank(bob);
snow.earnSnow();

A per-user timer would need no warping at all.

Risk

Likelihood:

  • The first half needs no attacker. It occurs the moment two users try to earn in the same week, which is the ordinary case for any airdrop with more than one participant. The sponsor's own fixture demonstrates it by working around it.

  • The denial-of-service half is permissionless and costs only gas. buySnow(0) requires no ETH, no WETH, no approval and no prior balance, so any address can perform it, and repeat it every few days for the full 12 weeks.

  • Both paths are open for the entire FARMING_DURATION window, which is the only period during which Snow can be obtained at all.

Impact:

  • The advertised "free once a week" entitlement does not exist. In any week, exactly one address in the whole system can earn, and only if no one has called buySnow since.

  • An attacker denies the free distribution mechanism to every user for its entire lifetime at negligible cost. Since earnSnow is the only way to obtain Snow without paying, and Snow holdings are what the Snowman airdrop is derived from, this locks out every user who cannot afford to buy.

  • buySnow resetting the earn cooldown also means any ordinary purchase by any user silently postpones everyone else's free claim, with no notice and no relationship to the buyer's own entitlement.

Scope note, stated rather than left for a judge to find: this is not a permanent denial. canFarmSnow reverts once block.timestamp >= i_farmingOver, which is FARMING_DURATION (12 weeks) after deployment, so both earnSnow and buySnow close at that point regardless. The claim proved below is total denial for the whole 12-week window in which the mechanism exists, which is bounded but complete: the second proof of concept runs the attack across that entire window and ends with the victim holding zero.

Proof of Concept

Snow is deployed here directly with the project's own FEE = 5 from script/DeploySnow.s.sol, because Snow exposes no getter for its WETH address and the deploy script wires an instance the Helper does not return.

Part 1 - one user earning locks out everyone else. The test opens with the counterfactual so the result cannot be an artifact of the fixture: it first proves Bob can earn while the timer is clear, reverts that state, and only then lets Alice earn first.

function test_C4_one_user_earning_blocks_every_other_user_for_a_week() public {
// COUNTERFACTUAL: with the timer untouched, bob can earn immediately.
uint256 snap = vm.snapshotState();
vm.prank(bob);
snow.earnSnow();
assertEq(snow.balanceOf(bob), 1, "control: bob can earn when the timer is clear");
vm.revertToState(snap);
// Now alice earns first.
vm.prank(alice);
snow.earnSnow();
assertEq(snow.balanceOf(alice), 1);
// Bob is now locked out, despite never having earned anything.
vm.prank(bob);
vm.expectRevert(Snow.S__Timer.selector);
snow.earnSnow();
// He stays locked out for a full week of everyone's time, not his own.
vm.warp(block.timestamp + 1 weeks - 1);
vm.prank(bob);
vm.expectRevert(Snow.S__Timer.selector);
snow.earnSnow();
vm.warp(block.timestamp + 1);
vm.prank(bob);
snow.earnSnow();
assertEq(snow.balanceOf(bob), 1, "bob finally earns, one week late");
}

Part 2 - a free call denies the mechanism for its entire lifetime. Each round asserts S__Timer specifically. Had the farming window closed early the revert would have been S__SnowFarmingOver and the test would fail, so the test proves it stayed inside the window throughout.

function test_C4b_free_call_denies_the_earn_mechanism_for_its_entire_lifetime() public {
// 13 rounds of 6 days = 78 days, inside the 84-day farming window.
// buySnow(0) with no ETH: msg.value (0) == s_buyFee * 0 (0), so the
// equality branch is taken, zero tokens are minted, and the timer resets.
//
// Each round expects S__Timer specifically. If the farming window had
// already closed the revert would be S__SnowFarmingOver instead and this
// test would fail - so the test proves it stayed inside the window.
for (uint256 i = 0; i < 13; i++) {
vm.warp(block.timestamp + 6 days);
vm.prank(attacker);
snow.buySnow{value: 0}(0);
vm.prank(alice);
vm.expectRevert(Snow.S__Timer.selector);
snow.earnSnow();
}
// Now step past the 12-week farming deadline. The mechanism closes for
// good, and alice never earned a single wei during its whole existence.
vm.warp(block.timestamp + 7 days); // 85 days total, past the 84-day cutoff
vm.prank(alice);
vm.expectRevert(Snow.S__SnowFarmingOver.selector);
snow.earnSnow();
assertEq(snow.balanceOf(attacker), 0, "attacker minted nothing");
assertEq(snow.balanceOf(alice), 0, "alice earned nothing, ever");
assertEq(address(snow).balance, 0, "the attack cost zero ETH");
console2.log("days of denial, covering the whole farming window:", uint256(78));
console2.log("alice's Snow balance at farming close :", snow.balanceOf(alice));
}

Results:

[PASS] test_C4_one_user_earning_blocks_every_other_user_for_a_week()
[PASS] test_C4b_free_call_denies_the_earn_mechanism_for_its_entire_lifetime()
days of denial, covering the whole farming window: 78
alice's Snow balance at farming close : 0

The attacker spent zero ETH and zero WETH, minted zero tokens, and the victim finished the entire farming period with a balance of zero.

Recommended Mitigation

Make the cooldown per user, and stop an unrelated function from writing it.

- 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;
}

Remove the write from buySnow entirely, since buying is a separate mechanism from the free weekly earn and has no reason to govern it:

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);
}

The S__ZeroValue error is already declared in the contract and is currently used only in the constructor, so no new error type is needed. The zero-amount guard is worth adding on its own merits: buySnow(0) presently emits a SnowBought event for a purchase that never happened.

Distinctness

This concerns Snow.sol and the availability of the token itself, and is independent of the previously reported issues in Snowman.sol and SnowmanAirdrop.sol. Fixing the unrestricted mint, the live-balance Merkle leaf, or the unread claim flag leaves this defect untouched, and fixing this one leaves those untouched. The single point of contact is that earnSnow is the free route to the balance those reports discuss, which makes this the upstream availability problem rather than a duplicate of any of them.

Updates

Lead Judging Commences

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