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¶
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:
onlyActive: reverts withTimeInactiveunlessstartTime <= block.timestamp <= endTime.- If a hook is set, the contract calls its
beforeClaim. - The contract computes the leaf with
encodeLeaf(recipient, group, data)and checks it withverify(proof, leaf), which reverts withLeafUsedif the leaf was already claimed andInvalidProofif the proof does not match the root. - The contract marks the leaf as used and sends the tokens (extension-specific).
- The contract calls the hook's
afterClaim, then charges fees (see fee system). - 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;
}
dataisabi.encode(TokenTableMerkleDistributorData);decodeLeafData(bytes)decodes it.- A leaf can only be claimed when
startTime <= claimableTimestamp <= block.timestamp; otherwise the claim reverts withOutsideClaimableTimeRange. To split an allocation into several unlocks, give each unlock its own leaf with its ownclaimableTimestamp. - The contract transfers
claimableAmountoftokento the recipient. withdrawsends 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() setBaseParamsreverts withUnsupportedOperationunlesstokenisaddress(0)withdrawsends 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 asTokenTableMerkleDistributor. - If
feesTokenisaddress(0),msg.valuemust equalfees; otherwisemsg.valuemust be 0. Any other value reverts withIncorrectFees. - If
feesTokenequals the airdrop token, the recipient receivesclaimableAmount - feesandfeesgoes to the fee collector. Otherwise the recipient receives the fullclaimableAmountandfeesoffeesTokenis 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;withdrawsends the whole balance oftoken(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 iskeccak256(abi.encode(block.chainid, address(this), batchData)), notencodeLeaf. - Any address can call
batchClaim; each entry's tokens go to the recipient packed into that entry. Every entry must satisfystartTime <= claimableTimestamp <= block.timestamp. packRecipientTimeTokenandunpackRecipientTimeTokenbuild and read the packed field.- The owner sets the token list with
setTokens(address[])(address(0)is the native token). It reverts withEmptyTokensArrayfor an empty list and withClaimsAlreadyActiveonce a root is set andstartTimehas passed.getTokens()returns the list. setBaseParamsignores itstokenargument and storesaddress(0).- The owner can
pause()andunpause()batch claims. claimreverts withIndividualClaimNotAllowed, and delegate claims cannot succeed either. Batch claims do not call the claim hook, do not charge fees and do not emitClaimed.withdrawwith emptyextraDatawithdraws every listed token to the owner; withabi.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)claimableAmounttimes, so the NFT contract must allow this distributor to mint. withdrawalways reverts withUnsupportedOperation, 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
claimableAmountfield as thetokenId, 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
feelessThresholdwithsetFeelessThreshold(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
afterClaimhook. 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.valuemust be 0, otherwise the claim reverts withIncorrectFees.- 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 ofnftTokenIdin the configured NFT collection, or an address the owner has delegated to for this contract through delegate.xyz. Otherwise the claim reverts withUnsupportedOperation. - 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);decodeLeafDatareverts. - Claims also revert with
OutsideClaimableTimeRangeafter a non-zeroexpiryTimestamp. setClaimDelegateanddelegateClaimrevert withUnsupportedOperation, 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¶
After the distributor sends the tokens and the afterClaim hook runs:
- The fee collector is
TTADeployer.feeCollectors(distributor). If none is set, no fee is charged andmsg.valuemust be 0. - The fee is
ITTFeeCollector(feeCollector).getFee(distributor, claimedAmount). If it is 0, no fee is charged andmsg.valuemust be 0. - The fee token is
TTADeployer.feeTokens(distributor), which returnsgetFeeToken(distributor)from the fee collector. - Native (
address(0)):msg.valuemust equal the fee exactly, and it is forwarded to the fee collector. - ERC20:
msg.valuemust be 0, and the fee is transferred from the payer to the fee collector withtransferFrom, so the payer must approve the distributor first. - 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 |