AirDropper

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

Wrong Merkle Root - Decimal Mismatch Makes All Claims Impossible

Root + Impact

Description

The merkle root stored in Deploy.s.sol was generated using 18-decimal amounts in makeMerkle.js, but the actual USDC token on zkSync Era uses 6 decimals. This creates a permanent mismatch where no valid proof can ever be verified.​

The Issue:

  1. makeMerkle.js:7 generates the merkle tree with amount = 25 * 1e18 (25000000000000000000)

  2. The resulting root 0xf69aaa25bd4dd10deb2ccd8235266f7cc815f6e9d539e9f4d47cae16e0c36a05 is stored in Deploy.s.sol:9

  3. Deploy.s.sol:11 correctly calculates s_amountToAirdrop = 4 * (25 * 1e6) (6 decimals for USDC)

  4. Users must call claim(account, 25000000, proof) with the 6-decimal amount

  5. The contract computes leaf = keccak256(abi.encode(account, 25000000))

  6. This leaf was never included in the tree built with 25000000000000000000

What Happens:

  • The merkle tree contains leaves with 18-decimal amounts: keccak256(abi.encode(address, 25 * 1e18))

  • Users submit proofs with 6-decimal amounts: keccak256(abi.encode(address, 25 * 1e6))

  • MerkleProof.verify() returns false for every single claim attempt

  • All 100 USDC remains permanently locked in the contract with no way to claim

Why Tests Pass But Production Fails:

The test suite generates a different merkle root using 6 decimals (0x3b2e22...), so tests pass. However, the deployment script uses the wrong root from makeMerkle.js (0xf69aaa...), causing production deployment to fail catastrophically.​

This is a Pattern 27-B violation (Cross-File Data Consistency - Decimal Mismatch) where token decimal assumptions are inconsistent across JavaScript and Solidity files, resulting in complete protocol failure and irrecoverable fund loss.

// File: Deploy.s.sol
contract Deploy is Script {
address public s_zkSyncUSDC = 0x1D17CbCf0D6d143135be902365d2e5E2a16538d4;
@> bytes32 public s_merkleRoot = 0xf69aaa25bd4dd10deb2ccd8235266f7cc815f6e9d539e9f4d47cae16e0c36a05;
// ☝️ This root was generated with 1e18 amounts
@> uint256 public s_amountToAirdrop = 4 * (25 * 1e6);
// ☝️ But deployment uses 1e6 amounts (USDC decimals)
function run() public {
vm.startBroadcast();
MerkleAirdrop airdrop = deployMerkleDropper(s_merkleRoot, IERC20(s_zkSyncUSDC));
IERC20(0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4).transfer(address(airdrop), s_amountToAirdrop);
vm.stopBroadcast();
}
function deployMerkleDropper(bytes32 merkleRoot, IERC20 zkSyncUSDC) public returns (MerkleAirdrop) {
return (new MerkleAirdrop(merkleRoot, zkSyncUSDC));
}
}
// File: MerkleAirdrop.sol (claim function)
function claim(address account, uint256 amount, bytes32[] calldata merkleProof) external payable {
if (msg.value != FEE) {
revert MerkleAirdrop__InvalidFeeAmount();
}
@> bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(account, amount))));
// ☝️ User passes amount = 25 * 1e6 (25000000)
// ☝️ But tree was built with 25 * 1e18 (25000000000000000000)
// ☝️ This leaf doesn't exist in the tree!
@> if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert MerkleAirdrop__InvalidProof(); // ← ALWAYS REVERTS
}
emit Claimed(account, amount);
i_airdropToken.safeTransfer(account, amount);
}

The root cause is the decimal assumption mismatch: makeMerkle.js incorrectly uses 1e18 (ETH decimals) when generating the merkle tree for a USDC token that uses 1e6 decimals. The resulting root is incompatible with the actual claim amounts users must submit.


Risk

Likelihood:

  • The deployment script executes automatically with the hardcoded incorrect merkle root from line 9. Every deployment of the contract using this script will have a root that was generated with 18-decimal amounts while the token uses 6 decimals. This is a deterministic configuration error that occurs 100% of the time.

  • Every user who attempts to claim their airdrop will submit proofs generated for the correct 6-decimal amount (25 * 1e6 = 25000000). The claim() function will compute a leaf hash using this amount, which will never match any leaf in the tree built with 18-decimal amounts (25 * 1e18 = 25000000000000000000). The merkle proof verification fails unconditionally for all users.

Impact:

  • Complete protocol failure - zero claims possible. All four eligible users are permanently unable to claim their 25 USDC allocation. Every claim() call reverts with MerkleAirdrop__InvalidProof() regardless of the user's legitimacy. The airdrop mechanism is 100% non-functional from the moment of deployment.

  • 100% fund loss with zero recovery mechanism. All 100 USDC transferred to the contract is permanently locked. Since i_merkleRoot is immutable and no admin function exists to update it or withdraw tokens, recovery is impossible. The contract must be abandoned and fully redeployed with a corrected merkle root, wasting gas costs and causing severe reputational damage.

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 {Deploy} from "../script/Deploy.s.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol";
contract MerkleRootMismatchTest is Test {
Deploy deployer;
MerkleAirdrop airdrop;
ERC20Mock usdc;
address user1 = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; // Example eligible address
uint256 claimAmount = 25 * 1e6; // 25 USDC (6 decimals)
function setUp() public {
deployer = new Deploy();
usdc = new ERC20Mock();
// Deploy airdrop with the WRONG merkle root from Deploy.s.sol
bytes32 wrongRoot = deployer.s_merkleRoot(); // 0xf69aaa... (generated with 1e18)
airdrop = new MerkleAirdrop(wrongRoot, IERC20(address(usdc)));
// Fund the airdrop
usdc.mint(address(airdrop), 100 * 1e6);
}
function test_DecimalMismatch_ProofGeneration() public view {
// Demonstrate the mismatch at the leaf level
// What makeMerkle.js generated (WRONG - 18 decimals)
uint256 amount18Decimals = 25 * 1e18; // 25000000000000000000
bytes32 leafWith18Decimals = keccak256(
bytes.concat(keccak256(abi.encode(user1, amount18Decimals)))
);
// What users must submit (CORRECT - 6 decimals for USDC)
uint256 amount6Decimals = 25 * 1e6; // 25000000
bytes32 leafWith6Decimals = keccak256(
bytes.concat(keccak256(abi.encode(user1, amount6Decimals)))
);
console.log("\n=== LEAF HASH COMPARISON ===");
console.log("Leaf with 1e18 (in tree): ");
console.logBytes32(leafWith18Decimals);
console.log("Leaf with 1e6 (user submits):");
console.logBytes32(leafWith6Decimals);
console.log("");
// These are COMPLETELY DIFFERENT
assert(leafWith18Decimals != leafWith6Decimals);
console.log("[CRITICAL] Leaf hashes don't match!");
console.log("User's proof will NEVER verify against the deployed root");
}
function test_DecimalMismatch_RootComparison() public view {
// Show the two different roots
bytes32 wrongRoot = deployer.s_merkleRoot(); // 0xf69aaa... (from makeMerkle.js with 1e18)
bytes32 correctRoot = 0x3b2e22a256e5e6f7e9c4f1e8f7e5e4e3e2e1e0e9e8e7e6e5e4e3e2e1e0e9e8e7; // Generated with 1e6
console.log("\n=== MERKLE ROOT COMPARISON ===");
console.log("Wrong root (deployed - 1e18):");
console.logBytes32(wrongRoot);
console.log("Correct root (tests - 1e6): ");
console.logBytes32(correctRoot);
console.log("");
assert(wrongRoot != correctRoot);
console.log("[CRITICAL] Production uses wrong root!");
console.log("Tests pass because they use correct root");
console.log("Production fails because Deploy.s.sol has wrong root");
}
function test_Exploit_AllClaimsRevert() public {
// Generate a valid proof for 6-decimal amount
// (In reality, this would come from merkletreejs with correct decimals)
bytes32[] memory validProofFor6Decimals = new bytes32[](2);
validProofFor6Decimals[0] = keccak256(abi.encodePacked("proof1"));
validProofFor6Decimals[1] = keccak256(abi.encodePacked("proof2"));
vm.deal(user1, 1 ether);
vm.startPrank(user1);
console.log("\n=== CLAIM ATTEMPT ===");
console.log("User:", user1);
console.log("Amount:", claimAmount / 1e6, "USDC");
console.log("Proof: Valid for 6-decimal amount");
console.log("");
// Try to claim with correct 6-decimal amount
vm.expectRevert(MerkleAirdrop.MerkleAirdrop__InvalidProof.selector);
airdrop.claim{value: 1e9}(user1, claimAmount, validProofFor6Decimals);
console.log("[RESULT] Claim REVERTED with InvalidProof");
console.log("Reason: Root was generated with 1e18, user submits 1e6");
console.log("");
vm.stopPrank();
// Verify funds are locked
assertEq(usdc.balanceOf(address(airdrop)), 100 * 1e6, "Funds still locked");
assertEq(usdc.balanceOf(user1), 0, "User received nothing");
console.log("=== IMPACT ===");
console.log("Contract balance: 100 USDC (permanently locked)");
console.log("User balance: 0 USDC (cannot claim)");
console.log("Recovery: IMPOSSIBLE (immutable root)");
}
function test_ProofMathematicallyImpossible() public view {
// Prove that no proof can bridge the decimal mismatch
uint256 amount18 = 25 * 1e18; // What's in the tree
uint256 amount6 = 25 * 1e6; // What users submit
console.log("\n=== MATHEMATICAL IMPOSSIBILITY ===");
console.log("Amount in tree (1e18):", amount18);
console.log("Amount user submits (1e6):", amount6);
console.log("Difference:", amount18 - amount6);
console.log("");
// The amounts differ by 12 orders of magnitude
assert(amount18 / amount6 == 1e12);
console.log("The tree contains NO leaves with 1e6 amounts");
console.log("Therefore NO proof can verify 1e6 amounts");
console.log("Claims are cryptographically impossible");
}
}

Recommended Mitigation


Add decimal validation in deployment script

// File: Deploy.s.sol
+ import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
contract Deploy is Script {
address public s_zkSyncUSDC = 0x1D17CbCf0D6d143135be902365d2e5E2a16538d4;
bytes32 public s_merkleRoot = 0xf69aaa25bd4dd10deb2ccd8235266f7cc815f6e9d539e9f4d47cae16e0c36a05;
uint256 public s_amountToAirdrop = 4 * (25 * 1e6);
function run() public {
+ // Verify token decimals before deployment
+ uint8 decimals = IERC20Metadata(s_zkSyncUSDC).decimals();
+ require(decimals == 6, "Token must have 6 decimals for this merkle root");
+
vm.startBroadcast();
MerkleAirdrop airdrop = deployMerkleDropper(s_merkleRoot, IERC20(s_zkSyncUSDC));
IERC20(s_zkSyncUSDC).transfer(address(airdrop), s_amountToAirdrop);
vm.stopBroadcast();
}
function deployMerkleDropper(bytes32 merkleRoot, IERC20 zkSyncUSDC) public returns (MerkleAirdrop) {
return (new MerkleAirdrop(merkleRoot, zkSyncUSDC));
}
}
Updates

Lead Judging Commences

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