Snowman Merkle Airdrop

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

s_earnTimer variable is common to all users, leading to everyone being blocked for no reasons

The s_earnTimer variable is common to all the users, meaning that anytime someone resets it, everyone is blocked until the end (1 week).

Description

  • There is a variable called s_earnTimer in the Snow.sol contract. The objective of this variable is to prevent users from earning snow at any time, they have to wait for 1 week until their next earning. And every user have their own timer, so they do not depend on other users earning times.

  • In our case, the s_earnTimer is common for all the user, leading to all the other users being unable to earn snow whenever ONE user call earnSnow() (because it resets the timer).

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; // here, the timer is set to block.timestamp for everyone.
}

Risk

Likelihood:

  • This will occur everytime a user calls earnSnow().

  • This will happen very often since everyone should be able to earn snow every week.

Impact:

  • This leads to all the users being unable to earn snow whenever a user calls this function

  • Meaning that no one can really earn snow every week as said by the protocol

Proof of Concept

To demonstrate this vulnerability, place the following test function in the test/TestSnow.t.sol file

function test_oneUserBlocksEveryone() public {
console.log("=== Test 1 : Alice bloque Bob et Charlie ===\n");
// T=0 : alice earns first
vm.prank(alice);
snow.earnSnow();
console.log("T=0 Alice earnSnow() -> OK, s_earnTimer =", snow.s_earnTimer());
console.log(" Alice balance :", snow.balanceOf(alice));
// T=3j : bob tries --> revert because alice already earned and rested the timer
vm.warp(block.timestamp + 3 days);
console.log("\nT=3j Bob earnSnow() -> should REVERT (blocked by Alice's timer)");
vm.prank(bob);
vm.expectRevert(Snow.S__Timer.selector);
snow.earnSnow();
console.log(" REVERT confirmed. Bob's balance :", snow.balanceOf(bob));
// T=5j : charlie tries --> same
vm.warp(block.timestamp + 2 days);
console.log("\nT=5j Charlie earnSnow() -> should REVERT");
vm.prank(charlie);
vm.expectRevert(Snow.S__Timer.selector);
snow.earnSnow();
console.log(" REVERT confirmed. Charlie's balance :", snow.balanceOf(charlie));
// T=7j : bob is coming back --> resets the timer for everyone
vm.warp(block.timestamp + 2 days);
console.log("\nT=7j Bob earnSnow() -> OK (Alice's expired)");
vm.prank(bob);
snow.earnSnow();
console.log(" Bob's balance :", snow.balanceOf(bob));
console.log(" s_earnTimer updated by bob");
// T=7j+1s : alice tries but is blocked by bob this time
vm.warp(block.timestamp + 1);
console.log("\nT=7j+1 Alice earnSnow() -> should REVERT (blocked by Bob's timer now)");
vm.prank(alice);
vm.expectRevert(Snow.S__Timer.selector);
snow.earnSnow();
console.log(" REVERT confirmed.\n");
console.log("[RESULT] Alice & Bob earned one time in 7 days.");
console.log(" Charlie : 1. They should have been able to earn independamently.\n");
}

Then run forge test --mt test_oneUserBlocksEveryone -vv

Recommended Mitigation

As a mitigation, you should create a variable for every user that tracks their earning time indepentamently of the other users.

+ mapping(address => uint256) private s_earnTimers;
// in earnSnow()
function earnSnow() external canFarmSnow {
+ if (s_earnTimers[msg.sender] != 0 &&
+ block.timestamp < s_earnTimers[msg.sender] + 1 weeks) {
revert S__Timer();
}
_mint(msg.sender, 1e18);
+ s_earnTimers[msg.sender] = block.timestamp;
+ emit SnowEarned(msg.sender, 1e18);
}
Updates

Lead Judging Commences

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