AirDropper

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

Merkle Tree Encodes 1e18 Token Amounts but USDC Uses 6 Decimals, Permanently DoSing All Claims

Root + Impact

Description

The protocol intends to airdrop 25 USDC (6 decimals) to each of 4 addresses. The `Deploy.s.sol` script correctly funds the contract with `4 * (25 * 1e6) = 100,000,000` (100 USDC). However, the `makeMerkle.js` script that generates the merkle root encodes each user's amount as `25 * 1e18` (25 quintillion), as if the token had 18 decimals.

The deploy script uses the merkle root produced by `makeMerkle.js`. When users attempt to claim, they face an impossible dilemma: using the tree amount (25e18) causes the transfer to revert due to insufficient contract balance, and using the intended amount (25e6) causes proof verification to fail because the leaf hash doesn't match.

// makeMerkle.js
@> const amount = (25 * 1e18).toString() // 25000000000000000000 -- wrong for USDC (6 decimals)
const userToGetProofOf = "0x20F41376c713072937eb02Be70ee1eD0D639966C"
const values = [
[userToGetProofOf, amount],
...
]
// script/Deploy.s.sol
bytes32 public s_merkleRoot = 0xf69aaa25bd4dd10deb2ccd8235266f7cc815f6e9d539e9f4d47cae16e0c36a05;
@> // This root was generated from makeMerkle.js with amount = 25e18
@> uint256 public s_amountToAirdrop = 4 * (25 * 1e6); // 100 USDC (correct for 6 decimals)
// But the merkle tree expects 25e18 per claim

Risk

Likelihood:

  • The merkle root in `Deploy.s.sol` (`0xf69a...`) matches `tree.json`, which was generated by `makeMerkle.js` using `25 * 1e18`. This mismatch exists in the committed codebase and will be deployed as-is.

  • Every claim attempt will fail because the two valid claim paths both revert.

Impact:

  • Permanent denial of service -- no user can ever claim their airdrop tokens. The 100 USDC is locked in the contract with no recovery mechanism (there is no owner withdrawal for tokens, only for ETH fees).

  • Total loss of all airdrop funds (100 USDC).

Proof of Concept

Both tests pass:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import { MerkleAirdrop } from "../src/MerkleAirdrop.sol";
import { AirdropToken } from "./mocks/AirdropToken.sol";
import { Test, console } from "forge-std/Test.sol";
/// @title F-002 PoC: Merkle Amount Mismatch - Airdrop DoS
/// @notice Demonstrates that using the deploy script's merkle root (built with 25e18 amounts)
/// causes all claims to fail because the contract only holds 100e6 tokens.
contract F002_AmountMismatchTest is Test {
MerkleAirdrop public airdrop;
AirdropToken public token;
// This is the merkle root from Deploy.s.sol / tree.json (built with 25e18 amounts)
bytes32 public deployMerkleRoot = 0xf69aaa25bd4dd10deb2ccd8235266f7cc815f6e9d539e9f4d47cae16e0c36a05;
// Deploy script funds 100 USDC (6 decimals)
uint256 amountToFund = 4 * (25 * 1e6); // 100_000_000
// But the merkle tree encoded 25e18 per user
uint256 amountInTree = 25 * 1e18; // 25_000_000_000_000_000_000
address collectorOne = 0x20F41376c713072937eb02Be70ee1eD0D639966C;
// Proof for collectorOne from tree.json (generated with 25e18 amounts)
// From makeMerkle.js output, the proof for collectorOne is:
// 0x4fd31fee0e75780cd67704fbc43caee70fddcaa43631e2e1bc9fb233fada2394
// 0xc88d18957ad6849229355580c1bde5de3ae3b78024db2e6c2a9ad674f7b59f84
bytes32 proofOne = 0x4fd31fee0e75780cd67704fbc43caee70fddcaa43631e2e1bc9fb233fada2394;
bytes32 proofTwo = 0xc88d18957ad6849229355580c1bde5de3ae3b78024db2e6c2a9ad674f7b59f84;
bytes32[] proof = [proofOne, proofTwo];
function setUp() public {
token = new AirdropToken();
// Deploy with the PRODUCTION merkle root (from makeMerkle.js with 25e18)
airdrop = new MerkleAirdrop(deployMerkleRoot, token);
// Fund with the PRODUCTION amount (100e6 = 100 USDC)
token.mint(address(this), amountToFund);
token.transfer(address(airdrop), amountToFund);
}
/// @notice Claiming with the correct tree amount (25e18) should revert due to insufficient balance
function test_F002_claimWithTreeAmountReverts() public {
uint256 fee = airdrop.getFee();
vm.deal(collectorOne, fee);
vm.startPrank(collectorOne);
// Claim with amount=25e18 (what the tree encodes). Proof should verify,
// but safeTransfer of 25e18 tokens will revert because contract only has 100e6.
vm.expectRevert(); // ERC20InsufficientBalance
airdrop.claim{ value: fee }(collectorOne, amountInTree, proof);
vm.stopPrank();
console.log("[F-002] PROVEN: Claim with tree amount (25e18) reverts - insufficient balance");
console.log(" Contract balance:", token.balanceOf(address(airdrop)));
console.log(" Requested amount:", amountInTree);
}
/// @notice Claiming with the intended USDC amount (25e6) should revert due to invalid proof
function test_F002_claimWithIntendedAmountInvalidProof() public {
uint256 fee = airdrop.getFee();
uint256 intendedAmount = 25 * 1e6;
vm.deal(collectorOne, fee);
vm.startPrank(collectorOne);
// Claim with amount=25e6 (what was intended). The proof was built for 25e18,
// so the leaf hash won't match and proof verification fails.
vm.expectRevert(MerkleAirdrop.MerkleAirdrop__InvalidProof.selector);
airdrop.claim{ value: fee }(collectorOne, intendedAmount, proof);
vm.stopPrank();
console.log("[F-002] PROVEN: Claim with intended amount (25e6) fails - invalid proof (tree expects 25e18)");
}
}

Recommended Mitigation

Fix `makeMerkle.js` to use the correct USDC decimal precision and regenerate the merkle root:

// makeMerkle.js
- const amount = (25 * 1e18).toString()
+ const amount = (25 * 1e6).toString()
```
Then update the deploy script with the new merkle root:
```diff
// script/Deploy.s.sol
- bytes32 public s_merkleRoot = 0xf69aaa25bd4dd10deb2ccd8235266f7cc815f6e9d539e9f4d47cae16e0c36a05;
+ bytes32 public s_merkleRoot = <new root from regenerated tree with 25e6 amounts>;
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 3 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!