AirDropper

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

Two different USDC addresses used in Deploy::run() causes MerkleAirdrop to reference a different token than the one funded, making all claims permanently fail

Description

  • The deploy script is intended to deploy MerkleAirdrop with the zkSync USDC token address and immediately fund the contract with 100 USDC so that eligible addresses can begin claiming.

  • However, two different addresses are used for the same token across the script. The state variable s_zkSyncUSDC — which is passed to the MerkleAirdrop constructor as i_airdropToken — differs from the hardcoded address used in the transfer() call that funds the contract. The characters at position 20 in the hex string diverge: ...135be... vs ...135aE.... This is not an EIP-55 checksum difference — it is a genuinely different address. As a result, the contract holds USDC from one address but attempts to distribute USDC from another.

contract Deploy is Script {
// @> Address A — passed to MerkleAirdrop constructor as i_airdropToken
address public s_zkSyncUSDC = 0x1D17CbCf0D6d143135be902365d2e5E2a16538d4;
// ^^
function run() public {
vm.startBroadcast();
MerkleAirdrop airdrop = deployMerkleDropper(s_merkleRoot, IERC20(s_zkSyncUSDC));
// @> Address B — different address used to transfer funds into the contract
IERC20(0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4).transfer(address(airdrop), s_amountToAirdrop);
// ^^
vm.stopBroadcast();
}
}

Risk

Likelihood:

  • The mismatch is present in the script as written — every deployment using this script reproduces the bug without any attacker involvement

  • The two addresses differ by a single character and pass a visual inspection, making this easy to miss during code review without a character-by-character comparison

Impact:

  • The contract's i_airdropToken points to Address A, but the funded balance belongs to Address B — every call to claim() calls safeTransfer() on Address A, which holds zero balance, causing all claims to revert permanently

  • The 100 USDC transferred via Address B is locked inside the contract with no recovery function, resulting in a complete and irreversible loss of airdrop funds

Proof of Concept

The bug requires no external attacker — it is triggered by the deployment itself. Converting both addresses to lowercase makes the divergence unambiguous and confirms they are not the same address with different EIP-55 casing:

// Character-by-character comparison (lowercase):
//
// Address A (s_zkSyncUSDC, used in constructor):
// 0x1d17cbcf0d6d143135be902365d2e5e2a16538d4
// ^^
// Address B (hardcoded in transfer() call):
// 0x1d17cbcf0d6d143135ae902365d2e5e2a16538d4
// ^^
//
// Difference at position 20: 'b' vs 'a' — these are two distinct contracts.
// EIP-55 checksum only changes uppercase/lowercase of the SAME hex digits.
// 'b' and 'a' are different hex digits → confirmed different address.
//
// Consequence after deployment:
//
// i_airdropToken = Address A (zero USDC balance)
// contract ETH balance of Address B token = 100 USDC
//
// User calls claim():
// → i_airdropToken.safeTransfer(user, 25e6)
// → Address A has 0 USDC
// → safeTransfer reverts with ERC20InsufficientBalance
// → all four eligible addresses are permanently unable to claim
// → 100 USDC locked in contract forever (no rescue function exists)

Recommended Mitigation

Remove the hardcoded address inside run() entirely and reuse the s_zkSyncUSDC state variable for both the constructor call and the transfer. This ensures a single source of truth for the token address and eliminates the possibility of the two diverging again in the future.

function run() public {
vm.startBroadcast();
MerkleAirdrop airdrop = deployMerkleDropper(s_merkleRoot, IERC20(s_zkSyncUSDC));
- IERC20(0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4).transfer(address(airdrop), s_amountToAirdrop);
+ IERC20(s_zkSyncUSDC).transfer(address(airdrop), s_amountToAirdrop);
vm.stopBroadcast();
}
Updates

Lead Judging Commences

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

[H-01] Address of USDC token in `Deploy.s.sol` is wrong causing the claiming process to fail

## Description The `s_zkSyncUSDC` address in `Deploy.s.sol` is incorrectly set, leading to a failure in the claiming process. This error results in funds being stuck in the `MerkleAirdrop` contract due to the immutability of the token address. ## Impact All funds become permanently trapped in the `MerkleAirdrop` contract, rendering them inaccessible for claiming or transfer. **Proof of Concept:** To demonstrate the issue, a test contract can be added and executed using the following command: `forge test --zksync --rpc-url $RPC_ZKSYNC --mt testDeployOnZkSync` Use the RPC URL `https://mainnet.era.zksync.io` for testing. <details> <summary>Proof Of Code</summary> ```javascript // SPDX-License-Identifier: MIT pragma solidity 0.8.24; import { MerkleAirdrop, IERC20 } from "../src/MerkleAirdrop.sol"; import { Test, console2 } from "forge-std/Test.sol"; contract MerkleAirdropTest is Test { MerkleAirdrop public s_airdrop; uint256 s_amountToCollect = (25 * 1e6); // 25.000000 address s_collectorOne = 0x20F41376c713072937eb02Be70ee1eD0D639966C; bytes32 s_proofOne = 0x32cee63464b09930b5c3f59f955c86694a4c640a03aa57e6f743d8a3ca5c8838; bytes32 s_proofTwo = 0x8ff683185668cbe035a18fccec4080d7a0331bb1bbc532324f40501de5e8ea5c; bytes32[] s_proof = [s_proofOne, s_proofTwo]; address public deployer; // From Deploy.t.sol bytes32 public s_merkleRoot = 0x3b2e22da63ae414086bec9c9da6b685f790c6fab200c7918f2879f08793d77bd; address public s_zkSyncUSDC = 0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4; uint256 public s_amountToAirdrop = 4 * (25 * 1e6); function setUp() public { deployer = makeAddr("deployer"); deal(0x1D17CbCf0D6d143135be902365d2e5E2a16538d4, deployer, 100 * 1e6); vm.deal(s_collectorOne, 100 ether); } function testDeployOnZkSync() public { if (block.chainid != 324) { return; } vm.startPrank(deployer); // From here there is the code from run() s_airdrop = deployMerkleDropper(s_merkleRoot, IERC20(s_zkSyncUSDC)); // Send USDC -> Merkle Air Dropper IERC20(0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4).transfer(address(s_airdrop), s_amountToAirdrop); // end code from run vm.stopPrank(); vm.startPrank(s_collectorOne); s_airdrop.claim{ value: s_airdrop.getFee() }(s_collectorOne, s_amountToCollect, s_proof); vm.stopPrank(); } function deployMerkleDropper(bytes32 merkleRoot, IERC20 zkSyncUSDC) public returns (MerkleAirdrop) { return (new MerkleAirdrop(merkleRoot, zkSyncUSDC)); } } ``` </details> ## Recommendations To resolve the issue, update the s_zkSyncUSDC address in Deploy.s.sol to the correct value: ```diff - address public s_zkSyncUSDC = 0x1D17CbCf0D6d143135be902365d2e5E2a16538d4; + address public s_zkSyncUSDC = 0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4; ```

Support

FAQs

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

Give us feedback!