AirDropper

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

makeMerkle.js encodes 25e18 into merkle leaves while Deploy.s.sol funds the contract with only 25e6 USDC, making every claim permanently revert

Root + Impact

Normal behavior

Each of the 4 eligible addresses should be able to call claim() and receive 25 USDC. The merkle tree encodes the eligible amount per address, and the contract must hold at least that amount to fulfill each transfer.

Description

The issue

There is a critical decimal mismatch between the amount encoded in the merkle tree leaves and the amount actually deposited into the contract. makeMerkle.js uses 1e18 as the decimal multiplier (ETH convention), while USDC has only 6 decimal places. Deploy.s.sol correctly uses 1e6 for the funding amount but never corrects the tree.

// makeMerkle.js — builds the merkle tree
// @> Uses 1e18: encodes 25_000_000_000_000_000_000 into every leaf
const amount = (25 * 1e18).toString()
// Deploy.s.sol — funds the airdrop contract
// @> Uses 1e6: deposits only 100_000_000 tokens (100 USDC with 6 decimals)
uint256 public s_amountToAirdrop = 4 * (25 * 1e6);

When any user calls claim(), the verified amount from the leaf is 25_000_000_000_000_000_000. The contract calls safeTransfer(account, 25_000_000_000_000_000_000) against a balance of 100_000_000. This reverts every time. The airdrop is completely non-functional on real deployment.

The existing test suite masks this bug: MerkleAirdropTest.t.sol constructs its own independent merkleRoot using 25e6 amounts, so testUsersCanClaim passes — but it never exercises the root generated by makeMerkle.js that would actually be deployed.

Risk

Likelihood:

  • This will occur on every real deployment because both files are used together: makeMerkle.js generates s_merkleRoot for Deploy.s.sol, and the mismatch is structural

  • No existing test catches this because the test suite uses a separately hardcoded root with the correct 25e6 amount

Impact:

  • Every claim() call reverts — not a single user receives their allocation

  • The 100 USDC deposited at deployment is permanently locked in the contract with no mechanism to withdraw it (there is no emergencyWithdraw function)

  • The protocol must re-deploy entirely, regenerating the merkle tree with correct amounts, at additional gas cost and with potential loss of user trust

Proof of Concept

Add this test to test/MerkleAirdropTest.t.sol and run forge test --match-test test_DecimalMismatchBreaksClaims -vvv:

function test_DecimalMismatchBreaksClaims() public {
// Reproduce the real deployment scenario:
// merkle root is built from 25e18 amounts (as makeMerkle.js does)
// but the contract is funded with only 25e6 tokens
// The deployed root from makeMerkle.js (uses 25e18 amounts)
bytes32 realDeployRoot = 0xf69aaa25bd4dd10deb2ccd8235266f7cc815f6e9d539e9f4d47cae16e0c36a05;
// Deploy a fresh airdrop with the real root
MerkleAirdrop realAirdrop = new MerkleAirdrop(realDeployRoot, token);
// Fund it with the amount Deploy.s.sol uses: 4 * 25e6
uint256 fundingAmount = 4 * (25 * 1e6);
token.mint(address(this), fundingAmount);
token.transfer(address(realAirdrop), fundingAmount);
// The claim amount the leaf encodes (25e18 from makeMerkle.js)
uint256 claimAmountFromTree = 25 * 1e18;
vm.deal(collectorOne, realAirdrop.getFee());
vm.startPrank(collectorOne);
// This will revert — the contract cannot transfer 25e18 tokens
// when it only holds 100e6
vm.expectRevert();
realAirdrop.claim{ value: realAirdrop.getFee() }(collectorOne, claimAmountFromTree, proof);
vm.stopPrank();
// collectorOne receives nothing — airdrop is broken
assertEq(token.balanceOf(collectorOne), 0);
}

Recommended Mitigation

Fix the decimal multiplier in makeMerkle.js to match USDC's 6-decimal precision, then regenerate the root and update the deploy script.

// makeMerkle.js
- const amount = (25 * 1e18).toString()
+ const amount = (25 * 1e6).toString() // USDC uses 6 decimals

After making this change, re-run yarn run makeMerkle and update s_merkleRoot in Deploy.s.sol with the newly generated root:

// Deploy.s.sol
- bytes32 public s_merkleRoot = 0xf69aaa25bd4dd10deb2ccd8235266f7cc815f6e9d539e9f4d47cae16e0c36a05;
+ bytes32 public s_merkleRoot = <new_root_from_makeMerkle_output>;

No changes are required to the funding amount — 4 * (25 * 1e6) is already correct for USDC.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 23 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-03] Wrong Merkle Root used in `Deploy.s.sol` script causing eligible user cant claim

## Description `Deploy.s.sol` script provide the wrong value causing eligible address can not claim when the contract are deployed using the script. ## Vulnerability Details There are mismatch in value of merkle tree and merkle proof provided in `Deploy.s.sol` and `MerkleAirdropTest.t.sol` as the latter provided the correct value but the `Deploy.s.sol` script provide the wrong value causing eligible address can not claim when the contract are deployed using the script. NOTE: this assume the value of `s_zkSyncUSDC` used in `Deploy.s.sol` are already corrected using `0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4` otherwise it would revert and not showing `MerkleAirdrop__InvalidProof` error. If we run `yarn run makeMerkle` the following value would be returned: ```bash Merkle Root: 0xf69aaa25bd4dd10deb2ccd8235266f7cc815f6e9d539e9f4d47cae16e0c36a05 Proof for address: 0x20F41376c713072937eb02Be70ee1eD0D639966C with amount: 25000000000000000000: [ '0x4fd31fee0e75780cd67704fbc43caee70fddcaa43631e2e1bc9fb233fada2394', '0xc88d18957ad6849229355580c1bde5de3ae3b78024db2e6c2a9ad674f7b59f84' ] ``` the Merkle Root value `0xf69aaa25bd4dd10deb2ccd8235266f7cc815f6e9d539e9f4d47cae16e0c36a05` are used in the `Deploy.s.sol` script: ```javascript @> bytes32 public s_merkleRoot = 0xf69aaa25bd4dd10deb2ccd8235266f7cc815f6e9d539e9f4d47cae16e0c36a05; // 4 users, 25 USDC each uint256 public s_amountToAirdrop = 4 * (25 * 1e6); ``` this is problematic because the intended value for every claim is `25 USDC` or `25 * 1e6` because USDC decimal is 6 instead of 18, and the above Merkle Root value are intended for `25 * 1e18`. the `claim` function cannot be called successfully because the contract only held `s_amountToAirdrop = 4 * (25 * 1e6)` USDC and user cannot claim `25 * 1e18` as this is wrong value. <details><summary>PoC</summary> Add your zksync rpc url `ZKSYNC_MAINNET_RPC_URL` to `.env` file. Add this helper code to `Deploy.s.sol` so our test can capture the address of `MerkleAirdrop` and `IERC20` contract: ```diff - function run() public { + function run() public returns (MerkleAirdrop, IERC20) { vm.startBroadcast(); MerkleAirdrop airdrop = deployMerkleDropper(s_merkleRoot, IERC20(s_zkSyncUSDC)); // Send USDC -> Merkle Air Dropper IERC20(0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4).transfer(address(airdrop), s_amountToAirdrop); vm.stopBroadcast(); + // add helper + return (airdrop, IERC20(s_zkSyncUSDC)); } ``` Add `MerkleAirdropDeployScriptTest.t.sol` to `test` folder. `MerkleAirdropDeployScriptTest.t.sol`: ```javascript // SPDX-License-Identifier: MIT pragma solidity 0.8.24; import { MerkleAirdrop, IERC20 } from "../src/MerkleAirdrop.sol"; import { AirdropToken } from "./mocks/AirdropToken.sol"; import { Deploy } from "script/Deploy.s.sol"; import { Test } from "forge-std/Test.sol"; import { console } from "forge-std/Test.sol"; contract MerkleAirdropTest is Test, Deploy { MerkleAirdrop public airdrop; IERC20 public tokenIERC20; AirdropToken public token; uint256 amountToCollect = (25 * 1e6); // 25.000000 uint256 amountToSend = amountToCollect * 4; address collectorOne = 0x20F41376c713072937eb02Be70ee1eD0D639966C; // using proof from makeMerkle.js bytes32 proofOne = 0x4fd31fee0e75780cd67704fbc43caee70fddcaa43631e2e1bc9fb233fada2394; bytes32 proofTwo = 0xc88d18957ad6849229355580c1bde5de3ae3b78024db2e6c2a9ad674f7b59f84; bytes32[] public proof = [proofOne, proofTwo]; function setUp() public { // assume deployer have enough USDC balance, we use deal address correctUSDCAddress = 0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4; deal(address(IERC20(correctUSDCAddress)), address(msg.sender), amountToSend); console.log("USDC before:", IERC20(correctUSDCAddress).balanceOf(address(msg.sender))); // running the Deploy.s.sol script (airdrop, tokenIERC20) = Deploy.run(); console.log("USDC after:", IERC20(correctUSDCAddress).balanceOf(address(msg.sender))); } function testPOCUsingDeployScriptContractBrokenBecauseWrongMerkleRoot() public { // assume the correct USDC address are used in deploying MerkleAirdrop.sol uint256 startingBalance = tokenIERC20.balanceOf(collectorOne); vm.deal(collectorOne, airdrop.getFee()); vm.startPrank(collectorOne); airdrop.claim{ value: airdrop.getFee() }(collectorOne, amountToCollect, proof); vm.stopPrank(); uint256 endingBalance = tokenIERC20.balanceOf(collectorOne); assertEq(endingBalance - startingBalance, amountToCollect); } ``` after that run the following `forge test --zksync --fork-url $ZKSYNC_MAINNET_RPC_URL --mt testPOCUsingDeployScriptContractBrokenBecauseWrongMerkleRoot` the test will FAIL: ```bash Failing tests: Encountered 1 failing test in test/MerkleAirdropDeployScriptTest.t.sol:MerkleAirdropTest [FAIL. Reason: MerkleAirdrop__InvalidProof()] testPOCUsingDeployScriptContractBrokenBecauseWrongMerkleRoot() (gas: 32340) ``` </details> ## Impact user cannot claim the airdrop ## Recommendations Changing the correct Merkle Root can solve this problem. <details><summary>Code</summary> Using modified `makeMerkle.js` we can generate the correct value: ```diff . . . /*////////////////////////////////////////////////////////////// INPUTS //////////////////////////////////////////////////////////////*/ - const amount = (25 * 1e18).toString(); + const amount = (25 * 1e6).toString(); const userToGetProofOf = "0x20F41376c713072937eb02Be70ee1eD0D639966C"; // (1) const values = [ [userToGetProofOf, amount], ["0x277D26a45Add5775F21256159F089769892CEa5B", amount], ["0x0c8Ca207e27a1a8224D1b602bf856479b03319e7", amount], ["0xf6dBa02C01AF48Cf926579F77C9f874Ca640D91D", amount], ]; /*////////////////////////////////////////////////////////////// PROCESS //////////////////////////////////////////////////////////////*/ // (2) const tree = StandardMerkleTree.of(values, ["address", "uint256"]); // (3) console.log("Merkle Root:", tree.root); // (4) for (const [i, v] of tree.entries()) { // if (v[0] === userToGetProofOf) { // (3) const proof = tree.getProof(i); + console.log(`Proof for address: ${v} with amount: ${amount}:\n`, proof); - console.log(`Proof for address: ${userToGetProofOf} with amount: ${amount}:\n`, proof); - } } . . . ``` after that run `yarn run makeMerkle` and the following should returned: ```bash Merkle Root: 0x3b2e22da63ae414086bec9c9da6b685f790c6fab200c7918f2879f08793d77bd Proof for address: 0x20F41376c713072937eb02Be70ee1eD0D639966C,25000000 with amount: 25000000: [ '0x32cee63464b09930b5c3f59f955c86694a4c640a03aa57e6f743d8a3ca5c8838', '0x8ff683185668cbe035a18fccec4080d7a0331bb1bbc532324f40501de5e8ea5c' ] Proof for address: 0x277D26a45Add5775F21256159F089769892CEa5B,25000000 with amount: 25000000: [ '0x2683f462a4457349d6d7ef62d4208ef42c89c2cff9543cd8292d9269d832c3e8', '0xdcad361f30c4a5b102a90b4ec310ffd75c577ccdff1c678adb20a6f02e923366' ] Proof for address: 0x0c8Ca207e27a1a8224D1b602bf856479b03319e7,25000000 with amount: 25000000: [ '0xee1cda884ead2c9f34338f48263e7edd6e5f35bf4f09c9c0930d995911004eed', '0x8ff683185668cbe035a18fccec4080d7a0331bb1bbc532324f40501de5e8ea5c' ] Proof for address: 0xf6dBa02C01AF48Cf926579F77C9f874Ca640D91D,25000000 with amount: 25000000: [ '0x1e6784ff835523401f4db6e3ab48fa5bdf523a46a5bc0410a5639d837352b194', '0xdcad361f30c4a5b102a90b4ec310ffd75c577ccdff1c678adb20a6f02e923366' ] ``` using the correct value above, we should change the `Deploy.s.sol` value of `s_merkleRoot`: `Deploy.s.sol`: ```diff - bytes32 public s_merkleRoot = 0xf69aaa25bd4dd10deb2ccd8235266f7cc815f6e9d539e9f4d47cae16e0c36a05; + bytes32 public s_merkleRoot = 0x3b2e22da63ae414086bec9c9da6b685f790c6fab200c7918f2879f08793d77bd; // 4 users, 25 USDC each uint256 public s_amountToAirdrop = 4 * (25 * 1e6); ``` after that add the following code to `MerkleAirdropDeployScriptTest.t.sol` that we already created beforehand. `MerkleAirdropDeployScriptTest.t.sol`: ```javascript function testUsingDeployScriptContractCorrectMerkleRoot() public { // assume the correct USDC address are used in deploying MerkleAirdrop.sol // using the correctly generated merkleProof bytes32[] memory correctProofs = new bytes32[](8); correctProofs[0] = 0x32cee63464b09930b5c3f59f955c86694a4c640a03aa57e6f743d8a3ca5c8838; correctProofs[1] = 0x8ff683185668cbe035a18fccec4080d7a0331bb1bbc532324f40501de5e8ea5c; correctProofs[2] = 0x2683f462a4457349d6d7ef62d4208ef42c89c2cff9543cd8292d9269d832c3e8; correctProofs[3] = 0xdcad361f30c4a5b102a90b4ec310ffd75c577ccdff1c678adb20a6f02e923366; correctProofs[4] = 0xee1cda884ead2c9f34338f48263e7edd6e5f35bf4f09c9c0930d995911004eed; correctProofs[5] = 0x8ff683185668cbe035a18fccec4080d7a0331bb1bbc532324f40501de5e8ea5c; correctProofs[6] = 0x1e6784ff835523401f4db6e3ab48fa5bdf523a46a5bc0410a5639d837352b194; correctProofs[7] = 0xdcad361f30c4a5b102a90b4ec310ffd75c577ccdff1c678adb20a6f02e923366; // list of eligible addresses address[4] memory eligibleAddress = [ 0x20F41376c713072937eb02Be70ee1eD0D639966C, 0x277D26a45Add5775F21256159F089769892CEa5B, 0x0c8Ca207e27a1a8224D1b602bf856479b03319e7, 0xf6dBa02C01AF48Cf926579F77C9f874Ca640D91D ]; for (uint256 i = 0; i < eligibleAddress.length; ++i) { uint256 startingBalance = tokenIERC20.balanceOf(eligibleAddress[i]); vm.deal(eligibleAddress[i], airdrop.getFee()); // get corresponding proof for address i bytes32[] memory proofs = new bytes32[](2); proofs[0] = correctProofs[i * 2]; proofs[1] = correctProofs[i * 2 + 1]; vm.startPrank(eligibleAddress[i]); airdrop.claim{ value: airdrop.getFee() }(eligibleAddress[i], amountToCollect, proofs); vm.stopPrank(); uint256 endingBalance = tokenIERC20.balanceOf(eligibleAddress[i]); assertEq(endingBalance - startingBalance, amountToCollect); } } ``` then run the following command `forge test --zksync --fork-url $ZKSYNC_MAINNET_RPC_URL --mt testUsingDeployScriptContractCorrectMerkleRoot` the test result PASS: ```bash Ran 1 test for test/MerkleAirdropDeployScriptTest.t.sol:MerkleAirdropTest [PASS] testUsingDeployScriptContractCorrectMerkleRoot() (gas: 63544) Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 76.19s (23.89s CPU time) ``` </details>

Support

FAQs

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

Give us feedback!