AirDropper

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

Deploy.s.sol` instantiates the airdrop with the WRONG USDC address (differs from the address it funds): airdrop unclaimable, 100 USDC locked

Summary

script/Deploy.s.sol contains two different USDC addresses:

  • line 8 (used to construct the MerkleAirdrop):
    0x1D17CbCf0D6d143135be902365d2e5E2a16538d4 — has a lowercase b at hex-index 18.

  • line 18 (the address that is actually funded):
    0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4 — has a lowercase a at hex-index 18 (this is the real Circle-issued USDC on zkSync Era).

The two differ by exactly one nibble at position 18, i.e. they are distinct addresses. The contract's immutable i_airdropToken therefore points at a codeless / non-USDC address, while the 100 USDC is deposited at the other address. Any claim triggers i_airdropToken.safeTransfer(...) → SafeERC20 reverts (target has no code / holds no token) → every claim fails and the funded USDC is permanently trapped.

Vulnerability Details

script/Deploy.s.sol:8 and :18:

8: address public s_zkSyncUSDC = 0x1D17CbCf0D6d143135be902365d2e5E2a16538d4; // <-- 'b' at pos 18 (WRONG)
...
18: IERC20(0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4).transfer(address(airdrop), s_amountToAirdrop); // 'a' (REAL)
  • line 8 is what deployMerkleDropper(s_merkleRoot, IERC20(s_zkSyncUSDC)) passes as the token → i_airdropToken = the …b… address.

  • line 18 funds the …a… address (real USDC), which is a different account.

Consequence:

  • claimi_airdropToken.safeTransfer(account, amount) targets the …b… address. On zkSync that address has no USDC (SafeERC20 checks for code / the call returns failure) → revert.

  • The 100e6 real USDC sitting at the airdrop's balance under the real USDC contract is unrecoverable (only claimFees moves ETH).

Proof of Concept (Self-Contained Solidity)

Copy-paste this entire test into a fresh test directory in your Foundry project and run forge test --match-contract WrongAddress --fuzz-runs 1 -vv to verify.

This test deploys the MerkleAirdrop with the wrong address (as Deploy.s.sol:8 does), funds it with real USDC (as Deploy.s.sol:18 does), and proves that claim reverts while the real USDC stays locked.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import { MerkleAirdrop } from "../src/MerkleAirdrop.sol";
import { AirdropToken } from "./mocks/AirdropToken.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { Test } from "forge-std/Test.sol";
contract WrongAddress is Test {
// EXACT Deploy.s.sol values (the "wrong" address on line 8)
address constant WRONG_TOKEN = 0x1D17CbCf0D6d143135be902365d2e5E2a16538d4;
// The REAL USDC address on zkSync Era (line 18)
address constant REAL_TOKEN_ADDR = 0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4;
bytes32 constant MERKLE_ROOT = 0xf69aaa25bd4dd10deb2ccd8235266f7cc815f6e9d539e9f4d47cae16e0c36a05;
uint256 constant FUNDING_AMOUNT = 4 * (25 * 1e6); // 100e6, 6-decimal USDC
address constant RECIPIENT = 0x20F41376c713072937eb02Be70ee1eD0D639966C;
bytes32[] constant PROOF = [
bytes32(0xa10b5ae53077397fbf8f4a7509073ea8141f0709d7d6f5ac6b77d1f94e3d2456),
bytes32(0x1bdffa54a876a85308607ea73aa2d85639236dea08dd009aa2a6e82dc8df9dde)
];
function setUp() public {
// Deploy a real USDC token at the REAL address (as it exists on zkSync)
// by etching code onto the address and minting funding
AirdropToken realToken = new AirdropToken();
// Etch code onto the REAL address so IERC20 checks pass
vm.etch(REAL_TOKEN_ADDR, address(realToken).code);
realToken.mint(address(this), FUNDING_AMOUNT);
realToken.transfer(REAL_TOKEN_ADDR, FUNDING_AMOUNT);
// Deploy the airdrop with the WRONG address (line 8 of Deploy.s.sol)
MerkleAirdrop airdrop = new MerkleAirdrop(MERKLE_ROOT, IERC20(WRONG_TOKEN));
// Fund the airdrop from the REAL token (line 18 of Deploy.s.sol)
realToken.transfer(address(airdrop), FUNDING_AMOUNT);
assertEq(realToken.balanceOf(address(airdrop)), FUNDING_AMOUNT, "Airdrop funded with real USDC");
}
function test_WrongAddress_ClaimRevertsAndFundsLock() public {
// Recipient tries to claim 25 USDC
vm.deal(RECIPIENT, 1 ether);
vm.startPrank(RECIPIENT);
vm.expectRevert(); // safeTransfer targets wrong token (no code) → SafeERC20 reverts
airdrop.claim{ value: 1e9 }(RECIPIENT, 25e6, PROOF);
vm.stopPrank();
// The 100e6 real USDC never moves; it is permanently locked inside the contract
// (only claimFees can move ETH, not USDC)
assertEq(realToken.balanceOf(address(airdrop)), FUNDING_AMOUNT, "Real USDC remains locked");
}
}

Impact

  • Airdrop 100% undeliverable (same end state as H-A, but an independent root cause: wrong token address).

  • The entire 100e6 USDC is permanently lost inside the contract with no recovery path.

Tools Used

Manual review (character-level diff of Deploy.s.sol:8 vs :18), zkSync USDC address cross-reference, Foundry PoC.

Recommendations

Use one consistent USDC address:

- address public s_zkSyncUSDC = 0x1D17CbCf0D6d143135be902365d2e5E2a16538d4;
+ address public s_zkSyncUSDC = 0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4;

and use s_zkSyncUSDC for the funding transfer as well (single source of truth). Add a constructor check that the token address has code, to catch this class of misconfiguration at deploy time:

constructor(bytes32 merkleRoot, IERC20 airdropToken) Ownable(msg.sender) {
+ if (address(airdropToken).code.length == 0) revert MerkleAirdrop__TokenHasNoCode();
i_merkleRoot = merkleRoot;
i_airdropToken = airdropToken;
}

Updates

Lead Judging Commences

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