AirDropper

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

claimFees() sends accumulated ETH to owner() via a low-level call with no fallback; if owner is a non-payable contract, all fee ETH is permanently locked

Root + Impact

Description

  • MerkleAirdrop.claimFees() collects all ETH held by the contract and forwards it to owner() using a low-level .call{value: balance}(""). If the call reverts, the function reverts with MerkleAirdrop__TransferFailed() — and the ETH stays in the contract.

  • On zkSync Era, native Account Abstraction means deployers are frequently smart contract accounts (multisigs, AA wallets, deployer scripts). Many of these do not implement receive() or fallback() with payable. If owner() is any such contract, every call to claimFees() will revert, and the accumulated ETH fee balance is permanently unreachable — the contract holds it with no alternative withdrawal path.

// src/MerkleAirdrop.sol
function claimFees() external onlyOwner {
(bool succ,) = payable(owner()).call{ value: address(this).balance }("");
if (!succ) {
revert MerkleAirdrop__TransferFailed(); // @> reverts but ETH stays locked
}
// @> no alternative recipient, no partial withdrawal, no rescue path
}

Risk

Likelihood:

  • zkSync Era natively encourages smart contract accounts. A deployer script, Gnosis Safe, or any AA wallet without a receive() function will trigger this bug on every claimFees() call.

  • Even on standard EVM chains, multisig owners that reject raw ETH are common.

Impact:

  • All ETH accumulated from claim fees is permanently locked. For an airdrop with many recipients (e.g., 1,000 claims at 1 gwei each = 1,000 gwei), the loss scales linearly.

  • The owner cannot recover the ETH short of a full contract redeployment (which also requires a new Merkle root and token refund).

Proof of Concept

The test deploys a NonPayableOwner contract as the owner of the airdrop. Alice claims and pays the fee. When claimFees() is called, the low-level ETH transfer to the non-payable owner reverts, locking the fee permanently in the airdrop contract.

contract NonPayableOwner {
// No receive() — rejects raw ETH
function callClaimFees(MerkleAirdrop airdrop) external {
airdrop.claimFees();
}
}
function testClaimFeesLockedForNonPayableOwner() public {
// Deploy airdrop owned by a non-payable contract
NonPayableOwner npo = new NonPayableOwner();
MerkleAirdrop lockedAirdrop = new MerkleAirdrop(ROOT, IERC20(address(token)));
// Transfer ownership to the non-payable contract
lockedAirdrop.transferOwnership(address(npo));
// Fund the airdrop and let Alice claim (paying the fee)
token.mint(address(lockedAirdrop), ALICE_AMOUNT);
vm.deal(alice, FEE);
vm.prank(alice);
lockedAirdrop.claim{value: FEE}(alice, ALICE_AMOUNT, aliceProof);
// Fee is in the contract
assertEq(address(lockedAirdrop).balance, FEE);
// Owner tries to collect — reverts because NonPayableOwner has no receive()
vm.expectRevert(MerkleAirdrop.MerkleAirdrop__TransferFailed.selector);
npo.callClaimFees(lockedAirdrop);
// Fee remains locked — no alternative path exists
assertEq(address(lockedAirdrop).balance, FEE);
}

The fee paid by Alice cannot be retrieved. With many claimants the trapped balance scales proportionally, and there is no rescue function.

Recommended Mitigation

Accept an explicit to address in claimFees() so the owner can direct ETH to a known-payable recipient:

- function claimFees() external onlyOwner {
- (bool succ,) = payable(owner()).call{ value: address(this).balance }("");
- if (!succ) { revert MerkleAirdrop__TransferFailed(); }
+ function claimFees(address payable to) external onlyOwner {
+ require(to != address(0), "zero address");
+ (bool succ,) = to.call{ value: address(this).balance }("");
+ if (!succ) { revert MerkleAirdrop__TransferFailed(); }
}

This lets the owner specify an EOA or any contract known to accept ETH, avoiding permanent lockup regardless of what type of account holds ownership.

Updates

Lead Judging Commences

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