Snowman Merkle Airdrop

First Flight #42
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

Tokens permanently locked in airdrop contract without staking or recovery mechanism

Description:

The SnowmanAirdrop contract transfers SNOW tokens from users to itself during the claim process but provides no mechanism to recover or utilize these tokens. When users claim their Snowman NFTs, their SNOW tokens are transferred to the airdrop contract via safeTransferFrom(receiver, address(this), amount) but remain permanently locked since the contract lacks any withdrawal, rescue, or administrative functions. This creates an unintended token sink that effectively removes tokens from circulation without proper burning.

Additionally, the protocol documentation states that "Snow.sol is an ERC20 token that allows holders to claim Snowman NFTs through a staking mechanism in the SnowmanAirdrop contract. By staking Snow tokens, users receive Snowman NFTs proportional to their Snow token holdings." However, the implementation does not provide any actual staking mechanism - tokens are simply transferred to the contract and locked permanently without any staking rewards, unstaking functionality, or yield generation that would be expected from a proper staking system.

Attack path:

  1. User calls claimSnowman() with valid merkle proof and signature

  2. Contract validates the claim and transfers user's SNOW tokens to itself: i_snow.safeTransferFrom(receiver, address(this), amount)

  3. Contract mints NFT to user and marks claim as complete

  4. SNOW tokens remain permanently locked in the contract with no recovery method

Impact:

All claimed SNOW tokens are permanently locked and cannot be recovered

Recommended Mitigation:

Option 1: Real burning

// Replace token transfer with actual burning
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s) external {
// ... existing validation logic ...
// Instead of transferring to contract:
// i_snow.safeTransferFrom(receiver, address(this), amount);
// Implement proper burning:
i_snow.safeTransferFrom(receiver, BURN_ADDRESS, amount); // 0x000...000
// OR if burn function exists:
// i_snow.burnFrom(receiver, amount);
// ... rest of function ...
}

Option 2: Add extraction functions

import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract SnowmanAirdrop is EIP712, ReentrancyGuard, Ownable {
// ... existing code ...
constructor(bytes32 _merkleRoot, address _snow, address _snowman)
EIP712("Snowman Airdrop", "1")
Ownable(msg.sender) {
// ... existing constructor logic ...
}
function emergencyWithdrawSnow() external onlyOwner {
uint256 balance = i_snow.balanceOf(address(this));
require(balance > 0, "No tokens to withdraw");
i_snow.safeTransfer(owner(), balance);
}
function rescueTokens(address token, uint256 amount) external onlyOwner {
require(token != address(0), "Invalid token address");
IERC20(token).safeTransfer(owner(), amount);
}
function emergencyWithdrawEth() external onlyOwner {
(bool success, ) = payable(owner()).call{value: address(this).balance}("");
require(success, "ETH withdrawal failed");
}
}

Option 3: Implement proper staking mechanism

contract SnowmanAirdrop is EIP712, ReentrancyGuard, Ownable {
mapping(address => uint256) private stakedAmount;
mapping(address => uint256) private stakeTimestamp;
function stakeAndClaim(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s) external {
// ... existing validation logic ...
// Stake tokens instead of locking them
stakedAmount[receiver] = amount;
stakeTimestamp[receiver] = block.timestamp;
i_snow.safeTransferFrom(receiver, address(this), amount);
// Mint NFT
i_snowman.mintSnowman(receiver, amount);
}
function unstake() external {
uint256 amount = stakedAmount[msg.sender];
require(amount > 0, "No staked tokens");
stakedAmount[msg.sender] = 0;
stakeTimestamp[msg.sender] = 0;
i_snow.safeTransfer(msg.sender, amount);
}
}

The first option (actual burning) is recommended if the intent is to permanently remove tokens from circulation. The second option provides administrative control and emergency recovery capabilities while maintaining the current token transfer mechanism. The third option would implement the actual staking mechanism as described in the documentation.

Updates

Lead Judging Commences

yeahchibyke Lead Judge 21 days ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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