Why use a pull-based airdrop?

A pull-based design separates allocation from distribution. The contract owner or a backend system loads claim data once, and recipients later call a claim() function to receive their tokens.

This is useful when:

  • the recipient set is large
  • some recipients may be contracts with custom logic
  • you want to avoid batch transfer gas spikes
  • you need a clear audit trail of claims
  • you want to prevent double claims with minimal state

Compared with a direct transfer loop, a claim-based model is easier to reason about and safer to operate in production.

Core design goals

A secure claim contract should:

  • verify that a caller is eligible
  • prevent the same allocation from being claimed twice
  • use checks-effects-interactions ordering
  • handle token transfers safely
  • support owner-controlled setup without exposing arbitrary minting or claiming

Contract architecture

A practical airdrop claim contract usually needs three parts:

  1. Allocation storage — how much each address can claim
  2. Claim tracking — whether an address has already claimed
  3. Token transfer logic — sending ERC20 tokens to the claimant

A minimal version can store a mapping from address to amount and a boolean claim flag. For better gas efficiency, you can store a Merkle root instead of every address on-chain, but the direct mapping approach is easier to understand and is ideal for smaller distributions or tutorials.

Storage model

ComponentPurposeNotes
allocationsAmount each address may claimSimple, direct, easy to audit
claimedPrevents double claimsMust be updated before external calls
tokenERC20 token being distributedShould be immutable if possible
ownerAdmin who loads allocationsUse a standard ownership pattern

Example: secure claim contract

The following example demonstrates a straightforward pull-based airdrop for ERC20 tokens. It uses OpenZeppelin-style interfaces and ownership semantics, but keeps the logic focused on the claim flow.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

interface IERC20 {
    function transfer(address to, uint256 amount) external returns (bool);
}

abstract contract Ownable {
    address public owner;

    event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);

    constructor() {
        owner = msg.sender;
        emit OwnershipTransferred(address(0), msg.sender);
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }

    function transferOwnership(address newOwner) external onlyOwner {
        require(newOwner != address(0), "Zero address");
        emit OwnershipTransferred(owner, newOwner);
        owner = newOwner;
    }
}

contract TokenAirdropClaim is Ownable {
    IERC20 public immutable token;

    mapping(address => uint256) public allocations;
    mapping(address => bool) public claimed;

    event AllocationSet(address indexed account, uint256 amount);
    event Claimed(address indexed account, uint256 amount);

    constructor(address tokenAddress) {
        require(tokenAddress != address(0), "Zero token");
        token = IERC20(tokenAddress);
    }

    function setAllocation(address account, uint256 amount) external onlyOwner {
        require(account != address(0), "Zero account");
        require(!claimed[account], "Already claimed");
        allocations[account] = amount;
        emit AllocationSet(account, amount);
    }

    function setAllocations(address[] calldata accounts, uint256[] calldata amounts) external onlyOwner {
        require(accounts.length == amounts.length, "Length mismatch");

        for (uint256 i = 0; i < accounts.length; i++) {
            address account = accounts[i];
            uint256 amount = amounts[i];

            require(account != address(0), "Zero account");
            require(!claimed[account], "Already claimed");

            allocations[account] = amount;
            emit AllocationSet(account, amount);
        }
    }

    function claim() external {
        address account = msg.sender;
        require(!claimed[account], "Already claimed");

        uint256 amount = allocations[account];
        require(amount > 0, "Nothing to claim");

        claimed[account] = true;
        allocations[account] = 0;

        require(token.transfer(account, amount), "Token transfer failed");

        emit Claimed(account, amount);
    }

    function recoverUnclaimed(address to, uint256 amount) external onlyOwner {
        require(to != address(0), "Zero address");
        require(token.transfer(to, amount), "Recovery failed");
    }
}

How the claim flow works

The contract follows a simple sequence:

  1. The owner sets allocations for eligible addresses.
  2. The contract holds enough ERC20 tokens to cover all claims.
  3. A user calls claim().
  4. The contract checks eligibility and claim status.
  5. The contract marks the claim as used.
  6. The contract transfers tokens to the caller.

The most important detail is step 5: state changes happen before the external token transfer. This reduces the risk of reentrancy-related issues if the token is non-standard or malicious.

Why claimed and allocations both exist

You might wonder why the contract stores both a claimed flag and an allocation amount. The answer is clarity and safety:

  • claimed is the authoritative source for whether the address already used its entitlement.
  • allocations records the original claimable amount and can be cleared after use.

This dual-state approach makes it easier to inspect contract state and reduces ambiguity in off-chain tooling.


Best practices for production use

1. Prefer immutable token addresses

If the distributed token is fixed, declare it immutable. This prevents accidental changes and makes the contract easier to audit.

2. Validate funding before launch

Before enabling claims, ensure the contract holds enough tokens to cover all allocations. A common operational mistake is publishing allocations without depositing sufficient balance.

A simple off-chain check is:

  • sum all allocations
  • compare against token.balanceOf(address(this))
  • add a safety buffer for rounding or future adjustments

3. Emit events for every administrative action

Events are essential for indexing and transparency. AllocationSet and Claimed allow frontends, analytics tools, and auditors to reconstruct distribution history.

4. Keep admin functions narrow

The owner should be able to set allocations and recover unclaimed tokens, but not arbitrarily alter already claimed balances. That distinction protects users from post-claim tampering.

5. Consider a claim deadline

Many airdrops should not remain open forever. Adding a deadline can simplify accounting and allow the owner to recover unclaimed tokens after a fixed period.


Adding a claim deadline

A deadline is a common extension for real deployments. It limits the claim window and prevents indefinite liability.

uint256 public immutable claimDeadline;

constructor(address tokenAddress, uint256 deadline) {
    require(tokenAddress != address(0), "Zero token");
    require(deadline > block.timestamp, "Invalid deadline");
    token = IERC20(tokenAddress);
    claimDeadline = deadline;
}

function claim() external {
    require(block.timestamp <= claimDeadline, "Claim period ended");

    address account = msg.sender;
    require(!claimed[account], "Already claimed");

    uint256 amount = allocations[account];
    require(amount > 0, "Nothing to claim");

    claimed[account] = true;
    allocations[account] = 0;

    require(token.transfer(account, amount), "Token transfer failed");

    emit Claimed(account, amount);
}

This pattern is especially useful when the airdrop is tied to a campaign, product launch, or governance snapshot.


Handling ERC20 transfer edge cases

Not all ERC20 tokens behave perfectly. Some return false instead of reverting, while others may have fee-on-transfer behavior. For a claim contract, the safest assumption is to distribute a standard ERC20 token with a reliable transfer() implementation.

If you need broader compatibility, use a safe transfer wrapper that checks return values and handles tokens that do not return a boolean consistently. In production, many teams rely on audited libraries rather than writing custom token wrappers.

When to avoid fee-on-transfer tokens

Fee-on-transfer tokens complicate airdrops because recipients may receive less than the recorded allocation. If exact distribution matters, avoid such tokens or explicitly document the net amount users should expect.


Optional upgrade: Merkle-based claims

For large distributions, storing every allocation on-chain can become expensive. A Merkle-based claim system replaces the allocations mapping with a Merkle root and proof verification.

ApproachGas costComplexityBest for
Direct mappingHigher on setupLowSmall to medium distributions
Merkle claimsLower on-chain storageMediumLarge distributions
Signature-based claimsLow storageMedium to highDynamic or off-chain managed campaigns

Merkle claims are a strong next step when you need thousands of recipients. The tradeoff is that users must provide a proof, and the contract must verify it correctly. For many teams, the direct mapping version is the right starting point before moving to a proof-based design.


Testing scenarios you should cover

A claim contract should be tested with realistic edge cases:

  • a valid claimant receives the correct amount
  • a non-eligible address cannot claim
  • a claimant cannot claim twice
  • the owner can set allocations in batches
  • zero-address allocations are rejected
  • claims fail after the deadline, if implemented
  • recovery functions cannot steal claimable balances prematurely

Example test checklist

Test caseExpected result
Claim with no allocationRevert with Nothing to claim
Claim twiceSecond call reverts
Set allocation for zero addressRevert
Batch allocation length mismatchRevert
Transfer failure from tokenRevert
Claim after deadlineRevert if deadline enabled

Testing both happy paths and failure paths is especially important because claim contracts are often deployed once and then left immutable.


Operational deployment tips

Airdrop contracts are often part of a larger release process. A safe deployment workflow looks like this:

  1. Deploy the ERC20 token or confirm the token contract address.
  2. Deploy the claim contract with the correct token address.
  3. Load allocations through a script or admin tool.
  4. Transfer enough tokens into the claim contract.
  5. Verify a few sample claims on a test wallet.
  6. Publish the claim instructions and contract address.

If you are distributing to a public audience, provide a simple frontend or script that shows:

  • whether the user is eligible
  • how much they can claim
  • whether they already claimed
  • the claim deadline, if any

This reduces support load and prevents user confusion.


Common mistakes to avoid

Forgetting to fund the contract

Allocations are not enough; the contract must actually hold the tokens.

Updating allocations after claims start

If you allow post-launch changes, make sure they cannot overwrite claimed state.

Using loops for on-chain distribution

Mass transfer loops are brittle and can fail due to gas limits.

Ignoring token behavior

Always confirm the token’s transfer semantics before integrating it.

Omitting event logs

Without events, off-chain monitoring becomes much harder.


Conclusion

A pull-based token airdrop claim contract is a practical pattern for safe, scalable distribution. It avoids the fragility of mass transfers, gives users control over claiming, and keeps the on-chain logic simple enough to audit.

For most teams, the direct mapping version is the best starting point. Once the distribution grows, you can evolve the design toward Merkle proofs or signature-based claims while preserving the same core principle: recipients pull their tokens when they are ready.

Learn more with useful resources