Skip to main content

Merkle distributor program

The claim program. Reads a published merkle root, lets each viewer prove their leaf with (index, amount, proof), flips a bit in a packed bitmap, and transfers tokens from a single per-distribution vault. Optionally mints a Metaplex Core or Bubblegum NFT instead.

Forked from the Jito / Jupiter merkle distributor and audited at v0.30 by Neodyme + OtterSec (reports in distributor/audit/). The source has since been bumped to Anchor 1.0.2 + solana-program 3.0 and extended with the NFT distribution path — re-audit is required before the next mainnet redeploy.

  • Program ID: 4ffj6hEnx6cqp4ToMALExqk6QwPNSbZyr8ro9yW1Qvok
  • Anchor: 1.0.2
  • Solana: 3.0
  • Source: distributor/merkle-distributor/ in this workspace
  • Sibling programs in the same workspace: streamer-vault, launchpad-vault

Why merkle?

A naive airdrop sends one transaction per recipient. With thousands of viewers that is gas-prohibitive on most chains and rent-prohibitive on Solana. A merkle distributor lets you publish a single 32-byte root on-chain. Each recipient claims by submitting their leaf + proof; the program verifies and pays them out from a single vault. Cost is amortized to the claimers themselves.

Why a bitmap instead of per-claim PDAs?

BitView's distributor packs claim status into a single bitmap on the distributor account. At 100K claimants this is roughly 1,800× cheaper in rent than the per-claim ClaimStatus PDA pattern (~$16K vs ~$9 in rent at typical SOL prices).

Account layout
[ 0 .. 8] Anchor discriminator
[ 8 .. 264] Distributor header (zero-copy, repr(C))
[264 .. N] Bitmap, ceil(max_num_nodes / 8) bytes, bit i = "leaf i claimed"

At 100K nodes the bitmap is 12.5 KB and the whole account fits well under the 10 MiB Solana account size limit.

Instructions

Token path (SPL + Token-2022)

InstructionCallerPurpose
init_distributorstreamer / operatorCreate distributor PDA + token vault, commit the merkle root, set enable_slot + clawback_start_ts
claimviewerVerify proof, flip the bitmap bit, transfer from vault via transfer_checked
clawbackpermissionless after clawback_start_tsSweep remaining vault balance to clawback_receiver
set_admin / accept_adminadmin → pending adminTwo-step admin transfer (guards against typos)
set_clawback_receiveradminChange clawback destination (immutable treasury_authority validates)
set_enable_slotadminAdjust the claim activation slot (capped to a 2-year future window)
close_distributoradminReclaim rent after the clawback grace window

NFT path

InstructionCallerPurpose
init_nft_distributorstreamer / operatorSame shape as token path; kind ∈ {0=Core, 1=Compressed}
claim_nft_coreviewerVerify proof, flip the bitmap bit, (Layer 2 in progress) CPI into mpl-core::CreateV2 to mint a fresh per-claim Metaplex Core asset
claim_nft_compressedviewerSame shape for Bubblegum compressed NFTs (CPI into mpl-bubblegum::mint_to_collection_v1 — Layer 2 in progress)
set_nft_enable_slot / close_nft_distributoradminNFT lifecycle counterparts

PDA derivation

distributor_pda = pda(
[b"Distributor", base, mint, version_le_bytes],
program_id,
)

Where:

  • base — per-tree pubkey, kept by the operator. Lets the same (mint, version) host multiple distinct trees if needed.
  • mint — the SPL mint (token path) or the collection mint (NFT path).
  • versionu32, lets one mint host multiple trees (one per page-sized chunk if the claimant count exceeds the per-tree cap).

claim does not create a per-claim PDA. Claim status is the bit at offset index in the bitmap on the distributor account itself.

Merkle scheme

SHA256, indexed leaves, sorted siblings (Jito / Jupiter-compatible):

leaf = sha256( 0x00 || sha256(index_le_u32 || claimant_32 || amount_le_u64) )
node_pair = sha256( 0x01 || min(L, R) || max(L, R) )

The sorted-pair shape means proofs don't carry a "left or right" indicator — the verifier just sorts before hashing.

NFT leaves hash a metadata URI instead of an amount:

nft_leaf = sha256( 0x00 || sha256(index_le_u32 || claimant_32 || sha256(metadata_uri_string)) )

Max proof depth is 24 (covers 2²⁴ ≈ 16.7M leaves with safety margin for unbalanced trees). For larger distributions the off-chain builder splits across multiple trees by version.

Custom errors (selected)

InvalidProof, IndexOutOfRange, AlreadyClaimed, NotStarted, ExceededMax, Clawed, ClawbackBeforeStart, Unauthorized, ArithmeticError, OwnerMismatch, VaultMismatch, NoPendingAdmin, PendingAdminMismatch, FrozenReceiver, EnableSlotTooFar, ProofTooLong, ClaimWindowTooShort, InvalidNftKind, ClaimWindowClosed, UriTooLong, KindMismatch, NftAuthorityMismatch, TreeConfigMismatch, TreasuryAuthorityMismatch, EnableSlotInPast, BubblegumTreeTooLarge, CloseTooEarly, ClaimWindowTooFar.

Events

  • ClaimedEvent { distributor, index, claimant, amount }
  • NftClaimedEvent { distributor, index, claimant, kind }

Flow timeline

streamer wallet bitview-bot viewer wallet
│ │ │
│── (Token-2022 mint + ────────┼──────────────────────────────│
│ streamer-vault PDA) │ │
│ │ │
│── init_distributor ──────────┼──────────────────────────────│
│ (or atomic with │ │
│ streamer-vault:: │ │
│ withdraw_for_distribution) │ │
│ │ │
│ (active — accrual loop runs) │
│ │ │
│── (distribution ends) ───────│ │
│ │── POST /distributions-api/ │
│ │ {id}/finalize │
│ │── build merkle tree from │
│ │ accruals collection │
│ │── publish root via │
│ │ set_enable_slot ───────────────────────▶ on-chain
│ │ │
│ │ │── GET /claims-api/proof
│ │◀── proof JSON ───────────────│
│ │ │
│ │── POST /distributor-tx-api/ │
│ │ build-claim │
│ │── base64 unsigned tx ────────│
│ │ │── sign + submit ────▶ claim

Sibling-program purpose-gating

The merkle-distributor program is the only sink the streamer-vault accepts for withdraw_for_distribution, enforced by both account-owner inspection and sibling-instruction discriminator pinning. The streamer-vault checks that the next instruction in the same transaction is init_distributor on this program and points at the same vault.

This means a compromised streamer wallet cannot move the 85%-locked supply anywhere except into a distributor vault — and only if that distributor is being initialized in the same transaction.

Vesting and clawback

The distributor supports:

  • Linear vesting between start_vesting_ts and end_vesting_ts (BitView's default tier is no-vesting; configure on a per-distribution basis if needed).
  • Clawback after clawback_start_ts, permissionless to call; sweeps the remaining vault balance to clawback_receiver.
  • Enable-slot scheduling so claims activate at a known future slot (capped to a 2-year future window).

Off-chain tooling

ToolPurpose
distributor/cliBuild merkle trees from CSV / JSON. Splits across multiple trees if > max_nodes_per_tree (default 100K).
distributor/apiaxum HTTP server (:7001). Loads tree files on startup; serves proofs at GET /user/:pubkey. Read-only, stateless, hot-reloads on new tree files.
bitview-merkle (in bitview-bot)In-process SHA256 builder; produces the same root the on-chain program verifies. Used during distribution finalize.

The bot proxies the axum API at /claims-api/proof/*, so the frontend never hits the proof server directly.

Cross-references