Snowman Merkle Airdrop

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

Snow.s_earnTimer is one contract-wide value, so one address's earnSnow blocks every other address for a week and all users combined can earn at most 12 Snow over the whole farming window

earnSnow checks and updates a single global timer, so the "free once a week" faucet serves one address a week in total, to whoever calls first

Description

The README says Snow "can either be earned for free onece a week, or bought at anytime" (README.md:19), which reads as each holder being able to earn once a week. earnSnow enforces the limit with one uint256 shared by the whole contract (src/Snow.sol:30): it checks that value and then overwrites it for whoever called (src/Snow.sol:93-98).

// src/Snow.sol
@> uint256 private s_earnTimer; // :30, one value for every user
...
function earnSnow() external canFarmSnow {
@> if (s_earnTimer != 0 && block.timestamp < (s_earnTimer + 1 weeks)) { // :93, checks the global timer
revert S__Timer();
}
_mint(msg.sender, 1);
​
@> s_earnTimer = block.timestamp; // :98, any caller's earn resets it for everyone
}

So after any address earns, every other address reverts with S__Timer for a week, including addresses that have never earned. No purchase is involved. Over the 12-week window at most 12 base units can be earned by all users combined, and each week's unit goes to whoever calls first after the timer expires. A single address that calls at each expiry takes every free unit.

On intent: the repo's own script/Helper.s.sol waits a week between each user's earnSnow, so the author knew one earn blocks the next. That can be read as a protocol-wide rate. But the README's wording is about what a holder can do, and a faucet that gives 12 units to the whole world over the whole farming window, on a list of five accounts, makes "earned for free" close to meaningless for an airdrop. CodeHawks' validity guide lists "design opinions with no clear loss of funds indication" among findings that may be invalid. The effect here isn't a design preference. Listed holders who rely on earning can't reach their allocation without paying, and the first caller each week takes the unit from everyone else.

Risk

Likelihood: High. It happens in normal use, with no attacker: the first address to earn each week blocks all the others.

Impact: Medium. The free route to Snow, one of the README's two, is reduced to one unit a week in total. Buying still works, and no funds are taken.

  • Cap: in the PoC, 50 addresses trying at the start and middle of every week make 1,200 attempts, and exactly 12 succeed, against 600 on the per-user reading (test_F006_oracle_fiftyUsersTwelveWeeksOnlyTwelveUnits). Every revert is S__Timer, and after the window earning reverts with S__SnowFarmingOver, so 12 is the cap.

  • Capture: one listed address earning at each expiry ends with all 12 units. The other four listed accounts hold 0, and their claims revert with SA__ZeroAmount, so buying is their only route (test_F006_oracle_oneAddressTakesEveryFreeUnit). By arithmetic, not measured as a payment, that's 4 units at the deploy script's 5 ETH fee, 20 ETH.

  • Block: an address that has never earned is blocked for the whole week after someone else earns (test_F006_oracle_oneUsersEarnBlocksAnotherWhoNeverEarned).

Proof of Concept

Save the file below as test/F006GlobalEarnTimer.t.sol in the contest repository and run, from the repository root (no fork needed):

forge test --match-path test/F006GlobalEarnTimer.t.sol -vvv

It imports only the repository's own src/ files and forge-std. Its setUp deploys the real Snow, Snowman and SnowmanAirdrop the way the repo's deploy scripts do: MockWETH, a fee of 5, and the committed Merkle root. The committed allowlist's names and proofs are copied into the file from script/flakes/output.json, for the capture test. Nothing is mocked or stubbed, and no purchase happens anywhere in the file. Actors:

  • Attacker: none needed, since ordinary earning triggers it. In the capture test, alice plays the address that calls first after each expiry.

  • Victim: bob, the 50 users, and the other four listed accounts, who can't earn.

  • Protocol: Snow, with SnowmanAirdrop for the claim check.

In the capture test, Alice calling first each week is set by the test. On a real chain it depends on transaction ordering, which a determined caller can win often but not with certainty.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
​
// Save as test/F006GlobalEarnTimer.t.sol in the contest repository, then run:
// forge test --match-path test/F006GlobalEarnTimer.t.sol -vvv
// It imports only the repository's own src/ files and forge-std.
​
import {Test, console2} from "forge-std/Test.sol";
import {Snow} from "../src/Snow.sol";
import {Snowman} from "../src/Snowman.sol";
import {SnowmanAirdrop} from "../src/SnowmanAirdrop.sol";
import {MockWETH} from "../src/mock/MockWETH.sol";
​
/// @notice The committed allowlist, copied from script/flakes/output.json: the five fixture accounts, their leaf amount
/// and their proofs against the root in script/DeploySnowmanAirdrop.s.sol.
library Allowlist {
bytes32 internal constant ROOT = 0xc0b6787abae0a5066bc2d09eaec944c58119dc18be796e93de5b2bf9f80ea79a;
uint256 internal constant LEAF_AMOUNT = 1;
uint256 internal constant SIZE = 5;
​
function accountName(uint256 i) internal pure returns (string memory) {
if (i == 0) return "alice";
if (i == 1) return "bob";
if (i == 2) return "clara";
if (i == 3) return "dan";
return "eli";
}
​
function proof(uint256 i) internal pure returns (bytes32[] memory p) {
p = new bytes32[](3);
if (i == 0) {
p[0] = 0xf99782cec890699d4947528f9884acaca174602bb028a66d0870534acf241c52;
p[1] = 0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af;
p[2] = 0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
} else if (i == 1) {
p[0] = 0x51c4b9a3cc313d7d7325f2d5d9e782a5a484e56a38947ab7eea7297ec86ff138;
p[1] = 0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af;
p[2] = 0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
} else if (i == 2) {
p[0] = 0x0065f7c9c934093ee1c4d51b77e77ad69d1c21351298d21cc720df18a39412f5;
p[1] = 0xe4f70a2d0da3e6c29810b3eb84deeae82d06479d602b0e64225458c968f98cc1;
p[2] = 0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
} else if (i == 3) {
p[0] = 0xc7c84a70b50ff4103e9a8b3a716b446a138a507fc1b65ebdfae38439e52b2612;
p[1] = 0xe4f70a2d0da3e6c29810b3eb84deeae82d06479d602b0e64225458c968f98cc1;
p[2] = 0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
} else {
p[2] = 0xd7ed3892547c15a926b49d400e13fefe2c9f08de658f08b09925d5790383e978;
}
}
}
​
/// @title F-006 proof: Snow.s_earnTimer (Snow.sol:30) is one contract-wide value, so one address's earnSnow blocks
/// every other address for a week. At most 12 base units can be earned by all users combined.
/// @notice Snow, Snowman and SnowmanAirdrop are the real contracts from src/, deployed in setUp the way
/// script/DeploySnow.s.sol and script/DeploySnowmanAirdrop.s.sol do (MockWETH, a fee of 5, the committed root).
/// Nothing is stubbed. No purchase happens anywhere in this file, so buySnow's own write to the timer plays no part.
/// Actors:
/// - Attacker: none needed; ordinary earning triggers it. In the capture test alice plays the address that calls
/// first after each expiry and takes every free unit.
/// - Victim: bob, the 50 users, and the other four listed accounts, who can't earn.
/// - Protocol: Snow, with SnowmanAirdrop for the claim check.
/// Reachability:
/// - vm.warp in setUp sets the deployment time; later warps go to stored/computed timestamps inside the 12-week
/// farming window, i.e. ordinary time passing. No `block.timestamp + x` warps are chained.
/// - vm.prank(x) is x's own call to earnSnow, open to anyone during farming, or anyone submitting claimSnowman,
/// which has no caller check.
/// - No vm.deal, vm.store or vm.etch: earning needs no funds and no special state.
contract F006GlobalEarnTimerTest is Test {
uint256 internal constant START = 1_700_000_000; // the deployment time this test uses
uint256 internal constant BUY_FEE = 5; // the fee DeploySnow.s.sol uses
uint256 private constant USERS = 50; // ordinary users trying to earn
uint256 private constant WEEKS = 12; // the farming window
​
MockWETH internal weth; // Protocol: the WETH Snow is deployed with
Snow internal snow; // Protocol
Snowman internal nft; // Protocol
SnowmanAirdrop internal airdrop; // Protocol
address internal collector; // Protocol: the fee collector
​
address private alice; // the address that earns first
address private bob; // Victim: has never earned
​
function setUp() public {
vm.warp(START); // the deployment time
collector = makeAddr("collector"); // Protocol: the collector
weth = new MockWETH(); // Protocol: the WETH, as DeploySnow.s.sol deploys it
snow = new Snow(address(weth), BUY_FEE, collector); // Protocol: Snow, with a fee of 5
nft = new Snowman("data:image/svg+xml;base64,AA=="); // Protocol: Snowman (the image doesn't matter here)
airdrop = new SnowmanAirdrop(Allowlist.ROOT, address(snow), address(nft)); // Protocol: the airdrop, with the committed root
alice = makeAddr("alice"); // first earner
bob = makeAddr("bob"); // Victim
}
​
/// @dev The i-th of the 50 users.
function _user(uint256 i) private returns (address) {
return makeAddr(string.concat("user", vm.toString(i))); // a fresh address per index
}
​
/// @dev Try one earn as `who`; true if it succeeded. Any revert other than S__Timer fails the test.
function _tryEarn(address who) private returns (bool ok) {
vm.prank(who); // any address may call earnSnow during farming
try snow.earnSnow() { // the earn attempt
ok = true; // it succeeded
} catch (bytes memory err) { // it reverted
assertEq(bytes4(err), Snow.S__Timer.selector, "only the timer blocks"); // and only because of the timer
ok = false; // it failed
}
}
​
/// @dev Bob has never earned and nobody bought anything, yet his first earn reverts because Alice earned.
function test_F006_oracle_oneUsersEarnBlocksAnotherWhoNeverEarned() public {
vm.prank(alice); // alice calls
snow.earnSnow(); // her earn sets the one global timer
assertEq(snow.balanceOf(alice), 1); // she earned a unit
​
vm.prank(bob); // Victim calls
vm.expectRevert(Snow.S__Timer.selector); // blocked by alice's timer
snow.earnSnow(); // bob's first ever earn
​
// Still blocked one second before Alice's week ends.
vm.warp(START + 1 weeks - 1); // time: just before alice's week ends
vm.prank(bob); // Victim calls
vm.expectRevert(Snow.S__Timer.selector); // still blocked
snow.earnSnow(); // earn attempt
​
assertEq(snow.balanceOf(bob), 0, "Bob, who never earned, got nothing in the week Alice earned"); // nothing for bob
assertEq(snow.totalSupply(), 1); // one unit in total
}
​
/// @dev Control: on a fresh deployment with nobody else earning, Bob's first earn succeeds.
function test_F006_control_bobEarnsWhenNobodyElseHas() public {
vm.prank(bob); // bob calls
snow.earnSnow(); // nobody has earned, so it succeeds
assertEq(snow.balanceOf(bob), 1); // one unit
}
​
/// @dev 50 addresses each try earnSnow at the start and middle of every week of the 12-week window, with no
/// purchases. Exactly 12 calls succeed and totalSupply == 12, against 600 on the per-user reading. The caller
/// order is rotated each week, and each week's winner is simply whoever calls first.
function test_F006_oracle_fiftyUsersTwelveWeeksOnlyTwelveUnits() public {
uint256 successes; // earns that went through
uint256 attempts; // earns tried
for (uint256 week = 0; week < WEEKS; week++) { // each week of the window
uint256 weekStart = START + week * 1 weeks; // the week's first moment
uint256 midWeek = weekStart + 3 days; // a later moment in the same week
uint256 firstCaller = week % USERS; // rotate who calls first
​
vm.warp(weekStart); // time: the start of the week
for (uint256 k = 0; k < USERS; k++) { // every user tries, in rotated order
uint256 i = (firstCaller + k) % USERS; // this caller's index
bool ok = _tryEarn(_user(i)); // earn attempt
attempts++; // count it
if (ok) { // if it succeeded
successes++; // count it
assertEq(i, firstCaller, "the week's unit goes to whoever calls first"); // only the first caller wins
}
}
vm.warp(midWeek); // time: mid-week
for (uint256 i = 0; i < USERS; i++) { // everyone tries again
assertFalse(_tryEarn(_user(i)), "nobody else earns later in the week"); // all blocked
attempts++; // count it
}
}
console2.log("earn attempts", attempts); // log: 1200
console2.log("successful earns", successes); // log: 12
console2.log("snow.totalSupply()", snow.totalSupply()); // log: 12
console2.log("per-user reading would allow", USERS * WEEKS); // log: 600
​
assertEq(successes, WEEKS, "exactly one earn per week across all 50 users"); // one per week, globally
assertEq(snow.totalSupply(), WEEKS, "12 units total, not 600"); // the whole free supply
assertEq(address(snow).balance, 0, "no purchases involved"); // no ETH paid
assertEq(weth.balanceOf(address(snow)), 0, "no purchases involved"); // no WETH paid
​
// Past the window, earning is closed entirely, so 12 is the cap.
vm.warp(START + 12 weeks); // time: the window is over
vm.prank(_user(0)); // a user calls
vm.expectRevert(Snow.S__SnowFarmingOver.selector); // farming is closed
snow.earnSnow(); // earn attempt
}
​
/// @dev One listed address calling at each expiry takes every free unit; the other four listed accounts earn
/// nothing in any week and cannot claim without buying.
function test_F006_oracle_oneAddressTakesEveryFreeUnit() public {
for (uint256 week = 0; week < WEEKS; week++) { // each week of the window
vm.warp(START + week * 1 weeks); // time: the timer has just expired
vm.prank(alice); // alice calls first
snow.earnSnow(); // and takes the week's unit
for (uint256 i = 1; i < Allowlist.SIZE; i++) { // the other four listed accounts
assertFalse(_tryEarn(makeAddr(Allowlist.accountName(i))), "rest of the list is blocked"); // all blocked
}
}
assertEq(snow.balanceOf(alice), WEEKS, "Alice holds the whole free supply"); // 12 units
assertEq(snow.totalSupply(), WEEKS); // and nobody else has any
for (uint256 i = 1; i < Allowlist.SIZE; i++) { // each Victim on the list
address who = makeAddr(Allowlist.accountName(i)); // the account
assertEq(snow.balanceOf(who), 0, "nothing earned"); // holds no Snow
// Their claim fails for lack of Snow (leaf amount 1) before any signature check; the only route left
// is to buy.
vm.expectRevert(SnowmanAirdrop.SA__ZeroAmount.selector); // zero balance
airdrop.claimSnowman(who, Allowlist.proof(i), 0, bytes32(0), bytes32(0)); // claim attempt
}
}
}

Output, from a clean copy of the repository at commit 6efcb0f with its own foundry.toml:

[PASS] test_F006_oracle_oneUsersEarnBlocksAnotherWhoNeverEarned() (gas: 191570)
Suite result: ok. 4 passed; 0 failed; 0 skipped; finished in 137.10ms (144.90ms CPU time)
[PASS] test_F006_control_bobEarnsWhenNobodyElseHas() (gas: 110630)
[PASS] test_F006_oracle_fiftyUsersTwelveWeeksOnlyTwelveUnits() (gas: 43835139)
Logs:
earn attempts 1200
successful earns 12
snow.totalSupply() 12
per-user reading would allow 600
[PASS] test_F006_oracle_fiftyUsersTwelveWeeksOnlyTwelveUnits() (gas: 43835139)
Logs:
earn attempts 1200
successful earns 12
snow.totalSupply() 12
per-user reading would allow 600
[PASS] test_F006_oracle_oneAddressTakesEveryFreeUnit() (gas: 2562475)
[PASS] test_F006_oracle_oneUsersEarnBlocksAnotherWhoNeverEarned() (gas: 191570)
Suite result: ok. 4 passed; 0 failed; 0 skipped; finished in 137.10ms (144.90ms CPU time)
  • Block: Alice earns, then Bob, who has never earned, reverts with S__Timer, and still does one second before Alice's week ends. On a fresh deployment, Bob's first earn succeeds (the control).

  • Cap: 1,200 attempts, 12 successful earns, total supply 12, against 600 on the per-user reading. Each week's single success goes to whoever calls first.

  • Capture: Alice holds all 12 units, and the other four listed accounts hold 0 and can't claim.

Recommended Mitigation

Make the timer per address, checked and updated only in earnSnow. Where the fix has to touch buySnow's write to the timer, it uses that separate issue's fix and deletes the line. Converting it to the buyer's own entry instead would keep every purchase delaying the buyer's own earn:

- uint256 private s_earnTimer;
+ mapping(address => uint256) private s_earnTimer;
...
function buySnow(uint256 amount) external payable canFarmSnow {
...
- 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_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;
}

Notes:

  • Tested: this change was tested on a patched copy of Snow. Bob earns in the same week Alice does. The same 50-user schedule gives 600 successful earns, and every mid-week retry still reverts, so each user is still limited to one earn a week. All five listed accounts earn and claim in the first week for nothing. Buying with ETH and WETH, the weekly limit and collectFee still work.

  • Related: the line deleted in buySnow is a separate issue, where any purchase resets the earn timer. Its fix is that deletion.

  • Side effect: per-address earning can be farmed with fresh addresses, one unit each per week, and it makes single Snow units free to obtain.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 18 minutes 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!