Skip to main content

Guides

There are two ways to use ZK Compression to distribute your SPL tokens.

No-Code Airdrop Tool

Use Airship by Helius Labs to airdrop to up to 200,000 recipients via Webapp.

Custom Programmatic Airdrop

Create a programmatic airdrop with this guide for more control.

Cost Comparison

You can use the Airship Calculator to anticipate the cost of your airdrop.
Airdrop SizeRegular AirdropZK Compression Airdrop
10,00020.4 SOL (*$4,080)0.0065 SOL (*$1.3)
100,000203.96 SOL (*$40,080)0.065 SOL (*$13)
1,000,0002039.28 SOL (*$400,080)0.65 SOL (*$130)
** assuming $200 per SOL

Programmatic Airdrop

Choose your implementation based on your needs:
TabBest ForWhat You’ll GetTime
Implementation StepsFirst-time users, learningStep-by-step Localnet tutorial20 min
Simple Airdrop<10,000 recipientsProduction-ready single script10 min
Airdrop with Batched Instructions10,000+ recipientsBatched system with retry logic15 min
  • Implementation Steps
  • Simple Airdrop
  • Airdrop with Batched Instructions
What you’ll build: A test airdrop sending compressed tokens to 3 recipients on your local validator.
1

Prerequisites

Make sure you have dependencies and developer environment set up!
System Requirements
  • Node.js >= 20.18.0 (required by latest Solana packages)
  • npm or yarn package manager

Dependencies

npm install --save-dev typescript tsx @types/node && \
npm install --save \
    @lightprotocol/stateless.js \
    @lightprotocol/compressed-token \
    @solana/web3.js \
    @solana/spl-token
Alternatives:
yarn add --dev typescript tsx @types/node && \
yarn add \
    @lightprotocol/stateless.js \
    @lightprotocol/compressed-token \
    @solana/web3.js \
    @solana/spl-token
pnpm add --save-dev typescript tsx @types/node && \
pnpm add \
    @lightprotocol/stateless.js \
    @lightprotocol/compressed-token \
    @solana/web3.js \
    @solana/spl-token

Developer Environment

By default, this guide uses Localnet.
# Install the development CLI
npm install @lightprotocol/zk-compression-cli
# Start a local test validator
light test-validator
// createRpc() defaults to local test validator endpoints
import {
  Rpc,
  createRpc,
} from "@lightprotocol/stateless.js";

const connection: Rpc = createRpc();

async function main() {
  let slot = await connection.getSlot();
  console.log(slot);

  let health = await connection.getIndexerHealth(slot);
  console.log(health);
  // "Ok"
}

main();
Alternative: Using DevnetFollow these steps to create an RPC Connection. Replace <your_api_key> with your API key before running.
import { createRpc } from "@lightprotocol/stateless.js";

// Helius exposes Solana and Photon RPC endpoints through a single URL
const RPC_ENDPOINT = "https://devnet.helius-rpc.com?api-key=<your_api_key>";
const connection = createRpc(RPC_ENDPOINT, RPC_ENDPOINT, RPC_ENDPOINT);

console.log("Connection created!");
console.log("RPC Endpoint:", RPC_ENDPOINT);
2

Mint SPL tokens to your wallet

Run this mint-spl-tokens.ts to mint SPL tokens to your wallet.
mint-spl-tokens.ts
// Mint SPL Tokens for Airdrop - LocalNet
// 1. Load wallet and connect to local validator
// 2. Create SPL mint with token pool for compression via createMint()
// 3. Create ATA and mint SPL tokens to sender for airdrop preparation
// 4. Output mint address for use in simple-airdrop.ts

import { Keypair } from "@solana/web3.js";
import { createRpc } from "@lightprotocol/stateless.js";
import {
  createMint,
  getOrCreateAssociatedTokenAccount,
  mintTo,
} from "@solana/spl-token";
import { createTokenPool } from "@lightprotocol/compressed-token";
import * as fs from 'fs';
import * as os from 'os';

// Step 1: Setup local connection and load wallet
const connection = createRpc(); // defaults to localhost:8899

// Load wallet from filesystem
const walletPath = `${os.homedir()}/.config/solana/id.json`;
const secretKey = JSON.parse(fs.readFileSync(walletPath, 'utf8'));
const payer = Keypair.fromSecretKey(Buffer.from(secretKey));

(async () => {
  // Step 2: Create SPL mint with token pool for compression
  const mint = await createMint(connection, payer, payer.publicKey, null, 9);
  const poolTxId = await createTokenPool(connection, payer, mint);
  console.log(`Mint address: ${mint.toBase58()}`);
  console.log(`TokenPool created: ${poolTxId}`);

  // Step 3: Create associated token account for sender
  // The sender will send tokens from this account to the recipients as compressed tokens.
  const ata = await getOrCreateAssociatedTokenAccount(
    connection,
    payer,
    mint, // SPL mint with token pool for compression
    payer.publicKey
  );
  console.log(`ATA address: ${ata.address.toBase58()}`);

  // Step 4: Mint SPL tokens to ATA.
  // The sender will send tokens from this account to the recipients as compressed tokens.
  const mintToTxId = await mintTo(
    connection,
    payer,
    mint, // SPL mint with token pool for compression
    ata.address, // distributor ATA
    payer.publicKey,
    100_000_000_000 // amount: 100 tokens with 9 decimals
  );
  console.log(`\nSPL tokens minted and ready for distribution!`);
  console.log(`Transaction: ${mintToTxId}`);

  console.log(`\nCopy mint address to your airdrop script: ${mint.toBase58()}`);
})();
3

Execute the Airdrop

Next, distribute the SPL tokens to all recipients.
Ensure you have the latest @lightprotocol/stateless.js and @lightprotocol/compressed-token versions ≥ 0.21.0!
simple-airdrop.ts
// Simple Airdrop - LocalNet
// 1. Load wallet and select compression infrastructure with getStateTreeInfos() and getTokenPoolInfos()
// 2. Build CompressedTokenProgram.compress() instruction for multiple recipients in one transaction
// 3. Execute transaction with compute budget and confirm compression operation with sendAndConfirmTx()
// 4. Verify distribution via getCompressedTokenAccountsByOwner

import { Keypair, PublicKey, ComputeBudgetProgram } from "@solana/web3.js";
import {
  CompressedTokenProgram,
  getTokenPoolInfos,
  selectTokenPoolInfo,
} from "@lightprotocol/compressed-token";
import {
  bn,
  buildAndSignTx,
  calculateComputeUnitPrice,
  createRpc,
  dedupeSigner,
  Rpc,
  selectStateTreeInfo,
  sendAndConfirmTx,
} from "@lightprotocol/stateless.js";
import { getOrCreateAssociatedTokenAccount } from "@solana/spl-token";
import * as fs from 'fs';
import * as os from 'os';

// Step 1: Setup local connection and load wallet
const connection: Rpc = createRpc(); // defaults to localhost:8899
const mint = new PublicKey("MINTADDRESS"); // Replace with mint address from mint-spl-tokens.ts
// Local uses file wallet. Use constants from .env file in production
const walletPath = `${os.homedir()}/.config/solana/id.json`;
const secretKey = JSON.parse(fs.readFileSync(walletPath, 'utf8'));
const payer = Keypair.fromSecretKey(Buffer.from(secretKey));
const owner = payer;

(async () => {
  // Step 2: Select state tree and token pool
  const activeStateTrees = await connection.getStateTreeInfos();
  const treeInfo = selectStateTreeInfo(activeStateTrees);

  const infos = await getTokenPoolInfos(connection, mint);
  const info = selectTokenPoolInfo(infos);

  // Step 3: Get or create source token account for distribution
  // The sender will send tokens from this account to the recipients as compressed tokens.
  const sourceTokenAccount = await getOrCreateAssociatedTokenAccount(
    connection,
    payer,
    mint, // SPL mint with token pool for compression
    payer.publicKey
  );

  // Step 4: Define airdrop recipients and amounts
  const airDropAddresses = [
    Keypair.generate().publicKey,
    Keypair.generate().publicKey,
    Keypair.generate().publicKey,
  ];

  const amounts = [
    bn(20_000_000_000), // 20 tokens
    bn(30_000_000_000), // 30 tokens
    bn(40_000_000_000), // 40 tokens
  ];

  const totalAmount = amounts.reduce((sum, amt) => sum + amt.toNumber(), 0);
  console.log(`Distributing ${totalAmount / 1e9} compressed tokens to ${airDropAddresses.length} recipients`);

  const initialSplBalance = await connection.getTokenAccountBalance(sourceTokenAccount.address);
  console.log(`Sender initial balance: ${initialSplBalance.value.uiAmount} tokens`);

  // Step 5: Build transaction with compute budget and compression instruction
  const instructions = [];
  // Set compute unit limits based on recipient count (estimated 120k CU per recipient)
  instructions.push(
    ComputeBudgetProgram.setComputeUnitLimit({ units: 120_000 * airDropAddresses.length }),
    ComputeBudgetProgram.setComputeUnitPrice({
      microLamports: calculateComputeUnitPrice(20_000, 120_000 * airDropAddresses.length), // dynamic priority fee
    })
  );

  // Create compression instruction for multiple recipients in one transaction
  const compressInstruction = await CompressedTokenProgram.compress({
    payer: payer.publicKey,
    owner: owner.publicKey,
    source: sourceTokenAccount.address, // source ATA holding SPL tokens
    toAddress: airDropAddresses, // recipient addresses for compressed tokens
    amount: amounts, // different amounts for each recipient
    mint, // SPL mint with token pool for compression
    tokenPoolInfo: info,
    outputStateTreeInfo: treeInfo, // destination state tree
  });
  instructions.push(compressInstruction);

  // Step 6: Sign and send transaction
  const additionalSigners = dedupeSigner(payer, [owner]);
  const { blockhash } = await connection.getLatestBlockhash();
  const tx = buildAndSignTx(instructions, payer, blockhash, additionalSigners);

  // For production: Add address lookup table to reduce transaction size and fees
  // const lookupTableAddress = new PublicKey("9NYFyEqPkyXUhkerbGHXUXkvb4qpzeEdHuGpgbgpH1NJ"); // mainnet // or "qAJZMgnQJ8G6vA3WRcjD9Jan1wtKkaCFWLWskxJrR5V" for devnet
  // const lookupTableAccount = (await connection.getAddressLookupTable(lookupTableAddress)).value!;
  // const tx = buildAndSignTx(instructions, payer, blockhash, additionalSigners, [lookupTableAccount]);
  const txId = await sendAndConfirmTx(connection, tx);

  console.log(`\nAirdrop completed!`);
  console.log(`Transaction: ${txId}`);

  // Step 7: Verify distribution via getCompressedTokenAccountsByOwner
  for (let i = 0; i &#x3C; airDropAddresses.length; i++) {
    const recipientAccounts = await connection.getCompressedTokenAccountsByOwner(airDropAddresses[i], { mint });
    const balance = recipientAccounts.items.reduce((sum, account) => sum + Number(account.parsed.amount), 0);
    console.log(`Recipient ${i + 1} (${airDropAddresses[i].toString()}): ${balance / 1e9} compressed tokens`);
  }

  const finalSplBalance = await connection.getTokenAccountBalance(sourceTokenAccount.address);
  console.log(`\nSender balance after airdrop: ${finalSplBalance.value.uiAmount} SPL tokens`);

  return txId;
})();

Advanced Features

Solana Wallets like Phantom and Backpack already support compressed tokens. Still, you can let users decompress to SPL via your Frontend to customize claims.
import {
  bn,
  buildAndSignTx,
  sendAndConfirmTx,
  dedupeSigner,
  Rpc,
  createRpc,
} from "@lightprotocol/stateless.js";
import { ComputeBudgetProgram, Keypair, PublicKey } from "@solana/web3.js";
import {
  CompressedTokenProgram,
  getTokenPoolInfos,
  selectMinCompressedTokenAccountsForTransfer,
  selectTokenPoolInfosForDecompression,
} from "@lightprotocol/compressed-token";
import { getOrCreateAssociatedTokenAccount } from "@solana/spl-token";
import bs58 from "bs58";
import dotenv from "dotenv";
dotenv.config();

// Set these values in your .env file
const RPC_ENDPOINT = process.env.RPC_ENDPOINT;
const mint = new PublicKey(process.env.MINT_ADDRESS!);
const payer = Keypair.fromSecretKey(bs58.decode(process.env.PAYER_KEYPAIR!));

const owner = payer;
const amount = 1e5;
const connection: Rpc = createRpc(RPC_ENDPOINT);

(async () => {
  // 1. Create an associated token account for the user if it doesn't exist
  const ata = await getOrCreateAssociatedTokenAccount(
    connection,
    payer,
    mint,
    payer.publicKey
  );

  // 2. Fetch compressed token accounts
  const compressedTokenAccounts =
    await connection.getCompressedTokenAccountsByOwner(owner.publicKey, {
      mint,
    });

  // 3. Select
  const [inputAccounts] = selectMinCompressedTokenAccountsForTransfer(
    compressedTokenAccounts.items,
    bn(amount)
  );

  // 4. Fetch validity proof
  const proof = await connection.getValidityProof(
    inputAccounts.map((account) => account.compressedAccount.hash)
  );

  // 5. Fetch token pool infos
  const tokenPoolInfos = await getTokenPoolInfos(connection, mint);

  // 6. Select
  const selectedTokenPoolInfos = selectTokenPoolInfosForDecompression(
    tokenPoolInfos,
    amount
  );

  // 7. Build instruction
  const ix = await CompressedTokenProgram.decompress({
    payer: payer.publicKey,
    inputCompressedTokenAccounts: inputAccounts,
    toAddress: ata.address,
    amount,
    tokenPoolInfos: selectedTokenPoolInfos,
    recentInputStateRootIndices: proof.rootIndices,
    recentValidityProof: proof.compressedProof,
  });

  // 8. Sign, send, and confirm
  const { blockhash } = await connection.getLatestBlockhash();
  const additionalSigners = dedupeSigner(payer, [owner]);
  const signedTx = buildAndSignTx(
    [ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), ix],
    payer,
    blockhash,
    additionalSigners
  );
  return await sendAndConfirmTx(connection, signedTx);
})();

Set priority fees dynamically for decompression. Learn more here.

Native Swap via Jup-API

If you have a custom FE, you can let users swap compressed tokens using the Jup-API. A reference implementation is available here.

Next Steps

Explore more guides in our cookbook.