Skip to content

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

Accounts of the Merkle distributor program Accounts of the Merkle distributor program
Accounts of the Merkle distributor program

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.

pub struct Config {
  pub admin: Pubkey,
  pub new_admin: Pubkey,
  pub default_fee_collector: Pubkey,
}

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:

pub struct UsedLeaf {
  pub used: bool,
}

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