AirDropper

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

Deploy.s.sol passes one USDC address to the MerkleAirdrop constructor but funds a different address, so every claim reverts and the 100 USDC sent to the airdrop is permanently locked

Root + Impact

Description

  • Deploy.run() should deploy MerkleAirdrop with zkSync USDC as i_airdropToken, then send 100 USDC (4 × 25e6) of that same token to the contract so the four recipients can claim it. The README (L124) says the team will deploy with this script, and the script's own L17 comment reads "Send USDC -> Merkle Air Dropper".

  • The constructor token comes from s_zkSyncUSDC (L8, 0x1D17CbCf0D6d143135be902365d2e5E2a16538d4), but the funding transfer at L18 hard-codes a second literal, 0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4. Lowercased, the two differ in one hex digit (...143135be90... vs ...143135ae90...), so they are two different addresses, not two casings of one. Both are valid EIP-55 checksums, so solc accepts them without a warning. i_airdropToken is immutable, and claim (L39) is the only place the contract moves an ERC20. The funded tokens can therefore never be paid out or recovered.

// script/Deploy.s.sol L8: the token handed to the constructor ("...143135be90...")
@> address public s_zkSyncUSDC = 0x1D17CbCf0D6d143135be902365d2e5E2a16538d4; // L8
​
// script/Deploy.s.sol L14-L20
function run() public {
vm.startBroadcast();
@> MerkleAirdrop airdrop = deployMerkleDropper(s_merkleRoot, IERC20(s_zkSyncUSDC)); // L16
// Send USDC -> Merkle Air Dropper
@> IERC20(0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4).transfer(address(airdrop), s_amountToAirdrop); // L18: a different address ("...143135ae90...")
vm.stopBroadcast();
}
​
// src/MerkleAirdrop.sol L16 and L39: the token is fixed at construction and is the only ERC20 the contract ever moves
@> IERC20 private immutable i_airdropToken; // L16
@> i_airdropToken.safeTransfer(account, amount); // L39

Risk

Likelihood:

  • The failure happens on every run of the in-scope script as written. Both addresses are hard-coded literals, and the README states that this script is how the contract will be deployed. No attacker and no unusual condition is needed.

  • Nothing flags the mismatch at deployment time: deployment and funding both complete without a revert, as the PoC shows. The defect only appears once users' claims start reverting.

Impact:

  • Every claim by every recipient reverts. When the constructor address has no code, OZ 5.0.2 SafeERC20 reverts with AddressEmptyCode. When a live token sits there, the airdrop holds none of it and the transfer reverts on insufficient balance.

  • The 100 USDC sent to the airdrop in the other token has no way out. The token address is immutable, claim moves only i_airdropToken, and claimFees moves only ETH; the PoC shows the 100e6 balance unchanged after claimFees. In the reverse case, where the funded address has no code, the L18 funding call itself reverts and the script cannot ship a funded airdrop. Either way, the script as written cannot produce a working airdrop.

Proof of Concept

The PoC does not call the script directly. A DeployMirror contract reproduces the token handling of Deploy.run() with the L8, L11, L16 and L18 literals copied character for character. vm.startBroadcast/stopBroadcast are dropped, and the mirror plays the broadcaster, so it also becomes the owner. The Merkle root is a parameter, built as a 4 × 25e6 tree, so the token mismatch is tested on its own, separately from the root-amount bug. A 6-decimal ERC20 mock is etched at the funded address (ae). The tests show:

  • the baseline: one shared token address lets all four users claim;

  • the bug with no code at be: deployment succeeds silently, every claim reverts with AddressEmptyCode(be), and 100e6 stays locked after claimFees;

  • the bug with a live token at be: every claim reverts with ERC20InsufficientBalance, and 100e6 stays locked;

  • the reverse branch: funding reverts when the ae address has no code.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
​
import { Test, console2 } from "forge-std/Test.sol";
import { MerkleAirdrop, IERC20 } from "../src/MerkleAirdrop.sol";
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { IERC20Errors } from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol";
import { Address } from "@openzeppelin/contracts/utils/Address.sol";
​
/// 6-decimal stand-in for USDC. Its runtime code is etched at the hard-coded token addresses.
contract MockUSDC is ERC20 {
constructor() ERC20("USD Coin", "USDC") { }
​
function decimals() public pure override returns (uint8) {
return 6;
}
​
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
​
/// Verbatim reproduction of the token handling in script/Deploy.s.sol run() (L8, L11, L16, L18),
/// with the literals copied character-for-character. vm.startBroadcast()/stopBroadcast() are
/// dropped: this contract plays the broadcaster. The Merkle root is a parameter so the test can
/// use a tree with 6-decimal amounts and isolate the token mismatch from the separate root/decimals bug.
contract DeployMirror {
address public s_zkSyncUSDC = 0x1D17CbCf0D6d143135be902365d2e5E2a16538d4; // Deploy.s.sol L8
uint256 public s_amountToAirdrop = 4 * (25 * 1e6); // Deploy.s.sol L11
​
function run(bytes32 merkleRoot) external returns (MerkleAirdrop airdrop) {
airdrop = new MerkleAirdrop(merkleRoot, IERC20(s_zkSyncUSDC)); // Deploy.s.sol L16 (via deployMerkleDropper)
IERC20(0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4).transfer(address(airdrop), s_amountToAirdrop); // L18
}
​
receive() external payable { }
}
​
contract PoC_AD_02 is Test {
// Both literals compile in this file, so both are valid EIP-55 checksums (solc rejects bad ones).
address constant BE = 0x1D17CbCf0D6d143135be902365d2e5E2a16538d4; // token given to the constructor (L8/L16)
address constant AE = 0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4; // token actually funded (L18)
​
uint256 constant AMOUNT = 25e6; // 25 USDC, 6 decimals
uint256 constant TOTAL = 100e6;
uint256 constant FEE = 1e9;
​
address[4] internal users;
bytes32[4] internal leaves;
bytes32 internal root;
DeployMirror internal mirror;
​
function setUp() public {
users[0] = makeAddr("user0");
users[1] = makeAddr("user1");
users[2] = makeAddr("user2");
users[3] = makeAddr("user3");
for (uint256 i; i < 4; i++) {
leaves[i] = keccak256(bytes.concat(keccak256(abi.encode(users[i], AMOUNT))));
vm.deal(users[i], 1 ether);
}
root = _hashPair(_hashPair(leaves[0], leaves[1]), _hashPair(leaves[2], leaves[3]));
​
// The funded token ('ae') exists and the deployer holds 100 USDC of it.
MockUSDC impl = new MockUSDC();
vm.etch(AE, address(impl).code);
mirror = new DeployMirror();
MockUSDC(AE).mint(address(mirror), TOTAL);
}
​
/// Baseline: when the constructor token and the funded token are the same address, all four
/// recipients claim 25 USDC and the airdrop is emptied. The tree, proofs and mock are sound.
function test_baseline_matchingTokenAllFourClaim() public {
vm.startPrank(address(mirror));
MerkleAirdrop airdrop = new MerkleAirdrop(root, IERC20(AE));
IERC20(AE).transfer(address(airdrop), TOTAL);
vm.stopPrank();
​
for (uint256 i; i < 4; i++) {
vm.prank(users[i]);
airdrop.claim{ value: FEE }(users[i], AMOUNT, _proof(i));
assertEq(IERC20(AE).balanceOf(users[i]), AMOUNT, "baseline: user received 25 USDC");
}
assertEq(IERC20(AE).balanceOf(address(airdrop)), 0, "baseline: airdrop fully distributed");
}
​
/// The bug: the script's deployment succeeds silently, the airdrop holds 100 USDC of 'ae', but
/// i_airdropToken is 'be'. Every claim reverts (AddressEmptyCode) and the 100 USDC stays locked.
function test_scriptMismatch_everyClaimReverts_fundsLocked() public {
assertTrue(BE != AE, "the two literals are different addresses");
assertEq(BE.code.length, 0, "'be' has no code in this environment");
console2.log("constructor token (be):", BE);
console2.log("funded token (ae):", AE);
​
// Deployment + funding do NOT revert: nothing flags the mismatch at deploy time.
MerkleAirdrop airdrop = mirror.run(root);
​
assertEq(address(airdrop.getAirdropToken()), BE, "i_airdropToken is 'be'");
assertEq(IERC20(AE).balanceOf(address(airdrop)), TOTAL, "airdrop holds 100 USDC of 'ae'");
assertEq(IERC20(AE).balanceOf(address(mirror)), 0, "deployer spent its 100 USDC");
​
for (uint256 i; i < 4; i++) {
vm.expectRevert(abi.encodeWithSelector(Address.AddressEmptyCode.selector, BE));
vm.prank(users[i]);
airdrop.claim{ value: FEE }(users[i], AMOUNT, _proof(i));
assertEq(IERC20(AE).balanceOf(users[i]), 0, "user received nothing");
}
​
// The owner's only privileged function moves ETH, never ERC20: the 'ae' balance cannot leave.
vm.prank(address(mirror));
airdrop.claimFees();
assertEq(IERC20(AE).balanceOf(address(airdrop)), TOTAL, "100 USDC still locked after claimFees");
console2.log("'ae' balance locked in airdrop:", IERC20(AE).balanceOf(address(airdrop)));
}
​
/// Other branch: even if 'be' is a live ERC20, the airdrop holds none of it, so every claim
/// reverts on balance and the funded 'ae' tokens remain locked.
function test_scriptMismatch_beHasCode_claimsStillRevert() public {
MockUSDC implBe = new MockUSDC();
vm.etch(BE, address(implBe).code);
​
MerkleAirdrop airdrop = mirror.run(root);
assertEq(IERC20(BE).balanceOf(address(airdrop)), 0, "airdrop holds no 'be'");
assertEq(IERC20(AE).balanceOf(address(airdrop)), TOTAL, "airdrop holds 100 'ae'");
​
for (uint256 i; i < 4; i++) {
vm.expectRevert(
abi.encodeWithSelector(
IERC20Errors.ERC20InsufficientBalance.selector, address(airdrop), uint256(0), AMOUNT
)
);
vm.prank(users[i]);
airdrop.claim{ value: FEE }(users[i], AMOUNT, _proof(i));
}
assertEq(IERC20(AE).balanceOf(address(airdrop)), TOTAL, "100 'ae' still locked");
}
​
/// Opposite branch: if 'be' were the live token and 'ae' had no code, the L18 funding call
/// reverts (empty return data cannot be decoded as bool), so the script cannot ship a funded airdrop.
function test_scriptMismatch_aeHasNoCode_deploymentReverts() public {
vm.etch(AE, "");
MockUSDC implBe = new MockUSDC();
vm.etch(BE, address(implBe).code);
MockUSDC(BE).mint(address(mirror), TOTAL);
​
try mirror.run(root) returns (MerkleAirdrop) {
fail();
} catch (bytes memory reason) {
assertEq(reason.length, 0, "reverted with empty data from failed return decoding");
}
assertEq(IERC20(BE).balanceOf(address(mirror)), TOTAL, "nothing was funded");
}
​
function _proof(uint256 i) internal view returns (bytes32[] memory p) {
p = new bytes32[](2);
p[0] = leaves[i ^ 1];
p[1] = i < 2 ? _hashPair(leaves[2], leaves[3]) : _hashPair(leaves[0], leaves[1]);
}
​
function _hashPair(bytes32 a, bytes32 b) internal pure returns (bytes32) {
return a < b ? keccak256(abi.encode(a, b)) : keccak256(abi.encode(b, a));
}
}

Run from the repository root, with the file saved as test/PoC_AD-02.t.sol:

FOUNDRY_FFI=false FOUNDRY_DISABLE_NIGHTLY_WARNING=1 forge test --match-path test/PoC_AD-02.t.sol -vv

Result: Suite result: ok. 4 passed; 0 failed; 0 skipped.

Recommended Mitigation

--- a/script/Deploy.s.sol
+++ b/script/Deploy.s.sol
@@ -5,6 +5,7 @@
import { Script } from "forge-std/Script.sol";
contract Deploy is Script {
+ // single source of truth for the airdrop token: confirm on-chain that this is zkSync Era USDC
address public s_zkSyncUSDC = 0x1D17CbCf0D6d143135be902365d2e5E2a16538d4;
bytes32 public s_merkleRoot = 0xf69aaa25bd4dd10deb2ccd8235266f7cc815f6e9d539e9f4d47cae16e0c36a05;
// 4 users, 25 USDC each
@@ -15,7 +16,8 @@
vm.startBroadcast();
MerkleAirdrop airdrop = deployMerkleDropper(s_merkleRoot, IERC20(s_zkSyncUSDC));
// Send USDC -> Merkle Air Dropper
- IERC20(0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4).transfer(address(airdrop), s_amountToAirdrop);
+ IERC20(s_zkSyncUSDC).transfer(address(airdrop), s_amountToAirdrop);
+ require(airdrop.getAirdropToken().balanceOf(address(airdrop)) == s_amountToAirdrop, "airdrop not funded");
vm.stopBroadcast();
}
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!