Snowman Merkle Airdrop

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

Snow tokens permanently locked in airdrop contract

Snow tokens permanently locked in airdrop contract

Root + Impact

During claimSnowman, the receiver's Snow tokens are transferred to the airdrop contract. The contract has no function to withdraw, burn, or otherwise move those tokens out. The Snow is permanently locked.

i_snow.safeTransferFrom(receiver, address(this), amount); // @> tokens arrive, never leave
s_hasClaimedSnowman[receiver] = true;
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);

There is no corresponding withdrawSnow, burnSnow, unstake, or recoverTokens function anywhere in the contract. The airdrop contract does not inherit Ownable and has no access-control mechanism that could gate a withdrawal.

Risk

Likelihood :

  • Every single claim call transfers Snow tokens into the contract. Over the lifetime of the airdrop, the contract accumulates all claimed Snow with no mechanism to move it.

Impact :

  • Snow tokens that cost real ETH/WETH to mint are removed from circulation and locked in a dead contract. There is no economic or governance mechanism to recover them.

  • If the intent is to "burn" the tokens, using a blind transfer to a contract with no withdrawal function is fragile -- there is no event, no explicit burn, and no way to prove the tokens are intentionally destroyed.

Proof of Concept

function test_SnowLockedInAirdrop() public {
uint256 airdropSnowBefore = snow.balanceOf(address(airdrop));
bytes32 digest = airdrop.getMessageHash(alice);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, digest);
vm.prank(attacker);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
assertEq(snow.balanceOf(address(airdrop)), airdropSnowBefore + 1);
assertEq(nft.balanceOf(alice), 1);
// No withdraw function exists -- any call to hypothetical methods reverts
bytes memory sel = abi.encodeWithSignature("withdrawSnow(address,uint256)", alice, 1);
(bool success,) = address(airdrop).call(sel);
assertFalse(success);
sel = abi.encodeWithSignature("recoverTokens(address,address,uint256)", address(snow), alice, 1);
(success,) = address(airdrop).call(sel);
assertFalse(success);
// Snow is irrevocably locked
assertEq(snow.balanceOf(address(airdrop)), 1);
}

Recommended Mitigation

If the Snow is meant to be burned, call burn on the token or transfer to a canonical burn address with an explicit TokensBurned event. If the Snow is meant to be staked (as the README states), add a permissioned withdrawal function:

function withdrawSnow(address to, uint256 amount) external onlyOwner {
i_snow.safeTransfer(to, amount);
}

If neither is acceptable (e.g., the contract must remain ownerless), at minimum emit the transfer as a burn-like event and document that tokens are intentionally destroyed.

+ emit SnowBurned(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
Updates

Lead Judging Commences

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