Merkle Distributor¶
The Merkle Distributor runs token airdrops that recipients claim with Merkle proofs. An airdrop does not keep its recipient list on-chain. It stores the root of a Merkle tree built from the claims, plus one small record for each leaf that has been claimed. Each claim carries a proof that the program checks against the root. The distributor supports multiple token types and distribution patterns.
System overview¶
Account structure¶
Storage pattern¶
The program has one config account. It stores the admin's address, the default fee collector's address, and the address of a pending new admin for two-step ownership transfers.
One deployment can manage many airdrops. Each airdrop is stored as follows:
pub struct MerkleAirdrop {
pub owner: Pubkey,
pub root: [u8; 32],
pub project_token: Pubkey,
pub claim_delegate: Pubkey,
pub start_time: u64,
pub end_time: u64,
pub fee_collector: Pubkey,
pub fee_token: Pubkey,
pub paused: bool,
pub uri: String,
}
Each claim is described by the following data. It has no account of its own: the caller passes it to claim(), and the program hashes it into the Merkle leaf.
pub struct TokenTableMerkleDistributorData {
pub index: u64,
pub claimable_timestamp: u64,
pub claimable_amount: u64,
}
The leaf is the Keccak-256 hash of the Borsh-serialized Leaf struct, so each leaf is bound to one airdrop account and one recipient:
pub struct Leaf {
pub airdrop: Pubkey,
pub user: Pubkey,
pub group: [u8; 32],
pub data: TokenTableMerkleDistributorData,
}
When a leaf is claimed, the program creates a UsedLeaf account for it. Because the account is created with init, a second claim of the same leaf fails:
PDA seeds¶
| Account | Seeds | Count |
|---|---|---|
config |
"config" |
One per program |
version |
"version" |
One per program (created by version) |
airdrop |
"airdrop", project_id |
One per project ID |
vault |
"vault", airdrop address |
One per airdrop; token authority is the airdrop PDA |
used_leaf |
"usedLeaf", leaf hash |
One per claimed leaf |
Claim mechanism¶
Recipients can claim their own tokens, or the airdrop's single claim_delegate (set by the owner with set_claim_delegate) can claim for them. Either way, the tokens go to the recipient's associated token account.
pub fn claim(
ctx: Context<Claim>,
project_id: String,
leaf: [u8; 32],
proof: Vec<[u8; 32]>,
group: [u8; 32],
data: TokenTableMerkleDistributorData
) -> Result<()>
A claim succeeds only when the airdrop is not paused, the current time is strictly between start_time and end_time, the supplied leaf matches the leaf the program computes, the proof verifies against root, and data.claimable_timestamp is after start_time and before the current time.
Fee system¶
On every claim, the distributor makes a CPI to the fee collector's collect_fee instruction with the claimed amount. The account that signs the claim (the recipient or the delegate) pays the fee. It is not deducted from the claimed tokens.
The fee collector is the external FeeCollector program, which calculates fees and manages the vault accounts. Each airdrop stores its own fee_collector, copied from default_fee_collector at initialization. The fee for a project is read from the fee collector's Fee account (seeds "fee", distributor program ID, project_id). If that account is set to default, the fee is claimed_amount * default_fees_bips / 10000. Otherwise the fee collector uses a fixed fee if one is set, and custom bips if not.
Fees are paid in native SOL when fee_token is the default public key, and in an SPL token otherwise. The program admin sets the fee token per airdrop with set_fee_token.
Fee tokens are held in Program Derived Accounts (PDAs). SOL fees go to the fee collector's storage PDA, and SPL fees go to the fee collector's vault PDA (seeds "vault", fee token mint).
Security¶
Access control¶
| Role | Permissions |
|---|---|
| Program Admin | Set default fee collector, set an airdrop's fee collector and fee token, transfer program admin |
| Airdrop Owner | Set base parameters (token, time window, root, URI), set claim delegate, toggle pause, withdraw tokens, transfer airdrop ownership |
| Delegate | Claim on behalf of recipients |
| Users | Claim their allocated tokens |
deposit has no owner check: any account can deposit the airdrop's token into the vault.
Claim safeguards¶
The airdrop owner can pause and unpause claims with toggle_pause. A claim must fall inside the airdrop's time window and carry a valid Merkle proof, and the per-leaf record blocks a second claim of the same leaf.
Events and errors¶
Events¶
| Event | Description |
|---|---|
Deployed |
Program config created by deploy |
Initialized |
Successful airdrop initialization (project_id) |
ClaimDelegateSet |
Delegate address updated |
Claimed |
Successful token claim. The recipient field holds the signer of the claim |
Errors¶
| Error | Condition |
|---|---|
UnsupportedOperation |
start_time is not before end_time in set_base_params |
TimeInactive |
Outside airdrop claim window |
InvalidProof |
Supplied leaf does not match the computed leaf, or proof is invalid |
LeafUsed |
Defined, but not returned by the current code; a repeat claim fails when the used_leaf account already exists |
IncorrectFees |
Defined, but not returned by the current code |
OutsideClaimableTimeRange |
claimable_timestamp is not after start_time and before now |
NotOwner |
Not airdrop owner |
NotProgramAdmin |
Not program admin |
NotPermissioned |
Signer is neither the recipient nor the claim delegate, or is not the pending new admin |
InvalidFeeCollector |
Provided fee collector account is incorrect |
Paused |
Airdrop is paused |