AirDropper

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

Wrong USDC decimals in the Merkle tree make the airdrop permanently unclaimable and lock all 100 USDC

Description

makeMerkle.js builds the Merkle tree with amount = (25 * 1e18).toString() (18 decimals), but the airdrop token is USDC on zkSync Era, which has 6 decimals and only 4 * (25 * 1e6) = 100e6 is ever funded (Deploy.s.sol:11). The deployed root 0xf69aaa25... therefore commits each winner to 25e18 = 25 trillion USDC units, while the contract holds at most 100e6. Every claim requires proof for amount = 25e18; a winner passing the real 25 USDC (25e6) fails MerkleProof.verify, and passing 25e18 requires a balance the contract can never have. The airdrop is entirely unclaimable and the funded USDC is stuck.

Vulnerability Details

  • makeMerkle.js:7const amount = (25 * 1e18).toString() (out of scope, but the docs state it generates the root used by Deploy.s.sol).

  • Deploy.s.sol:9s_merkleRoot = 0xf69aaa25... reproduces the 25e18 tree root exactly.

  • Deploy.s.sol:11s_amountToAirdrop = 4 * (25 * 1e6) = 100e6 (6-decimal USDC).

  • MerkleAirdrop.sol:30-40claim(account, amount, proof) verifies amount against i_merkleRoot. With the deployed root, valid amount must be 25e18.

Magnitude: a single claim at 25e18 requires 2.5e11× the entire funded balance (100e6); all four claims need 1e12×. The contract can never satisfy a valid claim.

Root confirmed reproducible: node makeMerkle.js0xf69aaa25bd4dd10deb2ccd8235266f7cc815f6e9d539e9f4d47cae16e0c36a05 (matches Deploy.s.sol:9).

Impact

  • The airdrop is 100% non-functional: no winner can ever claim their 25 USDC.

  • The entire 100e6 USDC is permanently locked in the contract. The only ETH-withdrawal path is claimFees (owner-only, ETH only); there is no function to recover airdrop tokens. Funds are lost.

Proof of Concept

Passing Forge tests:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import { Test } from "forge-std/Test.sol";
import { console2 } from "forge-std/console2.sol";
import { MerkleAirdrop } from "../src/MerkleAirdrop.sol";
import { AirdropToken } from "./mocks/AirdropToken.sol";
contract MyAudit is Test {
MerkleAirdrop public airdrop;
AirdropToken public token;
bytes32 private constant DEPLOYED_ROOT =
0xf69aaa25bd4dd10deb2ccd8235266f7cc815f6e9d539e9f4d47cae16e0c36a05;
// leaf amount committed in the DEPLOYED tree (25e18)
uint256 private constant TREE_AMT = 25 * 1e18;
uint256 private constant FEE = 1e9;
address private a1 = 0x20F41376c713072937eb02Be70ee1eD0D639966C;
address private a2 = 0x277D26a45Add5775F21256159F089769892CEa5B;
address private a3 = 0x0c8Ca207e27a1a8224D1b602bf856479b03319e7;
address private a4 = 0xf6dBa02C01AF48Cf926579F77C9f874Ca640D91D;
bytes32[] internal p1 = [
bytes32(0x4fd31fee0e75780cd67704fbc43caee70fddcaa43631e2e1bc9fb233fada2394),
bytes32(0xc88d18957ad6849229355580c1bde5de3ae3b78024db2e6c2a9ad674f7b59f84)
];
bytes32[] internal p2 = [
bytes32(0xa10b5ae53077397fbf8f4a7509073ea8141f0709d7d6f5ac6b77d1f94e3d2456),
bytes32(0x4886895f1e3f66692dcdc184b4d644b690c617a17ae16b8b42a69b5f408d3012)
];
receive() external payable {}
function setUp() public {
token = new AirdropToken();
airdrop = new MerkleAirdrop(DEPLOYED_ROOT, token);
}
function test_F1_deployedAirdropIsUnclaimable() public {
// Exactly mirror Deploy.s.sol funding
token.mint(address(this), 4 * 25e6);
token.transfer(address(airdrop), 4 * 25e6);
vm.deal(a1, FEE);
vm.prank(a1);
// Even with the tiny 25e6 "intended" amount... proof needs 25e18, so:
// amount=25e18 -> revert (balance) ; amount=25e6 -> revert (proof)
vm.expectRevert();
airdrop.claim{ value: FEE }(a1, TREE_AMT, p1);
}
-----------
function test_F1b_usdcLocked_ownerCannotRecover() public {
token.mint(address(this), 100e6);
token.transfer(address(airdrop), 100e6);
// owner pulls ETH fees only
airdrop.claimFees();
assertEq(token.balanceOf(address(airdrop)), 100e6, "USDC still locked in contract");
assertEq(token.balanceOf(airdrop.owner()), 0, "owner got no USDC");
}
-------------
function test_zeroAmountFailsProof() public {
_fundTree();
vm.deal(a1, FEE);
vm.prank(a1);
vm.expectRevert();
airdrop.claim{ value: FEE }(a1, 0, p1);
}
function _fundTree() internal {
token.mint(address(this), TREE_AMT);
token.transfer(address(airdrop), TREE_AMT);
}
}

Tools Used

Manual review; node makeMerkle.js (root/leaf reproduction); Foundry PoCs.

Recommendations

Fix the off-chain amount to 6 decimals and rebuild the tree + root:

- const amount = (25 * 1e18).toString()
+ const amount = (25 * 1e6).toString()

Update Deploy.s.sol:9 to the regenerated root and fund the matching balance. As hardening, cross-check root ↔ token decimals at deploy time.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 30 minutes 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!