Skip to content

Merkle Distributor

The Merkle Distributor contracts airdrop tokens using merkle proofs. They support several token standards and distribution patterns.

A distributor does not store recipient data on-chain. It stores only the root of a Merkle tree, which keeps gas costs low, and records which leaves have been claimed. Each claim proves its leaf against the root.

Architecture

Merkle distributor contracts and their type numbers Merkle distributor contracts and their type numbers
Merkle distributor contracts and their type numbers

Distributor instances are deployed by TTADeployer (formerly named MDCreate2) as minimal-proxy clones of a registered implementation.

The core distribution logic is in BaseMerkleDistributor. Extensions build on it for specific token types, and custom extensions cover special cases.

Core contracts

BaseMerkleDistributor

The abstract base contract with the core distribution logic. Each deployed distributor is initialized with initialize(address owner_), which sets the owner and records the calling contract (TTADeployer) as the distributor's deployer.

Key features

Storage layout (ERC7201)

Storage uses the ERC7201 namespaced storage pattern, under the namespace ethsign.misc.BaseMerkleDistributor:

struct BaseMerkleDistributorStorage {
    mapping(bytes32 leaf => bool used) usedLeafs;
    bytes32 root;
    address deployer;
    address token;
    address claimDelegate;
    uint256 startTime;
    uint256 endTime;
    address claimHook;
}
Leaf encoding

Each leaf commits to the chain, the distributor address, the recipient, a group value and variant-specific data:

function encodeLeaf(address user, bytes32 group, bytes memory data) public view virtual returns (bytes32) {
    return keccak256(abi.encode(block.chainid, address(this), user, group, data));
}

The contract checks proofs against the stored root with Solady's MerkleProofLib, which hashes each pair of nodes in sorted order. Every leaf includes the chain ID and contract address, so a tree built for one distributor cannot be reused on another distributor or chain. The data layout depends on the extension (for example TokenTableMerkleDistributorData below).

Claim mechanism

There are two ways to claim. In a direct claim, recipients claim their own tokens (msg.sender is the recipient). In a delegate claim, the single claim delegate set by the owner claims on behalf of a recipient, and the tokens still go to the recipient.

function claim(
    bytes32[] calldata proof,
    bytes32 group,
    bytes calldata data,
    bytes calldata extraData
) external payable;

function delegateClaim(
    address recipient,
    bytes32[] calldata proof,
    bytes32 group,
    bytes calldata data,
    bytes calldata extraData
) external payable;

A claim runs in this order:

  1. onlyActive: reverts with TimeInactive unless startTime <= block.timestamp <= endTime.
  2. If a hook is set, the contract calls its beforeClaim.
  3. The contract computes the leaf with encodeLeaf(recipient, group, data) and checks it with verify(proof, leaf), which reverts with LeafUsed if the leaf was already claimed and InvalidProof if the proof does not match the root.
  4. The contract marks the leaf as used and sends the tokens (extension-specific).
  5. The contract calls the hook's afterClaim, then charges fees (see fee system).
  6. The contract emits Claimed(recipient, group, data).

extraData is not part of the leaf. The contract only forwards it to the claim hook.

Claim hooks

The owner can set an optional hook with setClaimHook(address hook). The hook implements IClaimHook, and the distributor calls it before and after every claim and delegate claim. If the hook reverts, the claim reverts, so a hook can enforce extra access rules.

interface IClaimHook {
    function beforeClaim(
        address delegate,
        address recipient,
        bytes32[] calldata proof,
        bytes32 group,
        bytes calldata data,
        bytes calldata extraData
    ) external;

    function afterClaim(
        address delegate,
        address recipient,
        bytes32[] calldata proof,
        bytes32 group,
        bytes calldata data,
        uint256 claimedAmount,
        bytes calldata extraData
    ) external;
}

delegate is address(0) for direct claims and the claim delegate's address for delegate claims.

Administration and views
Function Description
setBaseParams(address token, uint256 startTime, uint256 endTime, bytes32 root) Owner only. Sets the token, claim window and Merkle root. Reverts with UnsupportedOperation if startTime >= endTime. Can be called again to replace them.
setClaimHook(address hook) Owner only. Sets or clears (address(0)) the claim hook
setClaimDelegate(address delegate) Owner only. Sets the claim delegate and emits ClaimDelegateSet
withdraw(bytes extraData) Owner only. Withdraws tokens to the owner; behavior depends on the extension
getRoot(), getTime(), getToken(), getDeployer(), getClaimHook(), getClaimDelegate() Read the stored configuration. getTime() returns (startTime, endTime)
isLeafUsed(bytes32 leaf) Returns whether a leaf has been claimed
encodeLeaf(...), verify(...) Leaf hashing and proof check used by claims
version() Returns the version string, for example 0.4.7-TokenTableMerkleDistributor

Extension contracts

ERC20 extensions

TokenTableMerkleDistributor

Distributes an ERC20 token. Each claim unlocks at its own timestamp.

struct TokenTableMerkleDistributorData {
    uint256 index;
    uint256 claimableTimestamp;
    uint256 claimableAmount;
}
  • data is abi.encode(TokenTableMerkleDistributorData); decodeLeafData(bytes) decodes it.
  • A leaf can only be claimed when startTime <= claimableTimestamp <= block.timestamp; otherwise the claim reverts with OutsideClaimableTimeRange. To split an allocation into several unlocks, give each unlock its own leaf with its own claimableTimestamp.
  • The contract transfers claimableAmount of token to the recipient.
  • withdraw sends the contract's whole token balance to the owner.

TokenTableNativeMerkleDistributor

Distributes the chain's native token (for example ETH) with the same leaf data as TokenTableMerkleDistributor.

  • Accepts native tokens through receive()
  • setBaseParams reverts with UnsupportedOperation unless token is address(0)
  • withdraw sends the contract's whole native balance to the owner

TokenTableMerkleDistributorWithFees

Distributes an ERC20 or native token (token == address(0)) where each leaf also states the fee to pay for that claim.

struct TokenTableMerkleDistributorWithFeesData {
    uint256 index;
    uint256 claimableTimestamp;
    uint256 claimableAmount;
    uint256 fees;
    address feesToken;
}
  • Decoded with decodeLeafData(bytes); the claimable time rule is the same as TokenTableMerkleDistributor.
  • If feesToken is address(0), msg.value must equal fees; otherwise msg.value must be 0. Any other value reverts with IncorrectFees.
  • If feesToken equals the airdrop token, the recipient receives claimableAmount - fees and fees goes to the fee collector. Otherwise the recipient receives the full claimableAmount and fees of feesToken is sent from the contract's balance to the fee collector.
  • The fee collector is the one TTADeployer returns for this distributor (feeCollectors(address(this))). The contract does not check that one is set, so configure it before claims open. This variant does not use the shared fee-collector fee logic.
  • receive() accepts native tokens; withdraw sends the whole balance of token (native or ERC20) to the owner.

TokenTableMerkleMultitokenDistributorBatched

Distributes several tokens from one contract. One leaf covers a whole batch of claims, so a single proof releases every claim in the batch.

struct BatchClaimData {
    uint256 packedRecipientTimeToken; // recipient (160 bits) | claimableTimestamp << 160 (40 bits) | tokenIndex << 200 (56 bits)
    uint256 claimableAmount;
}

function batchClaim(BatchClaimData[] calldata batchData, bytes32[] calldata proof) external;
  • The leaf is generateGroupHash(batchData), which is keccak256(abi.encode(block.chainid, address(this), batchData)), not encodeLeaf.
  • Any address can call batchClaim; each entry's tokens go to the recipient packed into that entry. Every entry must satisfy startTime <= claimableTimestamp <= block.timestamp.
  • packRecipientTimeToken and unpackRecipientTimeToken build and read the packed field.
  • The owner sets the token list with setTokens(address[]) (address(0) is the native token). It reverts with EmptyTokensArray for an empty list and with ClaimsAlreadyActive once a root is set and startTime has passed. getTokens() returns the list.
  • setBaseParams ignores its token argument and stores address(0).
  • The owner can pause() and unpause() batch claims.
  • claim reverts with IndividualClaimNotAllowed, and delegate claims cannot succeed either. Batch claims do not call the claim hook, do not charge fees and do not emit Claimed.
  • withdraw with empty extraData withdraws every listed token to the owner; with abi.encode(tokenAddress) it withdraws that one token.

ERC721 extensions

SimpleERC721MerkleDistributor

Mints new NFTs to recipients. Uses TokenTableMerkleDistributorData.

  • Calls safeMint(recipient) on the NFT contract (IERC721SafeMintable) claimableAmount times, so the NFT contract must allow this distributor to mint.
  • withdraw always reverts with UnsupportedOperation, because the contract holds no NFTs.

SimpleNoMintERC721MerkleDistributor

Distributes existing NFTs that the contract holds. Uses TokenTableMerkleDistributorData.

  • Transfers the NFTs with safeTransferFrom.
  • Uses the leaf's claimableAmount field as the tokenId, so each leaf transfers one NFT.
  • The owner can withdraw specific token IDs with withdraw(abi.encode(uint256[] tokenIds)).

Custom extensions

CustomFeesNativeMerkleDistributor

Native token distributor with a fee threshold. Extends TokenTableNativeMerkleDistributor.

  • The owner sets feelessThreshold with setFeelessThreshold(uint256); getFeelessThreshold() returns it.
  • Claims whose amount is below the threshold pay no fee.
  • The claim flow is changed for fees: the contract sends the native tokens while it charges fees, after the afterClaim hook. If the fee token is native, the fee is deducted from the claimed amount and sent to the fee collector. If it is an ERC20, the fee is pulled from the claimer and the full amount is sent.
  • msg.value must be 0, otherwise the claim reverts with IncorrectFees.
  • Delegate claims are disabled and revert with UnsupportedOperation.

NFTGatedMerkleDistributor

Claims are gated by NFT ownership.

struct NFTGatedMerkleDistributorData {
    TokenTableMerkleDistributorData base;
    uint256 expiryTimestamp; // The airdrop can't be claimed past this timestamp. 0 == no expiry.
    uint256 nftTokenId; // Used to infer the eligible claimer (aka current owner or an address authorized by owner through delegate.xyz)
}
  • Leaves are encoded with address(0) as the user. The caller must be the current owner of nftTokenId in the configured NFT collection, or an address the owner has delegated to for this contract through delegate.xyz. Otherwise the claim reverts with UnsupportedOperation.
  • The delegate.xyz registry is at 0x00000000000000447e69651d841bD8D104Bed493. The constructor reverts on chains where delegate.xyz is not supported.
  • Tokens always go to the NFT's current owner.
  • The owner sets the NFT collection once with setNFT(address); getNFT() returns it.
  • Leaf data is decoded with decodeMOCALeafData(bytes); decodeLeafData reverts.
  • Claims also revert with OutsideClaimableTimeRange after a non-zero expiryTimestamp.
  • setClaimDelegate and delegateClaim revert with UnsupportedOperation, and fee collection is disabled.
  • The current deployment scripts mark this type as disabled, so it is not registered on TTADeployer by default.

Fee system

TokenTable service fees are configured outside the distributor: TTADeployer decides which fee collector applies to each distributor, and the fee collector (ITTFeeCollector, implemented by TTFeeCollector) decides the amount and the fee token. The distributor's owner cannot change them. The base flow below applies to TokenTableMerkleDistributor, TokenTableNativeMerkleDistributor and the ERC721 extensions. CustomFeesNativeMerkleDistributor modifies it, TokenTableMerkleDistributorWithFees replaces it with per-leaf fees, and NFTGatedMerkleDistributor and TokenTableMerkleMultitokenDistributorBatched charge no fees.

Fee flow

Merkle claim, hook and fee flow Merkle claim, hook and fee flow
Merkle claim, hook and fee flow

After the distributor sends the tokens and the afterClaim hook runs:

  1. The fee collector is TTADeployer.feeCollectors(distributor). If none is set, no fee is charged and msg.value must be 0.
  2. The fee is ITTFeeCollector(feeCollector).getFee(distributor, claimedAmount). If it is 0, no fee is charged and msg.value must be 0.
  3. The fee token is TTADeployer.feeTokens(distributor), which returns getFeeToken(distributor) from the fee collector.
  4. Native (address(0)): msg.value must equal the fee exactly, and it is forwarded to the fee collector.
  5. ERC20: msg.value must be 0, and the fee is transferred from the payer to the fee collector with transferFrom, so the payer must approve the distributor first.
  6. Any mismatch reverts with IncorrectFees.

The payer is the claimer for claim and the claim delegate for delegateClaim.

Configuration

Setting Source
Fee collector The address that receives fees. TTADeployer's owner sets a default with setDefaultFeeParams and per-distributor overrides with setDeploymentFeeParams
Fee token Native or ERC20, returned by the fee collector's getFeeToken
Fee amount The external ITTFeeCollector interface. In TTFeeCollector, a fixed fee set for the distributor takes priority, then a basis-point fee on the claimed amount (at most 1,000 bips), then the default fee

Security

Access control

Role Permissions
Owner Set base parameters and root, set hook and claim delegate, withdraw tokens, configure extensions
Delegate Claim on behalf of recipients (one address, set by the owner)
Users Claim their allocated tokens
TTADeployer / fee collector owners Choose the fee collector, fee amount and fee token applied to the distributor

Protections

The claim functions (claim, delegateClaim, batchClaim) have reentrancy guards. Every claim must fall inside the distribution window and carry a valid merkle proof, and leaf tracking prevents double claims. Leaves include the chain ID and distributor address, which prevents replay on another distributor or chain.

Events and errors

Events

Event Description
ClaimDelegateSet(address delegate) Delegate address updated
Claimed(address recipient, bytes32 group, bytes data) Successful token claim (not emitted by batchClaim)

Errors

Error Condition Selector
UnsupportedOperation Operation not allowed 0x9ba6061b
TimeInactive Outside claim window 0x0c143eb8
InvalidProof Merkle proof invalid 0x09bde339
LeafUsed Already claimed 0x0e4b0ab2
IncorrectFees Fee amount mismatch 0x1669aa83
OutsideClaimableTimeRange Leaf's claimable timestamp not reached, before startTime, or past expiry (extensions) 0xf5d66d05
Unauthorized Caller is not the owner or the claim delegate 0x82b42900
ETHTransferFailed Native transfer failed (WithFees, MultitokenBatched) 0xb12d13eb
IndividualClaimNotAllowed claim called on MultitokenBatched 0x16d8614d
EmptyTokensArray setTokens called with an empty list 0xe75d7d0c
ClaimsAlreadyActive setTokens called after claims started 0xe738d608
InvalidTimestamp Packed timestamp does not fit in 40 bits 0xb7d09497
InvalidTokenIndex Packed token index does not fit in 56 bits 0x609f982f
Paused batchClaim called while paused 0x9e87fac8