Run a merkle airdrop end-to-end
Run a merkle airdrop end-to-end
This recipe walks the full distributor and claimer flows for a PirateCrew airdrop. The model is the same one Jupiter, Solana Foundation, and most modern Solana drops use: compute a merkle root over (wallet, amount) leaves off-chain, write the root into an on-chain config PDA, and let recipients claim with a proof.
Two things make the Pirate flow different from a "roll your own" merkle distributor:
- Tree persistence. When you build with
persist: true, the server keeps the full tree in Supabase so claimers can fetch their own proof by wallet later. You never have to host the leaf list yourself. - Optional swap-to-SOL. Each claimer can append a Jupiter swap to their claim transaction so they receive SOL instead of the airdropped token — useful if your token is illiquid or the user just wants gas.
The hash function is keccak-256 (Ethereum-style), not SHA-256. Leaves match the on-chain verify_merkle_proof instruction in the Pirates program. If you compute the tree yourself with the wrong hash you'll get correct-looking proofs that the chain rejects.
Shape of the flow
airdrop_count is a u16 (0–65535) that disambiguates multiple separate airdrops for the same mint. Your first drop for a given mint is typically 0; if you run a second one for the same token six months later, that's 1, with its own root, vault, and claim state.
Prerequisites
- API key with both
airdrop:write(for tree-build) andairdrop:admin(if you wantmode=signedon init). - An SPL mint address you want to distribute.
- A funded admin wallet to pay for init.
- A list of
(wallet, amount)recipient entries. 1 to 50,000 leaves per call;amountis raw token units (multiply display amount by10^decimals).
export PIRATE_API_KEY="pk_live_..."
export MINT="GoLDDqDRHcGZBiGPeXAYi5ougndqBNQSNXdNeT3re6gr"Step 1 — Build the tree
Server-side: keccak-256 leaves, returns root, root_hex, leaf_count, and a tree_id. The full tree is stored so claimers can fetch proofs later.
import axios from "axios";
const entries = [
{ wallet: "8mP3xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxAaB1", amount: "1000000" },
{ wallet: "9qZ2xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxCcD3", amount: "2500000" },
{ wallet: "4xR7xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxEeF5", amount: "500000" },
];
const { data } = await axios.post(
"https://api.piratecrew.fun/v2/merkle-trees",
{
mint: process.env.MINT,
airdrop_count: 0,
decimals: 6,
entries,
},
{ headers: { Authorization: `Bearer ${process.env.PIRATE_API_KEY}` } },
);
console.log(data);
// {
// data: {
// root: "5ka...base58...",
// root_hex: "0x9e3f7c8b2a1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f7a8b9c0d1",
// mint: "GoLDDq...",
// leaf_count: 3,
// tree_id: "tree_01HXYZ..."
// },
// meta: { request_id: "req_…" }
// }Or via curl:
curl -X POST https://api.piratecrew.fun/v2/merkle-trees \
-H "Authorization: Bearer $PIRATE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"mint": "'"$MINT"'",
"airdrop_count": 0,
"decimals": 6,
"entries": [
{ "wallet": "8mP3...AaB1", "amount": "1000000" },
{ "wallet": "9qZ2...CcD3", "amount": "2500000" },
{ "wallet": "4xR7...EeF5", "amount": "500000" }
]
}'Save root_hex — you'll pass it to init. Save tree_id for the proof-lookup endpoint in step 3.
Step 2 — Initialize on-chain
This writes the merkle root into an airdrop_config PDA on the Pirates program. The PDA is derived from (platform_id, mint, airdrop_count), so the same mint can host many airdrops over time as long as airdrop_count increments.
curl -X POST https://api.piratecrew.fun/v2/airdrops \
-H "Authorization: Bearer $PIRATE_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"payer": "ADMIN_WALLET_PUBKEY",
"platform_id": "pirate-crew",
"mint": "'"$MINT"'",
"airdrop_count": 0,
"merkle_root": "0x9e3f7c8b2a1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f7a8b9c0d1",
"mode": "unsigned"
}'The merkle_root accepts hex with or without the 0x prefix.
{
"data": {
"mode": "unsigned",
"transaction": "AQABAo...",
"config_address": "CFG..."
},
"meta": { "request_id": "req_…" }
}Sign locally with the admin wallet, submit via POST /v2/transactions. After confirmation, the airdrop resource flips to status: "initialized" and the config PDA can be looked up on-chain.
If you have the airdrop:admin scope on your API key, use mode: "signed" to have the platform Privy wallet sign and broadcast in one round-trip — useful when running drops from a backend cron without local key material.
You'll typically fund the airdrop vault separately by transferring the total distribution amount of the SPL token to the config's vault PDA. Compute that with POST /v2/pdas/airdrop_vault and transfer ahead of any claims.
Step 3 — Claimer fetches a proof
This runs from your claim frontend, per user. With the tree persisted, no leaf list lives on the client — just the tree id and the wallet.
curl "https://api.piratecrew.fun/v2/merkle-trees/$TREE_ID/proofs/8mP3...AaB1" \
-H "Authorization: Bearer $PIRATE_API_KEY"{
"data": {
"wallet": "8mP3...AaB1",
"mint": "GoLDDq...",
"amount": "1000000",
"root": "5ka...",
"proof": ["0x1a2b...", "0x3c4d...", "0x5e6f..."]
},
"meta": { "request_id": "req_…" }
}If the wallet isn't in the tree, you get 404 not_found. Show "You're not eligible" in the UI and stop there.
Step 4 — Claimer builds and signs the claim tx
curl -X POST https://api.piratecrew.fun/v2/airdrops/$AIRDROP_ID/claims \
-H "Authorization: Bearer $PIRATE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"claimer": "8mP3...AaB1",
"amount": "1000000",
"platform_fee": 0,
"swap_to_sol": false,
"mode": "unsigned"
}'You can omit proof when the tree was persisted — the server fetches it from Supabase by (airdrop_id, claimer). If you persisted nothing, pass proof: ["0x1a2b...", "0x3c4d...", ...] explicitly from the lookup response.
{
"data": {
"mode": "unsigned",
"transaction": "AQABAo..."
},
"meta": { "request_id": "req_…" }
}Have the claimer sign with their wallet and submit via POST /v2/transactions. mode: "signed" is rejected — only the claimer's wallet has authority over its destination token account, so there is no server-signing path on this endpoint.
Swap to SOL on claim
Set swap_to_sol: true to append a Jupiter swap in the same transaction so the claimer immediately receives SOL instead of the airdropped token:
{
"claimer": "8mP3...AaB1",
"amount": "1000000",
"swap_to_sol": true,
"platform_fee": 0,
"mode": "unsigned"
}Useful when the airdropped token has no liquidity yet or the recipient just wants gas. The swap uses Jupiter routing and slippage limits — claimers with very thin liquidity may see the swap fail; the claim itself still succeeds.
platform_fee (lamports) optionally routes a flat SOL fee to the platform wallet within the same tx.
Gotchas
- keccak-256, not SHA-256. If you're computing trees yourself with OpenZeppelin's standard library or a SHA-256-based merkletreejs setup, the on-chain
verify_merkle_proofwill reject every proof. Use keccak-256, the same hash Ethereum uses. The server build endpoint already does this; if you bring your own tree, match its leaf encoding. - airdrop_count must match between build, init, and claim. A leaf in
tree_01...forairdrop_count: 0cannot be claimed against the config PDA created withairdrop_count: 1. Pin the value at the top of your distributor script. - Fund the vault. Init creates the config PDA but does not move tokens into it. Transfer the total distribution amount to the vault PDA before claimers start arriving, or every claim will fail with "insufficient funds in vault."
- Eligibility leaks. The proof endpoint will tell anyone with an API key whether a wallet is in your tree and how much it gets. If that matters, gate your frontend with your own auth before calling the API.
- Idempotency on init. Don't double-init the same
(platform_id, mint, airdrop_count)— the second call returns409 conflict. Use the sameIdempotency-Keyif you might retry. - Tree size cap. 50,000 leaves max per
POST /v2/merkle-treescall. For larger drops, shard across multipleairdrop_countvalues or use a custom claim funnel.
Next steps
- Airdrops — the full endpoint reference, scopes, and edge cases.
- Claim and split partner fees — fund the airdrop vault directly from partner fees.
- Transaction Modes — why claim is
unsigned-only.