AirDropper

AI First Flight #5
Beginner FriendlyDeFiFoundry
EXP
View results
Submission Details
Impact: medium
Likelihood: medium
Invalid

Lack of token withdrawal functionality

Root + Impact

Description

  • The MerkleAirdrop contract is designed to distribute tokens to eligible users over a claiming period, with the expectation that the project may need to recover any unclaimed tokens after the airdrop concludes for reallocation or treasury return.

  • The contract provides no mechanism for the owner to withdraw unclaimed tokens after the airdrop period ends, permanently locking any tokens that users fail to claim due to lost private keys, lack of awareness, or disinterest in the airdrop.

contract MerkleAirdrop is Ownable {
using SafeERC20 for IERC20;
// ... state variables ...
constructor(bytes32 merkleRoot, IERC20 airdropToken) Ownable(msg.sender) {
i_merkleRoot = merkleRoot;
i_airdropToken = airdropToken;
}
function claim(address account, uint256 amount, bytes32[] calldata merkleProof) external payable {
// ... claim logic ...
}
function claimFees() external onlyOwner {
// @> Only allows withdrawing ETH fees, not the airdrop tokens
(bool succ,) = payable(owner()).call{ value: address(this).balance }("");
if (!succ) {
revert MerkleAirdrop__TransferFailed();
}
}
// @> No function exists to withdraw unclaimed ERC20 tokens
// @> No function to end the airdrop period
// @> No expiry timestamp to enable token recovery
// @> Tokens remain locked forever if not claimed
}

Risk

Likelihood:

  • Users lose private keys for their eligible addresses, making it impossible for them to claim their allocation

  • Users remain unaware of the airdrop despite announcement efforts, leaving their allocation unclaimed indefinitely

  • The airdrop concludes after a reasonable claiming period (30-90 days), but significant portions remain unclaimed

  • Historical data shows 10-30% of airdrop allocations typically go unclaimed in real-world distributions

Impact:

  • Permanent capital inefficiency as unclaimed tokens are locked in the contract forever with no recovery mechanism

  • Project cannot reallocate unclaimed tokens to a new airdrop round or distribute them to active community members

  • Loss of treasury assets that could otherwise be used for protocol development, liquidity provision, or future incentive programs

  • Poor user experience for the project team who must deploy a new contract and repeat the entire process for subsequent airdrops

Proof of Concept

function testUnclaimedTokensPermanentlyLocked() public {
// Initial setup: Contract has 100 USDC for 4 users (25 each)
uint256 totalAirdrop = 100 * 1e6;
assertEq(token.balanceOf(address(airdrop)), totalAirdrop);
// Simulate realistic scenario: Only 2 out of 4 users claim
vm.deal(collectorOne, airdrop.getFee());
vm.prank(collectorOne);
airdrop.claim{value: airdrop.getFee()}(collectorOne, amountToCollect, proof);
// Another user claims (simulate with second set of proofs)
// ... second user claims 25 USDC ...
// Fast forward 6 months - airdrop period is over
vm.warp(block.timestamp + 180 days);
// 50 USDC remains unclaimed (2 users never claimed)
uint256 unclaimedAmount = token.balanceOf(address(airdrop));
assertEq(unclaimedAmount, 50 * 1e6);
// Owner tries to recover unclaimed tokens
vm.prank(airdrop.owner());
// No function exists to withdraw tokens!
// vm.expectRevert(); // Would fail - function doesn't exist
// airdrop.withdrawUnclaimedTokens();
// Tokens are permanently stuck in contract
assertEq(token.balanceOf(address(airdrop)), 50 * 1e6);
// Owner cannot:
// - Return tokens to treasury
// - Start new airdrop with these tokens
// - Donate to community
// Tokens are lost forever
}

Recommended Mitigation

Add a time-locked withdrawal function to allow owner to recover unclaimed tokens after a reasonable period:

contract MerkleAirdrop is Ownable {
using SafeERC20 for IERC20;
error MerkleAirdrop__InvalidFeeAmount();
error MerkleAirdrop__InvalidProof();
error MerkleAirdrop__TransferFailed();
+ error MerkleAirdrop__AirdropNotExpired();
uint256 private constant FEE = 1e9;
IERC20 private immutable i_airdropToken;
bytes32 private immutable i_merkleRoot;
+ uint256 private immutable i_expiryTime;
event Claimed(address account, uint256 amount);
event MerkleRootUpdated(bytes32 newMerkleRoot);
- constructor(bytes32 merkleRoot, IERC20 airdropToken) Ownable(msg.sender) {
+ constructor(bytes32 merkleRoot, IERC20 airdropToken, uint256 expiryTime) Ownable(msg.sender) {
i_merkleRoot = merkleRoot;
i_airdropToken = airdropToken;
+ i_expiryTime = expiryTime; // e.g., block.timestamp + 90 days
}
function claim(address account, uint256 amount, bytes32[] calldata merkleProof) external payable {
//...
}
function claimFees() external onlyOwner {
//...
}
+ /// @notice Allows owner to withdraw unclaimed tokens after expiry
+ /// @dev Can only be called after the airdrop expiry time has passed
+ function withdrawUnclaimedTokens() external onlyOwner {
+ if (block.timestamp < i_expiryTime) {
+ revert MerkleAirdrop__AirdropNotExpired();
+ }
+ uint256 balance = i_airdropToken.balanceOf(address(this));
+ i_airdropToken.safeTransfer(owner(), balance);
+ }
+ function getExpiryTime() external view returns (uint256) {
+ return i_expiryTime;
+ }
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 3 days 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!