Merkle-based airdrops have become the canonical pattern for distributing tokens to large sets of recipients. The design is elegant: instead of storing every eligible address on-chain, you commit to a Merkle root, publish proofs off-chain, and let claimants submit their own proof at claim time. Gas costs stay bounded. The contract stays lean. And yet, the simplicity of the interface hides a surprising density of attack surface.

This article dissects every meaningful attack class against Merkle distribution contracts — from proof manipulation to front-running to off-chain tree poisoning — and shows how to build a distribution that is resistant to all of them.


How Merkle Tree Based Distributions Work

A Merkle tree is a binary hash tree where every leaf node is a hash of some data element, and every internal node is a hash of its two children. The root of the tree commits to the entire dataset. To prove that a specific element belongs to the dataset, a claimant provides a proof: the sibling hashes at each level of the tree, from the leaf up to the root. The verifier recomputes the root by hashing the leaf with each sibling in sequence, and checks that the result matches the stored root.

In a token distribution contract, each leaf typically encodes a (address, amount) pair. The off-chain process:

  1. Collect all eligible (address, amount) pairs.
  2. Hash each pair to produce the leaf set.
  3. Build the Merkle tree over the leaf set.
  4. Publish the root on-chain.
  5. Publish the full tree (or at minimum, each claimant’s proof) off-chain.

At claim time, the contract verifies the proof against the stored root, marks the address as having claimed, and transfers tokens.

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

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";

contract BasicMerkleDistributor {
    address public immutable token;
    bytes32 public immutable merkleRoot;
    mapping(address => bool) public hasClaimed;

    event Claimed(address indexed claimant, uint256 amount);

    constructor(address _token, bytes32 _merkleRoot) {
        token = _token;
        merkleRoot = _merkleRoot;
    }

    function claim(uint256 amount, bytes32[] calldata proof) external {
        require(!hasClaimed[msg.sender], "Already claimed");

        bytes32 leaf = keccak256(abi.encodePacked(msg.sender, amount));
        require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");

        hasClaimed[msg.sender] = true;
        IERC20(token).transfer(msg.sender, amount);

        emit Claimed(msg.sender, amount);
    }
}

This skeleton works, but it contains subtle vulnerabilities we will now enumerate.


Proof Manipulation and Invalid Leaf Construction

The first class of attacks targets the relationship between the on-chain verifier and the leaf encoding scheme.

Second Pre-image Attacks on Leaf Hashing

The most dangerous naive implementation hashes leaves the same way it hashes internal nodes: with a single keccak256. If the contract computes keccak256(abi.encodePacked(account, amount)) for a leaf, an attacker can craft an internal node of the tree whose raw bytes happen to decode to a valid (address, amount) pair, and present it as a leaf with a shorter proof path.

The fix is standard: double-hash leaves. OpenZeppelin’s MerkleProof library expects leaves to be double-hashed, and the contract must hash identically.

// VULNERABLE: single hash, same as internal node format
bytes32 leaf = keccak256(abi.encodePacked(msg.sender, amount));

// CORRECT: double hash prevents second pre-image attacks
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encodePacked(msg.sender, amount))));

Arbitrary Proof Depth

If a contract does not constrain the maximum proof depth, an attacker may construct an artificial Merkle tree of arbitrary depth with a single leaf they control, and submit a proof for that tree. This does not break the root commitment — the stored root is fixed — but it does open edge cases in implementations that reconstruct the root differently from how the tree was built.

Always verify proofs against a root that was set during a controlled deployment process, never a root that claimants can influence.


Double-Claim Vulnerabilities and Nonce Tracking

The second attack class targets the claim tracking mechanism.

Bitmap vs. Mapping

Many early distributors used a mapping(address => bool) for claim tracking. This is correct for the single-token, single-distribution case, but fails when:

  • The same contract is redeployed with a new root (a “season” drop).
  • The same address appears in multiple distributions with different roots.
  • The contract supports multiple tokens with independent roots.

A more robust pattern uses a bitmap indexed by a claim index, which is cheaper in gas for large distributions and supports explicit per-index tracking.

// Bitmap-based claim tracking
mapping(uint256 => uint256) private claimedBitMap;

function isClaimed(uint256 index) public view returns (bool) {
    uint256 claimedWordIndex = index / 256;
    uint256 claimedBitIndex = index % 256;
    uint256 claimedWord = claimedBitMap[claimedWordIndex];
    uint256 mask = (1 << claimedBitIndex);
    return claimedWord & mask == mask;
}

function _setClaimed(uint256 index) private {
    uint256 claimedWordIndex = index / 256;
    uint256 claimedBitIndex = index % 256;
    claimedBitMap[claimedWordIndex] |= (1 << claimedBitIndex);
}

With index-based tracking, each leaf in the Merkle tree must include the index as part of the leaf encoding, making each leaf uniquely addressable.

bytes32 leaf = keccak256(bytes.concat(
    keccak256(abi.encodePacked(index, account, amount))
));

Nonce Tracking for Multi-Round Distributions

When a protocol runs recurring distributions, a distributionId nonce must be part of the leaf encoding and the claim tracking key. Without it, a proof valid in round 1 is replayable in round 2 if the root is rotated carelessly.

mapping(uint256 => mapping(uint256 => uint256)) private claimedBitMap;
// distributionId => wordIndex => bitmap

bytes32 leaf = keccak256(bytes.concat(
    keccak256(abi.encodePacked(distributionId, index, account, amount))
));

The Leaf Encoding Collision Attack

This is one of the most underappreciated vulnerabilities in Merkle distributions. It arises from the use of abi.encodePacked with variable-length or variable-count fields.

How the Collision Works

abi.encodePacked concatenates values without length prefixes. Consider a leaf that encodes (address account, uint256[] amounts). The packed encoding of (0xAlice, [100, 200]) is identical to (0xAlice, [100]) followed by 200 from an adjacent field — if the layout allows it.

More concretely, abi.encodePacked(a, b) where a = "AB" and b = "CD" is identical to abi.encodePacked("A", "BCD"). If any field in your leaf is dynamic (strings, bytes, arrays), abi.encodePacked is unsafe.

// VULNERABLE: dynamic array in packed encoding
bytes32 leaf = keccak256(abi.encodePacked(account, amounts)); 
// amounts is uint256[] — length prefix is dropped, collisions possible

// CORRECT: use abi.encode for dynamic types
bytes32 leaf = keccak256(abi.encode(account, amounts));
// abi.encode includes length prefixes, no collision possible

Address / Amount Field Order Collision

A subtler collision: if address (20 bytes) and uint256 (32 bytes) are packed, the total is 52 bytes. There is no other combination of Solidity types that produces a 52-byte encoding with the same values, so this specific case is safe. But if you add a second uint128 field, the boundary between address + uint128 and uint256 can collide with uint160 + uint96 depending on the ABI library in use. Prefer abi.encode unconditionally for leaf construction.


Front-Running Airdrop Claims

Front-running is a structural problem: Merkle proofs are public once submitted to the mempool. An attacker watching the mempool can copy a valid proof and replay it with a different recipient — unless the contract binds the proof to msg.sender.

The Vulnerable Pattern

// VULNERABLE: recipient is passed as a parameter, not enforced as msg.sender
function claim(address recipient, uint256 amount, bytes32[] calldata proof) external {
    bytes32 leaf = keccak256(bytes.concat(
        keccak256(abi.encodePacked(recipient, amount))
    ));
    require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
    // attacker can front-run with their own address as recipient
    // if the contract does not check msg.sender == recipient
    hasClaimed[recipient] = true;
    IERC20(token).transfer(recipient, amount);
}

This is correct in structure — recipient is part of the leaf, so the proof is bound to the recipient. The front-runner cannot change recipient without invalidating the proof. But if the contract does not include recipient in the leaf and instead uses a free recipient parameter, a front-runner can intercept and redirect.

The Safe Pattern: Bind to msg.sender

The simplest mitigation is to always use msg.sender as the account field in leaf construction, never a caller-supplied address.

function claim(uint256 index, uint256 amount, bytes32[] calldata proof) external {
    require(!isClaimed(index), "Already claimed");

    bytes32 leaf = keccak256(bytes.concat(
        keccak256(abi.encodePacked(index, msg.sender, amount))
    ));
    require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");

    _setClaimed(index);
    IERC20(token).transfer(msg.sender, amount);

    emit Claimed(index, msg.sender, amount);
}

Claiming on Behalf with EIP-712 Signatures

For protocols that need a relayer to submit claims on behalf of users (gas abstraction), use an EIP-712 signed message that includes the recipient, the distribution parameters, and a nonce. The relayer submits both the Merkle proof and the signature; the contract verifies both.

bytes32 public constant CLAIM_TYPEHASH = keccak256(
    "Claim(uint256 index,address account,uint256 amount,uint256 nonce)"
);

function claimWithSig(
    uint256 index,
    address account,
    uint256 amount,
    bytes32[] calldata proof,
    uint8 v, bytes32 r, bytes32 s
) external {
    bytes32 structHash = keccak256(abi.encode(
        CLAIM_TYPEHASH, index, account, amount, nonces[account]++
    ));
    bytes32 digest = _hashTypedDataV4(structHash);
    address signer = ECDSA.recover(digest, v, r, s);
    require(signer == account, "Invalid signature");

    require(!isClaimed(index), "Already claimed");
    bytes32 leaf = keccak256(bytes.concat(
        keccak256(abi.encodePacked(index, account, amount))
    ));
    require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");

    _setClaimed(index);
    IERC20(token).transfer(account, amount);
}

Off-Chain Merkle Tree Generation and Distribution Risks

The on-chain contract is only one half of the system. The off-chain components — tree generation, root publication, proof hosting — introduce their own risks.

Root Substitution by a Compromised Deployer

If the Merkle root is set by a mutable admin function rather than locked at deploy time, a compromised admin can substitute a root that redirects allocations to attacker-controlled addresses. Mitigations:

  • Set the root in the constructor and make it immutable.
  • If the root must be updatable (multi-round drops), gate updates behind a timelock and a multisig. Emit events on every update.
  • Consider committing the root to an on-chain governance process or a public announcement with a delay.
// Prefer immutable root
bytes32 public immutable merkleRoot;

constructor(bytes32 _merkleRoot) {
    merkleRoot = _merkleRoot;
}

Proof Availability Attacks

Proofs are typically hosted off-chain (IPFS, a CDN, or the protocol’s API). If proofs become unavailable before users claim, allocations are effectively frozen. Mitigations:

  • Publish the full Merkle tree to IPFS and record the CID on-chain (or in the deployment transaction calldata).
  • Use a sufficient claim window and a fallback mechanism (e.g., admin can extend the deadline).
  • Consider publishing the raw leaf data so users can reconstruct their own proof using any standard Merkle library.

Poisoned Leaf Injection During Tree Generation

If the tree generation process reads from an untrusted data source (e.g., an API that aggregates eligibility from multiple systems), an attacker who controls any upstream input can inject arbitrary (address, amount) pairs. This inflates supply or redirects tokens.

Mitigations:

  • Generate the tree from multiple independent data sources; require consensus.
  • Audit the generation script as rigorously as the contract.
  • Publish the raw input data alongside the tree so the community can verify the root independently.

Multi-Token Airdrop Contract Vulnerabilities

A single distributor contract sometimes manages multiple tokens, each with its own Merkle root. The interaction between independent distributions introduces new risks.

Cross-Distribution Proof Replay

If two distributions use the same leaf encoding schema and one has a weaker root (perhaps a test deployment), a proof valid for distribution A may be valid for distribution B if the roots happen to commit to overlapping leaf sets.

The fix is to include a distributionId or the token address in the leaf:

bytes32 leaf = keccak256(bytes.concat(
    keccak256(abi.encodePacked(token, distributionId, index, account, amount))
));

Reentrancy in Multi-Token Claims

When a distributor supports arbitrary ERC-20 tokens, it may encounter tokens with transfer hooks (ERC-777, fee-on-transfer, rebasing). A malicious token can reenter the claim function before the claimed state is set.

Always follow checks-effects-interactions: set the claimed state before the transfer.

function claim(
    address token,
    uint256 distributionId,
    uint256 index,
    uint256 amount,
    bytes32[] calldata proof
) external nonReentrant {
    require(!isClaimed(distributionId, index), "Already claimed");

    bytes32 leaf = keccak256(bytes.concat(
        keccak256(abi.encodePacked(token, distributionId, index, msg.sender, amount))
    ));
    bytes32 root = merkleRoots[token][distributionId];
    require(MerkleProof.verify(proof, root, leaf), "Invalid proof");

    // Effects before interactions
    _setClaimed(distributionId, index);

    IERC20(token).safeTransfer(msg.sender, amount);
}

Dust Griefing

If small allocations are included in the tree, an attacker can claim them on behalf of the intended recipients (if the contract allows recipient as a parameter), locking those recipients out without delivering meaningful value (since the tokens go to the attacker). The msg.sender binding pattern prevents this.


Time-Locked Airdrop Security

Many distributions include an expiry after which unclaimed tokens are recoverable by the protocol. Time-locking introduces several attack surfaces.

Last-Block Front-Running at Expiry

As the expiry block approaches, a legitimate claimant who attempts to claim in the last few blocks may be front-run by a miner or MEV searcher who either:

  • Censors the transaction until after expiry, then triggers the recovery function.
  • Front-runs the recovery to claim the tokens for themselves (if the recovery function is permissionless or misconfigured).

Mitigations:

  • Add a grace period between the “claim deadline” and the “recovery window”.
  • Make the recovery function callable only by a trusted multisig or governance, not publicly.
  • Use a sufficiently long claim window (months, not days).
uint256 public constant CLAIM_WINDOW = 365 days;
uint256 public constant RECOVERY_DELAY = 30 days;
uint256 public immutable deployedAt;

modifier withinClaimWindow() {
    require(block.timestamp <= deployedAt + CLAIM_WINDOW, "Claim window closed");
    _;
}

modifier afterRecoveryDelay() {
    require(
        block.timestamp > deployedAt + CLAIM_WINDOW + RECOVERY_DELAY,
        "Recovery delay not elapsed"
    );
    _;
}

function recoverUnclaimed(address recipient) external onlyOwner afterRecoveryDelay {
    uint256 balance = IERC20(token).balanceOf(address(this));
    IERC20(token).safeTransfer(recipient, balance);
}

Timestamp Manipulation

For short claim windows (hours or days), block timestamp manipulation by validators is a real concern. Use block numbers rather than timestamps for short windows, or add a sufficiently large buffer so that a 15-second manipulation is irrelevant.


Designing a Merkle Distribution Resistant to All Common Attacks

Combining all mitigations, the following is a hardened Merkle distributor pattern.

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

import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/access/Ownable2Step.sol";

/// @title HardenedMerkleDistributor
/// @notice Merkle-based token distributor with protections against all common
///         attack classes: double-claim, proof replay, front-running, reentrancy,
///         and off-chain root substitution.
contract HardenedMerkleDistributor is ReentrancyGuard, Ownable2Step {
    using SafeERC20 for IERC20;

    // -------------------------------------------------------------------------
    // Immutable state
    // -------------------------------------------------------------------------

    /// @notice The ERC-20 token being distributed.
    address public immutable token;

    /// @notice The Merkle root committing to the full allocation set.
    bytes32 public immutable merkleRoot;

    /// @notice Timestamp at which the claim window closes.
    uint256 public immutable claimDeadline;

    /// @notice Timestamp after which unclaimed tokens may be recovered.
    uint256 public immutable recoveryTimestamp;

    // -------------------------------------------------------------------------
    // Mutable state
    // -------------------------------------------------------------------------

    /// @dev Bitmap for O(1) claim tracking. wordIndex => bitmap.
    mapping(uint256 => uint256) private _claimedBitMap;

    // -------------------------------------------------------------------------
    // Events
    // -------------------------------------------------------------------------

    event Claimed(uint256 indexed index, address indexed account, uint256 amount);
    event Recovered(address indexed recipient, uint256 amount);

    // -------------------------------------------------------------------------
    // Constructor
    // -------------------------------------------------------------------------

    constructor(
        address _token,
        bytes32 _merkleRoot,
        uint256 _claimWindowDuration,
        uint256 _recoveryDelay
    ) Ownable2Step() {
        require(_token != address(0), "Zero token address");
        require(_merkleRoot != bytes32(0), "Zero merkle root");
        require(_claimWindowDuration >= 30 days, "Window too short");
        require(_recoveryDelay >= 7 days, "Recovery delay too short");

        token = _token;
        merkleRoot = _merkleRoot;
        claimDeadline = block.timestamp + _claimWindowDuration;
        recoveryTimestamp = claimDeadline + _recoveryDelay;
    }

    // -------------------------------------------------------------------------
    // External functions
    // -------------------------------------------------------------------------

    /// @notice Claim an allocation.
    /// @param index       Leaf index in the Merkle tree (for bitmap tracking).
    /// @param amount      Allocated token amount.
    /// @param proof       Merkle proof for the leaf (index, msg.sender, amount).
    function claim(
        uint256 index,
        uint256 amount,
        bytes32[] calldata proof
    ) external nonReentrant {
        require(block.timestamp <= claimDeadline, "Claim window closed");
        require(!isClaimed(index), "Already claimed");

        // Leaf is double-hashed to prevent second pre-image attacks.
        // abi.encode (not encodePacked) used to prevent field collision attacks.
        bytes32 leaf = keccak256(
            bytes.concat(keccak256(abi.encode(index, msg.sender, amount)))
        );

        require(
            MerkleProof.verifyCalldata(proof, merkleRoot, leaf),
            "Invalid proof"
        );

        // Effects before interactions (reentrancy safety).
        _setClaimed(index);

        IERC20(token).safeTransfer(msg.sender, amount);

        emit Claimed(index, msg.sender, amount);
    }

    /// @notice Recover unclaimed tokens after the recovery timestamp.
    /// @param recipient Address to receive recovered tokens.
    function recoverUnclaimed(address recipient) external onlyOwner {
        require(block.timestamp > recoveryTimestamp, "Recovery not yet available");
        require(recipient != address(0), "Zero recipient");

        uint256 balance = IERC20(token).balanceOf(address(this));
        require(balance > 0, "Nothing to recover");

        IERC20(token).safeTransfer(recipient, balance);

        emit Recovered(recipient, balance);
    }

    // -------------------------------------------------------------------------
    // Public view functions
    // -------------------------------------------------------------------------

    /// @notice Returns true if the allocation at `index` has been claimed.
    function isClaimed(uint256 index) public view returns (bool) {
        uint256 wordIndex = index / 256;
        uint256 bitIndex = index % 256;
        return (_claimedBitMap[wordIndex] >> bitIndex) & 1 == 1;
    }

    // -------------------------------------------------------------------------
    // Internal functions
    // -------------------------------------------------------------------------

    function _setClaimed(uint256 index) internal {
        uint256 wordIndex = index / 256;
        uint256 bitIndex = index % 256;
        _claimedBitMap[wordIndex] |= (1 << bitIndex);
    }
}

Key Design Decisions Summarized

PropertyImplementation ChoiceRationale
Leaf hashingDouble keccak256 with abi.encodePrevents second pre-image; prevents packing collisions
Claim trackingBitmap indexed by leaf indexO(1), gas-efficient, survives contract reuse
Front-run protectionmsg.sender bound in leafProof is non-transferable
Root mutabilityimmutable fieldAdmin cannot substitute root post-deploy
ReentrancynonReentrant + CEI orderingSafe against hook tokens
RecoveryOwner-only, delayed by constantPrevents griefing; owner can’t rush recovery
Claim windowMinimum 30-day floor enforcedUsers have time; short windows invite manipulation

Merkle Distribution Audit Checklist

Use this checklist when auditing or reviewing any Merkle-based distribution contract.

Leaf Encoding

  • Are leaves double-hashed (i.e., keccak256(keccak256(...))) to prevent second pre-image attacks?
  • Is abi.encode used instead of abi.encodePacked for leaf construction?
  • Does the leaf encoding include all fields required for uniqueness: (distributionId, index, account, amount)?
  • Is the leaf encoding identical between the off-chain tree generator and the on-chain verifier?

Claim Tracking

  • Is every claim indexed by a unique, stable identifier (bitmap index or mapping key)?
  • Does the claim state update happen before the token transfer (CEI pattern)?
  • Are multi-round distributions distinguished by a distributionId in the leaf and in the tracking structure?
  • Is there a test demonstrating that a double-claim reverts?

Proof Verification

  • Is the Merkle root stored as immutable or protected by a timelock + multisig?
  • Is the tree depth bounded or validated?
  • Is proof verification performed against a fixed, deployment-time root?
  • Is there a test that demonstrates an invalid proof is rejected?

Front-Running

  • Is msg.sender the account field in the leaf, or is a caller-supplied address validated against the leaf?
  • If a relayer pattern is used, is the claim signed by the recipient with an EIP-712 typed signature?
  • Does the signature scheme include a nonce to prevent replay?

Off-Chain Components

  • Is the Merkle tree data published to a permanent, content-addressed store (e.g., IPFS)?
  • Is the IPFS CID or proof dataset hash recorded in the deployment transaction calldata?
  • Is the tree generation script open-source and auditable?
  • Is the raw input data (address/amount list) published for independent root verification?
  • Is the root generation process reproducible deterministically from the published input?

Multi-Token and Multi-Distribution

  • Does each distribution include the token address and a distribution ID in the leaf?
  • Are separate bitmaps or mapping namespaces used per distribution?
  • Is safeTransfer used to handle non-standard ERC-20 tokens?
  • Is reentrancy protection applied (either nonReentrant modifier or strict CEI)?

Time-Locking

  • Is the claim window sufficiently long (30+ days minimum, ideally 6–12 months)?
  • Is there a grace period (7+ days) between the claim deadline and the recovery window?
  • Is the recovery function restricted to a trusted role (multisig or governance)?
  • Is there a test confirming that claims after the deadline revert?
  • Is there a test confirming that recovery before the recovery timestamp reverts?

General Security

  • Is the contract non-upgradeable, or if upgradeable, is the upgrade path protected by a timelock?
  • Is there a test suite covering all happy paths and all revert conditions?
  • Has the contract been fuzz-tested with arbitrary proof arrays?
  • Has the off-chain proof generation been independently reproduced from the published root inputs?
  • Is there an event emitted on every claim and every state-changing operation for off-chain monitoring?

Merkle distributions are deceptively simple at the interface level. The attack surface lives in the gap between the on-chain verifier and the off-chain proof generator, in the encoding assumptions that are implicit rather than enforced, and in the lifecycle events — deployment, claim, expiry, recovery — that each introduce a window for exploitation. A distribution that is secure is one where every one of these gaps has been explicitly closed, documented, and tested. The checklist above is not a substitute for a full audit, but it is a precise map of where auditors should look first.