AirDropper

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

Deploy.s.sol builds the airdrop with a codeless token address (0x...135be90...) but funds it with real USDC.e, so every claim reverts and funds are locked

Root + Impact

Description

Deploy.s.sol uses two different token addresses. The airdrop is constructed with s_zkSyncUSDC, but the funding transfer is sent from a different, hardcoded address:

// @> 0x1D17CbCf0D6d143135be902365d2e5E2a16538d4 ("...135be90...")
address public s_zkSyncUSDC = 0x1D17CbCf0D6d143135be902365d2e5E2a16538d4;
...
MerkleAirdrop airdrop = deployMerkleDropper(s_merkleRoot, IERC20(s_zkSyncUSDC));
// @> 0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4 ("...135aE90...") = real USDC.e on zkSync Era
IERC20(0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4).transfer(address(airdrop), s_amountToAirdrop);

The two literals differ in one byte (be vs ae), not only in checksum casing. A read-only eth_getCode query on zkSync Era mainnet returns 0 bytes of code for the configured address and 8672 bytes for the real USDC.e address.

So i_airdropToken points at an address with no code, while the real USDC is sent to the airdrop. In OpenZeppelin v5, SafeERC20.safeTransfer reverts when the target has no code and returns no data, so every claim reverts.

Risk

Likelihood:

  • This happens on the documented deployment path; no attacker action is needed.

Impact:

  • No eligible user can ever claim.

  • The 100 USDC sent to the airdrop is permanently locked: i_airdropToken is immutable and the contract has no function to move any other ERC20.

Proof of Concept

Both tests pass. The control case (same claim, but a token address that has code) succeeds in the replay test, so the revert is caused by the codeless token address.

function test_F3_deployTokenAddressMismatch() public {
Deploy d = new Deploy();
address configured = d.s_zkSyncUSDC();
address funded = 0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4;
assertTrue(configured != funded);
}
​
function test_F3_claimRevertsWhenTokenHasNoCode() public {
Deploy d = new Deploy();
address configured = d.s_zkSyncUSDC();
assertEq(configured.code.length, 0);
MerkleAirdrop airdrop = new MerkleAirdrop(testRoot, AirdropToken(configured));
proof.push(0x32cee63464b09930b5c3f59f955c86694a4c640a03aa57e6f743d8a3ca5c8838);
proof.push(0x8ff683185668cbe035a18fccec4080d7a0331bb1bbc532324f40501de5e8ea5c);
uint256 fee = airdrop.getFee();
vm.deal(users[0], fee);
vm.prank(users[0]);
vm.expectRevert();
airdrop.claim{ value: fee }(users[0], 25e6, proof);
}

Recommended Mitigation

Use a single, correct constant for both the constructor and the funding transfer:

- address public s_zkSyncUSDC = 0x1D17CbCf0D6d143135be902365d2e5E2a16538d4;
+ address public s_zkSyncUSDC = 0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4;
...
- IERC20(0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4).transfer(address(airdrop), s_amountToAirdrop);
+ IERC20(s_zkSyncUSDC).safeTransfer(address(airdrop), s_amountToAirdrop);
Updates

Lead Judging Commences

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