AirDropper

AI First Flight #5
Beginner FriendlyDeFiFoundry
EXP
View results
Submission Details
Impact: medium
Likelihood: medium
Invalid

Unchecked return value of IERC20.transfer() in Deploy::run() allows deployment to silently succeed with an unfunded airdrop contract

Description

  • The deploy script is intended to fund the MerkleAirdrop contract with 100 USDC immediately after deployment so that eligible addresses can begin claiming.

  • However, the transfer() call used to send USDC to the contract does not check the return value. The ERC-20 standard specifies that transfer() returns a bool indicating success or failure — some non-standard tokens return false instead of reverting on failure. If the transfer silently fails, the deployment transaction completes without error, the deployer believes the contract is fully funded, and all subsequent claim() calls revert because the contract holds zero tokens.

function run() public {
vm.startBroadcast();
MerkleAirdrop airdrop = deployMerkleDropper(s_merkleRoot, IERC20(s_zkSyncUSDC));
// @> Return value of transfer() is not captured or checked
// @> If transfer() returns false, deployment succeeds silently with 0 USDC in contract
IERC20(0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4).transfer(address(airdrop), s_amountToAirdrop);
vm.stopBroadcast();
}

Risk

Likelihood:

  • The deployer has an insufficient USDC balance at the time of deployment — transfer() returns false on non-reverting tokens, the script continues without error, and the contract is deployed with zero funds

  • The token at the provided address behaves as a non-standard ERC-20 that returns false on failure instead of reverting — a known pattern in older or custom token implementations

Impact:

  • The MerkleAirdrop contract is deployed in a permanently broken state — all claim() calls revert with an insufficient balance error, and no eligible address can receive their allocation

  • The deployer has no on-chain signal that the funding step failed, as the deployment transaction emits no error and the script exits cleanly — the broken state may go undetected until users begin reporting failed claims

Proof of Concept

The vulnerability stems from the fact that a successful transaction does not guarantee a successful token transfer. When transfer() returns false without reverting, the EVM treats the outer call as successful. Because the script captures no return value and performs no balance assertion afterward, the broken state is invisible at deployment time. The sequence below illustrates how this plays out:

// Conceptual failure flow:
//
// 1. Deployer runs the script with insufficient USDC balance
// (or token silently fails for any other reason)
//
// 2. deployMerkleDropper() succeeds — MerkleAirdrop is deployed at address X
//
// 3. IERC20(...).transfer(address(airdrop), 100e6) is called:
// → token.balanceOf(deployer) < 100e6
// → non-reverting token returns: false
// → EVM sees no revert — outer call continues normally
//
// 4. vm.stopBroadcast() — script exits with no error
// Deployer sees: "Deployment successful"
//
// 5. Reality:
// token.balanceOf(address(airdrop)) == 0 // contract is empty
//
// 6. First user calls claim():
// → i_airdropToken.safeTransfer(user, 25e6)
// → SafeERC20 checks return value → reverts with ERC20InsufficientBalance
// → all four claimants permanently blocked

Recommended Mitigation

Replace the raw IERC20.transfer() call with SafeERC20.safeTransfer(), which internally checks the return value and reverts if the transfer fails. Additionally, add a post-transfer balance assertion to confirm the contract received the expected amount — this catches edge cases such as fee-on-transfer tokens that deliver less than the requested amount.

import { MerkleAirdrop, IERC20 } from "../src/MerkleAirdrop.sol";
import { Script } from "forge-std/Script.sol";
+ import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract Deploy is Script {
+ using SafeERC20 for IERC20;
function run() public {
vm.startBroadcast();
MerkleAirdrop airdrop = deployMerkleDropper(s_merkleRoot, IERC20(s_zkSyncUSDC));
- IERC20(0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4).transfer(address(airdrop), s_amountToAirdrop);
+ IERC20(s_zkSyncUSDC).safeTransfer(address(airdrop), s_amountToAirdrop);
+ require(
+ IERC20(s_zkSyncUSDC).balanceOf(address(airdrop)) == s_amountToAirdrop,
+ "Deploy: airdrop contract underfunded"
+ );
vm.stopBroadcast();
}
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!