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)
| Instruction | Caller | Purpose |
|---|---|---|
init_distributor | streamer / operator | Create distributor PDA + token vault, commit the merkle root, set enable_slot + clawback_start_ts |
claim | viewer | Verify proof, flip the bitmap bit, transfer from vault via transfer_checked |
clawback | permissionless after clawback_start_ts | Sweep remaining vault balance to clawback_receiver |
set_admin / accept_admin | admin → pending admin | Two-step admin transfer (guards against typos) |
set_clawback_receiver | admin | Change clawback destination (immutable treasury_authority validates) |
set_enable_slot | admin | Adjust the claim activation slot (capped to a 2-year future window) |
close_distributor | admin | Reclaim rent after the clawback grace window |
NFT path
| Instruction | Caller | Purpose |
|---|---|---|
init_nft_distributor | streamer / operator | Same shape as token path; kind ∈ {0=Core, 1=Compressed} |
claim_nft_core | viewer | Verify 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_compressed | viewer | Same shape for Bubblegum compressed NFTs (CPI into mpl-bubblegum::mint_to_collection_v1 — Layer 2 in progress) |
set_nft_enable_slot / close_nft_distributor | admin | NFT 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).version—u32, 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_tsandend_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 toclawback_receiver. - Enable-slot scheduling so claims activate at a known future slot (capped to a 2-year future window).
Off-chain tooling
| Tool | Purpose |
|---|---|
distributor/cli | Build merkle trees from CSV / JSON. Splits across multiple trees if > max_nodes_per_tree (default 100K). |
distributor/api | axum 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
- Streamer vault — sibling program: 85% supply lock with purpose-gated withdrawals
- Launchpad vault — sibling program: BTV match-funding for Identity-tier pool seeding
- distributor (component overview) — workspace-level summary
- Architecture — one-screen visual
- API reference — REST shapes for the bot endpoints
- Security audits