Snowman Merkle Airdrop

First Flight #42
Beginner FriendlyFoundrySolidityNFT
100 EXP
Submission Details
Impact: medium
Likelihood: medium
Invalid

`Helper.s.sol` bypasses `Snow.sol` global cooldown using `vm.warp`, creating unrealistic token balances and invalid Merkle airdrop state

Author Revealed upon completion

Root + Impact

Description

Normal behavior:
The Snow contract enforces a global cooldown via s_earnTimer, allowing only one user to earn 1 SNOW token per week. This ensures fair, time-gated distribution.

The problem:
The Helper script breaks this logic by warping block.timestamp after each earnSnow() call to simulate that each user earned one token weekly. In reality, this state is not achievable on-chain, yet the script collects balances and may be used to create Merkle roots based on them — leading to invalid proof generation and user claim failures.


vm.prank(alice);
snow.earnSnow(); // ✅ Allowed at t = 0
vm.warp(block.timestamp + 1 weeks); // ⏩ Artificial jump
vm.prank(bob);
snow.earnSnow(); // ✅ Allowed now, but only due to script

Risk

Likelihood: Medium

  • This occurs any time the Helper script is used to simulate multiple users claiming earnSnow() in the same test run. vm.warp is called after each user to simulate weekly eligibility.

  • Developers may mistakenly assume the resulting Snow balances are valid, and use them to build Merkle tree input — resulting in root/hash mismatches and broken claims on-chain.

Impact: Medium

  • Airdrop Merkle trees may be built using balances that are not possible to reproduce on-chain.

  • Users may be incorrectly included or excluded from the claim list, leading to broken proofs.

  • Undermines trust in the airdrop distribution and breaks compatibility between test and mainnet behavior


Proof of Concept

The PoC shows that Snow.sol uses a global cooldown, which prevents more than one user from earning Snow tokens per week. In a real on-chain scenario, only one user can call earnSnow() successfully during that period.

However, the Helper.s.sol script artificially uses vm.warp to advance time between user calls, allowing multiple accounts to appear eligible in the same test script. This creates a false token state that is not reproducible on-chain — yet those balances can be used to generate a Merkle root, which breaks claim verification in production.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import {Snow} from "../src/Snow.sol";
contract HelperPoC is Test {
Snow public snow;
address alice = address(0xA11);
address bob = address(0xB22);
function setUp() public {
// Deploy Snow contract with dummy args
snow = new Snow(address(0xWETH), 1, address(0xCollector));
}
function testGlobalCooldownEnforced() public {
// Alice earns 1 Snow
vm.prank(alice);
snow.earnSnow();
assertEq(snow.balanceOf(alice), 1);
// Bob tries to earn immediately after Alice
vm.prank(bob);
vm.expectRevert(); // ❌ Reverts due to global cooldown
snow.earnSnow();
}
function testHelperBypassesCooldown() public {
// Alice earns
vm.prank(alice);
snow.earnSnow();
// Bypass global lock with artificial warp
vm.warp(block.timestamp + 1 weeks);
// Bob now succeeds only because time was warped
vm.prank(bob);
snow.earnSnow();
assertEq(snow.balanceOf(bob), 1); // ❗ Appears valid, but unrealistic on-chain
}
}

Recommended Mitigation

Switch the global cooldown (s_earnTimer) to a per-user cooldown mapping. This allows each user to earn 1 SNOW per 7 days without relying on global state. It removes the need for artificial time jumps in tests and aligns the script’s state with what’s actually possible on-chain.

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

Lead Judging Commences

yeahchibyke Lead Judge
3 days ago
yeahchibyke Lead Judge about 5 hours ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.