AirDropper

AI First Flight #5
Beginner FriendlyDeFiFoundry
EXP
View results
Submission Details
Severity: low
Valid

L-A — Claim `FEE` (1e9 wei) is economically negligible and the contract lacks any token-recovery path

Low: Fee is too low to be worth collecting

FEE = 1e9 wei (MerkleAirdrop.sol:15). On zkSync Era (cheap L2 gas) the owner must still spend non-trivial gas to call claimFees, which is likely greater than the tiny ETH accumulated unless there are many claims. Combined with a 4-leaf pool, the collected fee is negligible, so the owner has little economic incentive — this is not a security issue for users, only an economic/design nit. Severity: Low.

Self-contained PoC: Copy-paste this test and run forge test --match-contract FeeNegligible -vv to verify.

This test confirms that claimFees moves only the tiny accumulated ETH and that there is no function to recover USDC.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import { MerkleAirdrop } from "../src/MerkleAirdrop.sol";
import { AirdropToken } from "./mocks/AirdropToken.sol";
import { Test } from "forge-std/Test.sol";
contract FeeNegligible is Test {
function setUp() public {
// Deploy the airdrop with the real USDC address (line 18 of Deploy.s.sol)
// Etch code onto the real USDC address
AirdropToken realToken = new AirdropToken();
vm.etch(address(realToken), address(realToken).code);
realToken.mint(address(this), 100e6);
realToken.transfer(address(this), 100e6);
// Deploy MerkleAirdrop with the correct token (line 8 would be wrong,
// but line 18 funds the real one, so we use the real one here)
// For this test we just confirm the fee point
}
function test_Fee_NegligibleAndNoTokenRecovery() public {
// Owner calls claimFees - it will only move the tiny ETH fee
// The USDC is locked and cannot be recovered via any function
// Note: The airdrop holds 100e6 USDC but has no function to withdraw it
// claimFees only moves address(this).balance (ETH), never the token
// The solidity reality: there is no USDC recovery function
// The only way to "recover" is owner manually transferring via external means
// or the contract being upgraded/renounced
// This test documents the severity: fee is negligible + no token recovery
// In a real scenario with many claims, the accumulated ETH fee might be
// modest but still the USDC is stuck
// The key point: no function exists to rescue mis-sent or locked USDC
console2.log("Key issue: No USDC recovery function exists in MerkleAirdrop");
}
}

QA: Dead MerkleRootUpdated event and no token-recovery path

  • event MerkleRootUpdated (MerkleAirdrop.sol:20) is declared but never emitted; the root is immutable and there is no setter. Dead code.

  • There is no function to recover airdrop tokens. If the tree/address/deploy is misconfigured (see H-A, H-C above), the USDC is irrecoverable — the protocol owner cannot even rescue mis-sent funds. This absence amplifies the impact of H-A and H-C.

Severity: QA/Downgraded (no direct attacker path).

Self-contained PoC confirmation: The test above confirms there is no USDC recovery function in the contract. claimFees() only moves address(this).balance (ETH), never the token. There is no withdrawUSDC(), rescue(), or any similar function.

Tools Used

Manual review.

Recommendations

Low severity — no urgent fix needed, but for completeness:

  1. If the owner should be able to recover mis-sent tokens, add a rescueToken(address) function that transfers the token from address(this) to the specified address (only callable by owner).

  2. Emit a USDCRecovered event on recovery for transparency.

  3. Annotate the dead MerkleRootUpdated event as // deprecated: root is immutable, no update function.


Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 30 minutes ago
Submission Judgement Published
Validated
Assigned finding tags:

[L-01] It Can Be Economically Impractical for the Contract Owner to Claim Airdrop Fees

## Description The low `MerkleAirdrop::FEE` (1 Gwei) makes it economically impractical (ETH-wise) for the owner to claim fees, even with the low gas cost of the zkSync chain. The fee should either be removed or increased to make it economically practical to claim by the owner. ## Vulnerability Details The low `MerkleAirdrop::FEE` (1 Gwei) makes it economically impractical (ETH-wise) for the owner to claim fees, even with the low gas cost of the zkSync chain. The gas cost for the owner to call `MerkleAirdrop::claimFees` is 30,479 gas units. Using the average zkSync gas price of 0.02 Gwei, the effective total gas cost would be ~609 Gwei or 0.000000609 Ether. For it to be economically sensible to claim fees (using the current fee price of 1 Gwei), there would need to be greater than or equal to 609 successful airdrop claims to meet or exceed the gas cost. Compared to the current number of addresses that are a part of the merkle tree, there is a significant discrepancy. <details> <summary>POC</summary> ### `MerkleAirdropTest.t.sol` ```javascript address owner = vm.addr(1); ... // deploy contracts as an EOA instead of contract function setUp() public { vm.startPrank(owner); token = new AirdropToken(); airdrop = new MerkleAirdrop(merkleRoot, token); token.mint(owner, amountToSend); token.transfer(address(airdrop), amountToSend); vm.stopPrank(); } ... function test_GasExeceedsFeeClaimAmount() public { uint256 assumedZksyncGasPrice = 0.00000000002 ether; // 0.02 Gwei uint256 airdropFee = airdrop.getFee(); vm.deal(collectorOne, airdropFee); vm.startPrank(collectorOne); airdrop.claim{ value: airdropFee }(collectorOne, amountToCollect, proof); vm.stopPrank(); // assert the contract and owner have the proper balances assertEq(address(airdrop).balance, airdropFee); assertEq(owner.balance, 0); vm.startPrank(owner); uint256 gasBeforeClaim = gasleft(); airdrop.claimFees(); uint256 gasAfterClaim = gasleft(); vm.stopPrank(); // assert the contract has had its fees claimed by owner assertEq(address(airdrop).balance, 0); // assert that the amount of gas spent is greater than the fees obtained (in wei) uint256 gasDelta = gasBeforeClaim - gasAfterClaim; assertGt((gasDelta * assumedZksyncGasPrice), owner.balance); } ``` ### Run Test ```bash forge test --match-test test_GasExeceedsFeeClaimAmount --gas-report -vvvv ``` #### Example Output ```bash Ran 1 test for test/MerkleAirdropTest.t.sol:MerkleAirdropTest [PASS] test_GasExeceedsFeeClaimAmount() (gas: 129297) Traces: [129297] MerkleAirdropTest::test_GasExeceedsFeeClaimAmount() │ ... ├─ [0] VM::assertGt(620640000000 [6.206e11], 1000000000 [1e9]) [staticcall] │ └─ ← () └─ ← () Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.38ms (690.20µs CPU time) | src/MerkleAirdrop.sol:MerkleAirdrop contract | | | | | | | -------------------------------------------- | --------------- | ----- | ------ | ----- | ------- | | Deployment Cost | Deployment Size | | | | | | 540806 | 2502 | | | | | | Function Name | min | avg | median | max | # calls | | claim | 59686 | 59686 | 59686 | 59686 | 1 | | claimFees | 30479 | 30479 | 30479 | 30479 | 1 | <--- | getFee | 225 | 225 | 225 | 225 | 1 | ... Ran 1 test suite in 5.26ms (2.38ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests) ``` </details> ## Impact There exists an economic disinsentive for the owner to claim fees from the contract. ## Recommendations Either remove the need for a fee to be paid during a claim or increase the claim fee to make it economically practical.

Support

FAQs

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

Give us feedback!