Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: medium
Likelihood: high
Invalid

Global s_earnTimer Causes Denial of Service Across All Users

Description

s_earnTimer is declared as a single uint256, not a per-address mapping:

uint256 private s_earnTimer;
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;
}

Every call to earnSnow(), regardless of who makes it, reads and overwrites this same shared variable. The cooldown check has no concept of "have I, this specific address, called this recently" — it only knows "has anyone called this recently."

Risk

Likelihood: High. This requires no adversarial setup at all — it's the automatic, guaranteed outcome the moment any two different addresses both try to use earnSnow() within the same week of each other. In any protocol with more than a single active user, this triggers constantly as a side effect of normal usage, not as an edge case someone has to engineer.

Impact: Medium. No funds are lost, and the block is temporary (resolves once the 1-week window from whichever address called last has elapsed) — so this is a denial-of-service on core functionality rather than a funds-at-risk issue. It's still a direct break of intended behavior: earnSnow() is supposed to let each user claim their own free weekly Snow, and instead a single caller can lock every other address out of that entirely, repeatedly, for as long as the farming period lasts. Affected users do still have the paid buySnow() path available as a fallback.

Proof of Concept

function test_M2_GlobalTimerBlocksOtherUsers() public {
address userA = makeAddr("userA");
address userB = makeAddr("userB");
vm.warp(block.timestamp + 1 weeks + 1);
vm.prank(userA);
snow.earnSnow();
assertEq(snow.balanceOf(userA), 1);
vm.prank(userB);
vm.expectRevert(Snow.S__Timer.selector);
snow.earnSnow();
assertEq(snow.balanceOf(userB), 0);
}

Result: PASS. userB, who has never called earnSnow() before in their life, is blocked purely because userA called it first — with zero interaction between the two addresses.

Recommended Mitigation

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

Lead Judging Commences

ai-first-flight-judge Lead Judge about 3 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!