Snowman Merkle Airdrop

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

Snow pulled into `SnowmanAirdrop` on claim has no exit path — it is locked forever while still counted in `totalSupply`

Root + Impact

Description

  • The inline comment states the staked tokens are sent to the contract "akin to burning", implying they leave circulation when the NFT is claimed.

  • Nothing is ever burned: Snow exposes no burn function, SnowmanAirdrop has no withdrawal/unstake path, and no other contract can move the tokens — the Snow sits at the airdrop address forever while remaining counted in totalSupply.

i_snow.safeTransferFrom(receiver, address(this), amount); // send tokens to contract... akin to burning @> never burned, never withdrawable

Risk

Likelihood:

  • Deterministic: every successful claim locks the user's entire Snow balance this way; there is no code path in any of the three contracts that can ever move it out.

Impact:

  • Users' tokens are permanently locked; if "burn" was the intent, the intended value sink never happens and supply accounting (totalSupply) is misleading.

  • Combined with the replay issue (same leaf claimable repeatedly), each replay locks another round of Snow with no recovery.

Proof of Concept

forge test --mt test_06_stakedSnowLockedNoExit -vvv

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
​
contract AuditPoC_M4 is Test {
SnowmanAirdrop airdrop;
Snow snow;
Snowman nft;
​
address alice;
uint256 alKey;
​
function setUp() public {
Helper deployer = new Helper();
(airdrop, snow, nft,) = deployer.run();
(alice, alKey) = makeAddrAndKey("alice");
}
​
function test_06_stakedSnowLockedNoExit() public {
vm.prank(alice);
snow.approve(address(airdrop), 1);
bytes32 digest = airdrop.getMessageHash(alice);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, digest);
vm.prank(makeAddr("relayer"));
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
​
assertEq(snow.balanceOf(address(airdrop)), 1); // Snow sits in the contract
assertEq(snow.totalSupply(), 5); // never burned, still counted
assertEq(snow.balanceOf(alice), 0);
// No function on Snow / Snowman / SnowmanAirdrop can move these tokens out.
}
}

Recommended Mitigation

Either actually burn on claim or provide an exit path:

- i_snow.safeTransferFrom(receiver, address(this), amount);
+ i_snow.burnFrom(receiver, amount); // requires adding a public burnFrom to Snow

or add unstakeSnow() / NFT-to-Snow redemption if the tokens are meant as a stake rather than a burn.

Updates

Lead Judging Commences

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