Skip to main content
Creation CostSPLlight-token
Token Account~2,000,000 lamports~11,000 lamports
Privy handles user authentication and wallet management. You build transactions with light-token and Privy signs them:
  1. Authenticate with Privy
  2. Build unsigned transaction
  3. Sign transaction using Privy’s wallet provider
  4. Send signed transaction to RPC

What you will implement

SPLLight
TransfercreateTransferInstruction()createTransferInterfaceInstructions()
Wrap from SPLN/AcreateWrapInstruction()
Unwrap to SPLN/AcreateUnwrapInstructions()
Get BalancegetAccount()getAtaInterface()
Tx HistorygetSignaturesForAddress()getSignaturesForOwnerInterface()
Use the payments-and-wallets agent skill to add light-token payment support to your project:
Add the marketplace and install:
/plugin marketplace add Lightprotocol/skills
/plugin install solana-rent-free-dev
For orchestration, install the general skill:
npx skills add https://zkcompression.com
1

Prerequisites

npm install @lightprotocol/compressed-token@beta \
            @lightprotocol/stateless.js@beta
Connect to an RPC endpoint that supports ZK Compression (Helius, Triton):
import { createRpc } from "@lightprotocol/stateless.js";

import {
  createTransferInterfaceInstructions,
  createWrapInstruction,
  createUnwrapInstructions,
  getAssociatedTokenAddressInterface,
  getAtaInterface,
} from "@lightprotocol/compressed-token/unified";

const rpc = createRpc(RPC_ENDPOINT);
2

Sign with Privy

Find complete examples on GitHub: Node.js and React.
Transfer light-tokens between wallets. Auto-loads cold (compressed) light-token, SPL or Token-2022 balance before sending.
About loading: Light Token accounts reduce account rent ~200x by auto-compressing inactive accounts. Before any action, the SDK detects cold balances and adds instructions to load them. This almost always fits in a single atomic transaction with your regular transfer. APIs return TransactionInstruction[][] so the same loop handles the rare multi-transaction case automatically.
import 'dotenv/config';
import {PrivyClient} from '@privy-io/node';
import {createRpc} from '@lightprotocol/stateless.js';
import {PublicKey, Transaction} from '@solana/web3.js';
import {
  createTransferInterfaceInstructions,
} from '@lightprotocol/compressed-token/unified';

const transferLightTokens = async (
  fromAddress: string,
  toAddress: string,
  tokenMintAddress: string,
  amount: number,
  decimals: number = 9,
) => {
  const connection = createRpc(process.env.HELIUS_RPC_URL!);

  const privy = new PrivyClient({
    appId: process.env.PRIVY_APP_ID!,
    appSecret: process.env.PRIVY_APP_SECRET!,
  });

  const fromPubkey = new PublicKey(fromAddress);
  const toPubkey = new PublicKey(toAddress);
  const mintPubkey = new PublicKey(tokenMintAddress);
  const tokenAmount = Math.floor(amount * Math.pow(10, decimals));

  // Loads cold (compressed), SPL, and Token 2022 balances into the Light Token associated token account before transfer.
  // Returns TransactionInstruction[][] — send [0..n-2] in parallel, then [n-1] last.
  const instructions = await createTransferInterfaceInstructions(
    connection, fromPubkey, mintPubkey, tokenAmount, fromPubkey, toPubkey,
  );

  // Sign and send each batch via Privy
  const walletId = process.env.TREASURY_WALLET_ID!;
  const authorizationKey = process.env.TREASURY_AUTHORIZATION_KEY!;
  const signatures: string[] = [];

  for (const ixs of instructions) {
    const tx = new Transaction().add(...ixs);
    const {blockhash} = await connection.getLatestBlockhash();
    tx.recentBlockhash = blockhash;
    tx.feePayer = fromPubkey;

    const {signed_transaction} = await privy.wallets().solana().signTransaction(
      walletId, {
        transaction: tx.serialize({requireAllSignatures: false}),
        authorization_context: {authorization_private_keys: [authorizationKey]},
      },
    ) as any;

    const sig = await connection.sendRawTransaction(
      Buffer.from(signed_transaction, 'base64'),
      {skipPreflight: false, preflightCommitment: 'confirmed'},
    );
    await connection.confirmTransaction(sig, 'confirmed');
    signatures.push(sig);
  }

  return signatures[signatures.length - 1];
};

export default transferLightTokens;

Show Balance

Query token balances to show a unified balance of SOL, Light, SPL, and Token-2022.
import {
  getAssociatedTokenAddressInterface,
  getAtaInterface,
} from "@lightprotocol/compressed-token/unified";

const ata = getAssociatedTokenAddressInterface(mint, owner);
const account = await getAtaInterface(rpc, ata, owner, mint);
console.log(account.parsed.amount);
import 'dotenv/config';
import {PublicKey, LAMPORTS_PER_SOL} from '@solana/web3.js';
import {TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID} from '@solana/spl-token';
import {createRpc} from '@lightprotocol/stateless.js';
import {
  getAtaInterface,
  getAssociatedTokenAddressInterface,
} from '@lightprotocol/compressed-token/unified';

interface TokenBalance {
  mint: string;
  decimals: number;
  hot: number;
  cold: number;
  spl: number;
  t22: number;
  unified: number;
}

interface BalanceBreakdown {
  sol: number;
  tokens: TokenBalance[];
}

export async function getBalances(
  ownerAddress: string,
): Promise<BalanceBreakdown> {
  const rpc = createRpc(process.env.HELIUS_RPC_URL!);
  const owner = new PublicKey(ownerAddress);

  // SOL balance
  let solLamports = 0;
  try {
    solLamports = await rpc.getBalance(owner);
  } catch (e) {
    console.error('Failed to fetch SOL balance:', e);
  }

  // Per-mint accumulator
  const mintMap = new Map<string, {spl: number; t22: number; hot: number; cold: number; decimals: number}>();

  const getOrCreate = (mintStr: string) => {
    let entry = mintMap.get(mintStr);
    if (!entry) {
      entry = {spl: 0, t22: 0, hot: 0, cold: 0, decimals: 9};
      mintMap.set(mintStr, entry);
    }
    return entry;
  };

  // 1. SPL accounts
  try {
    const splAccounts = await rpc.getTokenAccountsByOwner(owner, {
      programId: TOKEN_PROGRAM_ID,
    });
    for (const {account} of splAccounts.value) {
      const buf = toBuffer(account.data);
      if (!buf || buf.length < 72) continue;
      const mint = new PublicKey(buf.subarray(0, 32));
      const amount = buf.readBigUInt64LE(64);
      const mintStr = mint.toBase58();
      getOrCreate(mintStr).spl += toUiAmount(amount, 9);
    }
  } catch {
    // No SPL accounts
  }

  // 2. Token 2022 accounts
  try {
    const t22Accounts = await rpc.getTokenAccountsByOwner(owner, {
      programId: TOKEN_2022_PROGRAM_ID,
    });
    for (const {account} of t22Accounts.value) {
      const buf = toBuffer(account.data);
      if (!buf || buf.length < 72) continue;
      const mint = new PublicKey(buf.subarray(0, 32));
      const amount = buf.readBigUInt64LE(64);
      const mintStr = mint.toBase58();
      getOrCreate(mintStr).t22 += toUiAmount(amount, 9);
    }
  } catch {
    // No Token 2022 accounts
  }

  // 3. Hot balance from Light Token associated token account
  const mintKeys = [...mintMap.keys()];
  await Promise.allSettled(
    mintKeys.map(async (mintStr) => {
      try {
        const mint = new PublicKey(mintStr);
        const ata = getAssociatedTokenAddressInterface(mint, owner);
        const {parsed} = await getAtaInterface(rpc, ata, owner, mint);
        getOrCreate(mintStr).hot = toUiAmount(parsed.amount, 9);
      } catch {
        // Associated token account does not exist for this mint
      }
    }),
  );

  // 4. Cold balance from compressed token accounts
  try {
    const compressed = await rpc.getCompressedTokenBalancesByOwnerV2(owner);
    for (const item of compressed.value.items) {
      const mintStr = item.mint.toBase58();
      getOrCreate(mintStr).cold += toUiAmount(BigInt(item.balance.toString()), 9);
    }
  } catch {
    // No compressed accounts
  }

  // Assemble result
  const tokens: TokenBalance[] = [];
  for (const [mintStr, entry] of mintMap) {
    tokens.push({
      mint: mintStr,
      decimals: entry.decimals,
      hot: entry.hot,
      cold: entry.cold,
      spl: entry.spl,
      t22: entry.t22,
      unified: entry.hot + entry.cold,
    });
  }

  return {sol: solLamports / LAMPORTS_PER_SOL, tokens};
}

function toBuffer(data: Buffer | Uint8Array | string | unknown): Buffer | null {
  if (data instanceof Buffer) return data;
  if (data instanceof Uint8Array) return Buffer.from(data);
  return null;
}

function toUiAmount(raw: bigint | {toNumber: () => number}, decimals: number): number {
  const value = typeof raw === 'bigint' ? Number(raw) : raw.toNumber();
  return value / 10 ** decimals;
}

export default getBalances;

Get Transaction History

Fetch light-token transaction history for an owner.
import { createRpc } from "@lightprotocol/stateless.js";

const result = await rpc.getSignaturesForOwnerInterface(owner);
console.log(result.signatures); // Merged + deduplicated
console.log(result.solana);     // On-chain txs only
console.log(result.compressed); // Compressed txs only
import 'dotenv/config';
import {createRpc} from '@lightprotocol/stateless.js';
import {PublicKey} from '@solana/web3.js';

const getTransactionHistory = async (
  ownerAddress: string,
  limit: number = 10,
) => {
  const connection = createRpc(process.env.HELIUS_RPC_URL!);

  const owner = new PublicKey(ownerAddress);

  // Get Light Token interface signatures
  const result = await connection.getSignaturesForOwnerInterface(owner);

  if (!result.signatures || result.signatures.length === 0) {
    return {
      count: 0,
      transactions: [],
    };
  }

  const limitedSignatures = result.signatures.slice(0, limit);

  const transactions = limitedSignatures.map((sig) => ({
    signature: sig.signature,
    slot: sig.slot,
    blockTime: sig.blockTime ?? 0,
    timestamp: sig.blockTime ? new Date(sig.blockTime * 1000).toISOString() : '',
  }));

  return {
    count: result.signatures.length,
    transactions,
  };
};

export default getTransactionHistory;

One-time: Create interface PDA to existing SPL Mint

For existing SPL mints (e.g. USDC), register the SPL interface once. This creates the omnibus PDA that holds SPL tokens when wrapped to light-token.
Find a full code example here.
Check if the interface already exists:
import { getSplInterfaceInfos } from "@lightprotocol/compressed-token";

try {
  const infos = await getSplInterfaceInfos(rpc, mint);
  const exists = infos.some((i) => i.isInitialized);
  console.log("Interface exists:", exists);
} catch {
  console.log("No interface registered for this mint.");
}
Register:
import { Transaction, sendAndConfirmTransaction } from "@solana/web3.js";
import { LightTokenProgram } from "@lightprotocol/compressed-token";
import { TOKEN_PROGRAM_ID } from "@solana/spl-token";

const ix = await LightTokenProgram.createSplInterface({
  feePayer: payer.publicKey,
  mint,
  tokenProgramId: TOKEN_PROGRAM_ID,
});

const tx = new Transaction().add(ix);
await sendAndConfirmTransaction(rpc, tx, [payer]);
Use createMintInterface with TOKEN_PROGRAM_ID to create a new SPL mint and register the interface in one transaction:
import { createMintInterface } from "@lightprotocol/compressed-token/unified";
import { TOKEN_PROGRAM_ID } from "@solana/spl-token";

const { mint } = await createMintInterface(
  rpc, payer, payer, null, 9, undefined, undefined, TOKEN_PROGRAM_ID
);