AirDropper

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

No mechanism to recover unclaimed airdrop tokens: claimFees only withdraws ETH, so leftover ERC20 is permanently locked

Root + Impact

Description

The contract receives airdrop tokens (the owner funds it by transferring the ERC20 in), and distributes them via claim. The only withdrawal function is claimFees, which moves the contract's ETH balance to the owner:

function claimFees() external onlyOwner {
@> (bool succ,) = payable(owner()).call{ value: address(this).balance }(""); // ETH only
if (!succ) revert MerkleAirdrop__TransferFailed();
}

There is no function anywhere that transfers i_airdropToken out of the contract except via a valid claim. If some recipients never claim (very common for airdrops), their portion of the ERC20 remains in the contract with no recovery path - the owner cannot sweep it, and no one else can move it. The unclaimed tokens are permanently locked.

Risk

Likelihood: Medium - airdrops routinely have unclaimed allocations (lost keys, inactive users), so leftover tokens are the expected end state.

Impact: Medium - the unclaimed ERC20 is irrecoverable; protocol/owner cannot reclaim or redistribute it. No user funds already claimed are at risk, but the residual is a permanent loss.

Proof of Concept

After one legitimate claim, three recipients' worth of tokens remain. claimFees withdraws only ETH and leaves the tokens stuck, and no other recovery function exists. Runnable Foundry test (add to MerkleAirdropTest.t.sol):

function test_PoC_unclaimedTokensLocked() public {
// one legitimate claim happens
vm.deal(collectorOne, airdrop.getFee());
vm.prank(collectorOne);
airdrop.claim{value: airdrop.getFee()}(collectorOne, amountToCollect, proof);
uint256 stuck = token.balanceOf(address(airdrop)); // 3 recipients' worth remain
assertGt(stuck, 0);
// owner withdraws fees -> only ETH leaves; airdrop tokens are untouched
airdrop.claimFees(); // this test contract is the owner (it deployed the airdrop)
// tokens are still stuck with no way to recover them
assertEq(token.balanceOf(address(airdrop)), stuck);
}

Run forge test --mt test_PoC_unclaimedTokensLocked -vv; it passes - claimFees does not recover the ERC20 and no other path exists.

Recommended Mitigation

Add an owner-only function to recover leftover airdrop tokens (e.g. after a deadline), so unclaimed allocations are not stranded:

+ function recoverUnclaimedTokens(address to) external onlyOwner {
+ uint256 remaining = i_airdropToken.balanceOf(address(this));
+ i_airdropToken.safeTransfer(to, remaining);
+ }

(optionally gate this behind a claim-window expiry so it cannot be used to rug active claimants).

Updates

Lead Judging Commences

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