AirDropper

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

Fee of 1 gwei is Economically Impractical for Owner to Collect

Root + Impact

Description

The claim() function requires users to pay a 1 gwei (1e9 wei) fee per claim, which the owner can collect via claimFees(). However, with only 4 eligible addresses, the maximum collectible fees (4 gwei) are significantly less than the gas cost required to execute the withdrawal transaction.​

The Issue:

  1. Fee per claim: 1 gwei (1e9 wei)

  2. Maximum claims: 4 users

  3. Maximum total fees: 4 gwei (4e9 wei)

  4. Gas cost to call claimFees(): ~30,479 gas

  5. zkSync Era average gas price: ~0.02 gwei

  6. Transaction cost: 30,479 × 0.02 = ~609 gwei

  7. Net loss: 609 - 4 = 605 gwei (152x more expensive to collect than value collected)

What Happens:

  • Owner calls claimFees() to withdraw accumulated fees

  • Transaction costs ~609 gwei in gas

  • Owner receives only 4 gwei in fees

  • Owner loses 605 gwei net (152x loss)

Economic Incentives:

The fee mechanism creates a perverse incentive where:

  • Collecting fees is economically irrational

  • Fees remain locked in the contract forever

  • The fee serves no practical purpose except adding friction to claims

This is a Revenue Mechanism Design Flaw where the fee structure fails basic economic viability testing, making the feature entirely non-functional in practice.

// File: MerkleAirdrop.sol
contract MerkleAirdrop is Ownable {
using SafeERC20 for IERC20;
error MerkleAirdrop__InvalidFeeAmount();
error MerkleAirdrop__InvalidProof();
error MerkleAirdrop__TransferFailed();
@> uint256 private constant FEE = 1e9; // 1 gwei - ECONOMICALLY UNVIABLE
IERC20 private immutable i_airdropToken;
bytes32 private immutable i_merkleRoot;
event Claimed(address account, uint256 amount);
event MerkleRootUpdated(bytes32 newMerkleRoot);
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 {
@> if (msg.value != FEE) { // Forces users to pay 1 gwei
revert MerkleAirdrop__InvalidFeeAmount();
}
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(account, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert MerkleAirdrop__InvalidProof();
}
emit Claimed(account, amount);
i_airdropToken.safeTransfer(account, amount);
}
@> /// @notice Withdraw accumulated ETH fees - ECONOMICALLY IRRATIONAL TO CALL
function claimFees() external onlyOwner {
(bool succ,) = payable(owner()).call{ value: address(this).balance }("");
if (!succ) {
revert MerkleAirdrop__TransferFailed();
}
}
function getMerkleRoot() external view returns (bytes32) {
return i_merkleRoot;
}
function getAirdropToken() external view returns (IERC20) {
return i_airdropToken;
}
function getFee() external pure returns (uint256) {
return FEE;
}
}

The root cause is the hardcoded fee amount (1 gwei) combined with the small number of eligible users (4). The constant fee was likely chosen arbitrarily without economic analysis of:

  • Total addressable fee revenue (4 × 1 gwei = 4 gwei)

  • Gas cost to withdraw fees (~609 gwei on zkSync Era)

  • Net economic outcome (605 gwei loss per withdrawal)

The fee mechanism creates deadweight loss with no benefit to either party.​


Risk

Likelihood:

  • The economic calculation is deterministic. With exactly 4 eligible users and a fixed 1 gwei fee, the maximum collectible fees are precisely 4 gwei. The gas cost to execute claimFees() on zkSync Era is measurable (~30,479 gas). At any realistic gas price (>0.00013 gwei), calling claimFees() results in a net loss for the owner.

  • Any rational owner who calculates the economics will never call claimFees(). The 4 gwei in fees will remain permanently locked in the contract. Users pay the fee (adding friction to claims) but the fee serves no economic purpose since it cannot be profitably collected.

Impact:

  • Dead code with minor user friction. The fee mechanism is economically non-functional, making claimFees() effectively dead code. Users must pay 4 gwei total in fees that benefit no one - the fees are trapped in the contract forever, creating pure deadweight loss. This adds unnecessary complexity and user friction for zero benefit.

  • Design intent failure. If the fee was intended to generate revenue, prevent spam, or fund operations, it completely fails these goals. The mechanism suggests incomplete economic analysis during development. While the monetary impact is negligible (~4 gwei), it indicates poor design practices that could manifest more severely in scaled versions of this protocol.​

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {MerkleAirdrop} from "../src/MerkleAirdrop.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol";
contract FeeEconomicsTest is Test {
MerkleAirdrop airdrop;
ERC20Mock usdc;
address owner = makeAddr("owner");
address user1 = makeAddr("user1");
address user2 = makeAddr("user2");
address user3 = makeAddr("user3");
address user4 = makeAddr("user4");
uint256 constant FEE = 1e9; // 1 gwei
bytes32 merkleRoot = keccak256("test");
function setUp() public {
usdc = new ERC20Mock();
vm.prank(owner);
airdrop = new MerkleAirdrop(merkleRoot, IERC20(address(usdc)));
usdc.mint(address(airdrop), 100 * 1e6);
}
function test_FeeEconomics_NetLoss() public {
console.log("\n=== FEE ECONOMICS ANALYSIS ===");
console.log("");
// Step 1: Calculate maximum collectible fees
uint256 maxUsers = 4;
uint256 feePerUser = FEE;
uint256 maxTotalFees = maxUsers * feePerUser;
console.log("Fee Structure:");
console.log(" Fee per claim:", feePerUser / 1e9, "gwei");
console.log(" Max eligible users:", maxUsers);
console.log(" Max total fees:", maxTotalFees / 1e9, "gwei");
console.log("");
// Step 2: Simulate all users claiming (fees accumulate)
vm.deal(user1, 1 ether);
vm.deal(user2, 1 ether);
vm.deal(user3, 1 ether);
vm.deal(user4, 1 ether);
// Note: Claims would revert due to other vulnerabilities
// So we manually send fees to demonstrate the economics
vm.prank(user1);
(bool s1,) = address(airdrop).call{value: FEE}("");
require(s1);
vm.prank(user2);
(bool s2,) = address(airdrop).call{value: FEE}("");
require(s2);
vm.prank(user3);
(bool s3,) = address(airdrop).call{value: FEE}("");
require(s3);
vm.prank(user4);
(bool s4,) = address(airdrop).call{value: FEE}("");
require(s4);
uint256 contractBalance = address(airdrop).balance;
console.log("Contract accumulated fees:", contractBalance / 1e9, "gwei");
console.log("");
// Step 3: Measure gas cost to withdraw fees
uint256 ownerBalanceBefore = owner.balance;
uint256 gasPriceBefore = tx.gasprice;
vm.txGasPrice(0.02 gwei); // zkSync Era average gas price
uint256 gasPrice = tx.gasprice;
vm.prank(owner);
uint256 gasBefore = gasleft();
airdrop.claimFees();
uint256 gasUsed = gasBefore - gasleft();
uint256 ownerBalanceAfter = owner.balance;
uint256 feesCollected = ownerBalanceAfter - ownerBalanceBefore;
uint256 gasCost = gasUsed * gasPrice;
console.log("Withdrawal Transaction:");
console.log(" Gas used:", gasUsed);
console.log(" Gas price:", gasPrice / 1e9, "gwei");
console.log(" Gas cost:", gasCost / 1e9, "gwei");
console.log(" Fees collected:", feesCollected / 1e9, "gwei");
console.log("");
// Step 4: Calculate net outcome
int256 netOutcome = int256(feesCollected) - int256(gasCost);
uint256 lossMultiple = gasCost / feesCollected;
console.log("=== ECONOMIC OUTCOME ===");
console.log(" Net profit/loss:", netOutcome > 0 ? "+" : "", netOutcome / 1e9, "gwei");
console.log(" Loss multiple:", lossMultiple, "x");
console.log("");
// Assertions
assertLt(feesCollected, gasCost, "Collecting fees is economically unprofitable");
assertGt(lossMultiple, 100, "Loss is >100x the collected fees");
console.log("[CONCLUSION] Owner loses", uint256(-netOutcome) / 1e9, "gwei by calling claimFees()");
console.log("[RATIONAL BEHAVIOR] Owner will never call claimFees()");
console.log("[RESULT] 4 gwei permanently locked in contract");
}
function test_FeeEconomics_BreakevenAnalysis() public view {
console.log("\n=== BREAKEVEN ANALYSIS ===");
console.log("");
// What fee would be needed to break even?
uint256 gasUsed = 30479; // Approximate from previous test
uint256 gasPrice = 0.02 gwei;
uint256 gasCost = gasUsed * gasPrice;
uint256 maxUsers = 4;
uint256 breakevenFeePerUser = gasCost / maxUsers;
uint256 currentFee = FEE;
uint256 requiredMultiple = breakevenFeePerUser / currentFee;
console.log("Current Situation:");
console.log(" Gas cost:", gasCost / 1e9, "gwei");
console.log(" Current fee per user:", currentFee / 1e9, "gwei");
console.log(" Users:", maxUsers);
console.log(" Total fees:", (currentFee * maxUsers) / 1e9, "gwei");
console.log("");
console.log("Breakeven Analysis:");
console.log(" Required fee per user:", breakevenFeePerUser / 1e9, "gwei");
console.log(" Current fee per user:", currentFee / 1e9, "gwei");
console.log(" Required increase:", requiredMultiple, "x");
console.log("");
console.log("[CONCLUSION] Fee must increase", requiredMultiple, "x to break even");
console.log("[ALTERNATIVE] Remove fee mechanism entirely");
}
function test_FeeEconomics_AlternativeScenarios() public view {
console.log("\n=== ALTERNATIVE SCENARIOS ===");
console.log("");
uint256 gasUsed = 30479;
uint256 gasPrice = 0.02 gwei;
uint256 gasCost = gasUsed * gasPrice;
// Scenario 1: 10x more users
uint256 users10x = 40;
uint256 fees10x = users10x * FEE;
int256 net10x = int256(fees10x) - int256(gasCost);
console.log("Scenario 1: 10x more users (40 users)");
console.log(" Total fees:", fees10x / 1e9, "gwei");
console.log(" Gas cost:", gasCost / 1e9, "gwei");
console.log(" Net outcome:", net10x > 0 ? "+" : "", net10x / 1e9, "gwei");
console.log(" Viable:", net10x > 0 ? "YES" : "NO");
console.log("");
// Scenario 2: 100x more users
uint256 users100x = 400;
uint256 fees100x = users100x * FEE;
int256 net100x = int256(fees100x) - int256(gasCost);
console.log("Scenario 2: 100x more users (400 users)");
console.log(" Total fees:", fees100x / 1e9, "gwei");
console.log(" Gas cost:", gasCost / 1e9, "gwei");
console.log(" Net outcome:", net100x > 0 ? "+" : "", net100x / 1e9, "gwei");
console.log(" Viable:", net100x > 0 ? "YES" : "NO");
console.log("");
// Scenario 3: Higher fee (0.001 ETH)
uint256 higherFee = 0.001 ether;
uint256 feesWithHigherFee = 4 * higherFee;
int256 netHigherFee = int256(feesWithHigherFee) - int256(gasCost);
console.log("Scenario 3: Higher fee (0.001 ETH per claim)");
console.log(" Total fees:", feesWithHigherFee / 1e18, "ETH");
console.log(" Gas cost:", gasCost / 1e9, "gwei");
console.log(" Net outcome:", netHigherFee > 0 ? "+" : "", netHigherFee / 1e9, "gwei");
console.log(" Viable:", netHigherFee > 0 ? "YES" : "NO");
console.log("");
console.log("[CONCLUSION] Fee mechanism only viable with:");
console.log(" - 100x more users, OR");
console.log(" - 152x higher fee per user");
}
function test_FeeImpactOnUsers() public view {
console.log("\n=== USER IMPACT ===");
console.log("");
uint256 airdropAmount = 25 * 1e6; // 25 USDC
uint256 usdcPrice = 1e18; // $1 per USDC (for simplicity)
uint256 ethPrice = 3000e18; // $3000 per ETH
uint256 airdropValueUSD = (airdropAmount * usdcPrice) / 1e6;
uint256 feeValueUSD = (FEE * ethPrice) / 1e18;
uint256 feePercentage = (feeValueUSD * 10000) / airdropValueUSD;
console.log("User receives:", airdropAmount / 1e6, "USDC ($", airdropValueUSD / 1e18, ")");
console.log("User pays fee:", FEE / 1e9, "gwei ($", feeValueUSD / 1e18, ")");
console.log("Fee as % of airdrop:", feePercentage / 100, ".", feePercentage % 100, "%");
console.log("");
console.log("[ANALYSIS] Fee is negligible for users (~0.0001% of airdrop)");
console.log("[PROBLEM] Fee creates friction with zero benefit");
console.log("[RECOMMENDATION] Remove fee mechanism entirely");
}
}

Recommended Mitigation

// File: MerkleAirdrop.sol **Remove fee mechanism entirely

// File: MerkleAirdrop.sol
contract MerkleAirdrop is Ownable {
using SafeERC20 for IERC20;
error MerkleAirdrop__InvalidFeeAmount();
error MerkleAirdrop__InvalidProof();
- error MerkleAirdrop__TransferFailed();
- uint256 private constant FEE = 1e9;
IERC20 private immutable i_airdropToken;
bytes32 private immutable i_merkleRoot;
event Claimed(address account, uint256 amount);
event MerkleRootUpdated(bytes32 newMerkleRoot);
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 {
+ function claim(address account, uint256 amount, bytes32[] calldata merkleProof) external {
- if (msg.value != FEE) {
- revert MerkleAirdrop__InvalidFeeAmount();
- }
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(account, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert MerkleAirdrop__InvalidProof();
}
emit Claimed(account, amount);
i_airdropToken.safeTransfer(account, amount);
}
- function claimFees() external onlyOwner {
- (bool succ,) = payable(owner()).call{ value: address(this).balance }("");
- if (!succ) {
- revert MerkleAirdrop__TransferFailed();
- }
- }
function getMerkleRoot() external view returns (bytes32) {
return i_merkleRoot;
}
function getAirdropToken() external view returns (IERC20) {
return i_airdropToken;
}
- function getFee() external pure returns (uint256) {
- return FEE;
- }
}
Updates

Lead Judging Commences

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