Skip to main content
Creation CostSPLlight-token
Token Account~2,000,000 lamports~11,000 lamports
Solana Wallet Adapter handles wallet connection and transaction signing. You build transactions with light-token and the wallet signs them:
  1. Connect wallet via useWallet()
  2. Build unsigned transaction
  3. Sign transaction using signTransaction
  4. Send signed transaction to RPC

What you will implement

SPLLight
TransfercreateTransferInstruction()createTransferInterfaceInstructions()
ReceivegetOrCreateAssociatedTokenAccount()createLoadAtaInstructions()
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
Install wallet adapter packages:
npm install @solana/wallet-adapter-base \
            @solana/wallet-adapter-react \
            @solana/wallet-adapter-react-ui \
            @solana/wallet-adapter-wallets
Connect to an RPC endpoint that supports ZK Compression (Helius, Triton):
import { createRpc } from "@lightprotocol/stateless.js";

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

const rpc = createRpc(RPC_ENDPOINT);
2

Sign with Wallet Adapter

Find the complete example on GitHub: React.
Use signTransaction from useWallet() to sign light-token transactions:
import { useWallet } from "@solana/wallet-adapter-react";

const { publicKey, signTransaction } = useWallet();
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 { useState } from 'react';
import { PublicKey, type TransactionInstruction } from '@solana/web3.js';
import {
  createTransferInterfaceInstructions,
} from '@lightprotocol/compressed-token/unified';
import { createRpc } from '@lightprotocol/stateless.js';
import { signAndSendBatches, type SignTransactionFn } from './signAndSendBatches';

// For native SOL, the user must wrap SOL → WSOL first (via useWrap or
// the payments toolkit). createTransferInterfaceInstructions works with
// WSOL like any other SPL token once it exists in a token account.

export interface TransferParams {
  ownerPublicKey: string;
  mint: string;
  toAddress: string;
  amount: number;
  decimals?: number;
}

export interface TransferArgs {
  params: TransferParams;
  signTransaction: SignTransactionFn;
}

export function useTransfer() {
  const [isLoading, setIsLoading] = useState(false);

  const transfer = async (args: TransferArgs): Promise<string> => {
    setIsLoading(true);

    try {
      const { params, signTransaction } = args;
      const { ownerPublicKey, mint, toAddress, amount, decimals = 9 } = params;

      const rpc = import.meta.env.VITE_LOCALNET === 'true'
        ? createRpc()
        : createRpc(import.meta.env.VITE_HELIUS_RPC_URL);

      const owner = new PublicKey(ownerPublicKey);
      const mintPubkey = new PublicKey(mint);
      const recipient = new PublicKey(toAddress);
      const tokenAmount = Math.round(amount * Math.pow(10, decimals));

      const instructions: TransactionInstruction[][] =
        await createTransferInterfaceInstructions(
          rpc, owner, mintPubkey, tokenAmount, owner, recipient,
        );

      const signature = await signAndSendBatches(instructions, {
        rpc,
        feePayer: owner,
        signTransaction,
      });

      if (!signature) {
        throw new Error('Transfer returned no instructions');
      }

      return signature;
    } finally {
      setIsLoading(false);
    }
  };

  return { transfer, isLoading };
}
Your app logic may require you to create a single sign request for your user. Here’s how to do this:
const transactions = instructions.map((ixs) => new Transaction().add(...ixs));

// One approval for all
const signed = await wallet.signAllTransactions(transactions);

for (const tx of signed) {
  // send...
  await sendAndConfirmTransaction(rpc, tx);
}
While almost always you will have only one transfer transaction, you can optimize sending in the rare cases where you have multiple transactions. parallelize the loads, confirm them, and then send the transfer instruction after.
import {
  createTransferInterfaceInstructions,
  sliceLast,
} from "@lightprotocol/compressed-token/unified";

const instructions = await createTransferInterfaceInstructions(
  rpc, owner, mint, amount, owner, recipient
);
const { rest: loadInstructions, last: transferInstructions } = sliceLast(instructions);
// empty = nothing to load, will no-op.
await Promise.all(
  loadInstructions.map(async (ixs) => {
    const tx = new Transaction().add(...ixs);
    const { blockhash } = await rpc.getLatestBlockhash();
    tx.recentBlockhash = blockhash;
    tx.feePayer = owner;
    const signed = await signTransaction(tx);
    const sig = await rpc.sendRawTransaction(signed.serialize(), {
      skipPreflight: false, preflightCommitment: 'confirmed',
    });
    await rpc.confirmTransaction(sig, 'confirmed');
  })
);

// Then send the transfer
const transferTx = new Transaction().add(...transferInstructions);
const { blockhash } = await rpc.getLatestBlockhash();
transferTx.recentBlockhash = blockhash;
transferTx.feePayer = owner;
const signedTransfer = await signTransaction(transferTx);
await rpc.sendRawTransaction(signedTransfer.serialize(), {
  skipPreflight: false, preflightCommitment: 'confirmed',
});
All hooks in this toolkit use a shared signAndSendBatches helper that handles blockhash, signing, sending, and confirming for each instruction batch:
import { Transaction, TransactionInstruction, PublicKey } from '@solana/web3.js';

export type SignTransactionFn = (transaction: Transaction) => Promise<Transaction>;

interface SignAndSendOptions {
  rpc: any;
  feePayer: PublicKey;
  signTransaction: SignTransactionFn;
}

export async function signAndSendBatches(
  instructionBatches: TransactionInstruction[][],
  options: SignAndSendOptions,
): Promise<string | null> {
  const { rpc, feePayer, signTransaction } = options;
  const signatures: string[] = [];

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

    const signedTx = await signTransaction(tx);
    const sig = await rpc.sendRawTransaction(signedTx.serialize(), {
      skipPreflight: false,
      preflightCommitment: 'confirmed',
    });
    await rpc.confirmTransaction(sig, 'confirmed');
    signatures.push(sig);
  }

  return signatures.length > 0 ? signatures[signatures.length - 1] : null;
}

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 { useState, useCallback } from 'react';
import { PublicKey } from '@solana/web3.js';
import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, getMint } from '@solana/spl-token';
import { createRpc } from '@lightprotocol/stateless.js';
import {
  getAssociatedTokenAddressInterface,
  getAtaInterface,
} from '@lightprotocol/compressed-token/unified';

export interface TokenBalance {
  mint: string;
  decimals: number;
  isNative: boolean;
  hot: bigint;
  cold: bigint;
  spl: bigint;
  t22: bigint;
  unified: bigint;
}

export function useUnifiedBalance() {
  const [balances, setBalances] = useState<TokenBalance[]>([]);
  const [isLoading, setIsLoading] = useState(false);

  const fetchBalances = useCallback(async (ownerAddress: string) => {
    if (!ownerAddress) return;

    setIsLoading(true);
    try {
      const rpc = import.meta.env.VITE_LOCALNET === 'true'
        ? createRpc()
        : createRpc(import.meta.env.VITE_HELIUS_RPC_URL);
      const owner = new PublicKey(ownerAddress);

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

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

      // 1. SOL balance
      let solLamports = 0;
      try {
        solLamports = await rpc.getBalance(owner);
      } catch {
        // Failed to fetch SOL balance
      }

      // 2. 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 += amount;
        }
      } catch {
        // No SPL accounts
      }

      // 3. 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();
          const entry = getOrCreate(mintStr);
          entry.t22 += amount;
          entry.tokenProgram = TOKEN_2022_PROGRAM_ID;
        }
      } catch {
        // No Token 2022 accounts
      }

      // 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 += BigInt(item.balance.toString());
        }
      } catch {
        // No compressed accounts
      }

      // 5. Fetch actual decimals for each mint
      const mintKeys = [...mintMap.keys()];
      await Promise.allSettled(
        mintKeys.map(async (mintStr) => {
          try {
            const mint = new PublicKey(mintStr);
            const entry = getOrCreate(mintStr);
            const mintInfo = await getMint(rpc, mint, undefined, entry.tokenProgram);
            entry.decimals = mintInfo.decimals;
          } catch {
            // Keep default decimals if mint fetch fails
          }
        }),
      );

      // 6. Hot balance from Light Token associated token account
      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);
            const entry = getOrCreate(mintStr);
            entry.hot = BigInt(parsed.amount.toString());
          } catch {
            // Associated token account does not exist for this mint — hot stays 0n
          }
        }),
      );

      // 7. Assemble TokenBalance[]
      const result: TokenBalance[] = [];

      // Pull WSOL data from mintMap (if user has WSOL in token accounts)
      const WSOL_MINT = 'So11111111111111111111111111111111111111112';
      const wsolEntry = mintMap.get(WSOL_MINT);
      mintMap.delete(WSOL_MINT); // prevent duplicate row

      // SOL entry: native SOL + WSOL Light Token balances
      result.push({
        mint: WSOL_MINT,
        decimals: 9,
        isNative: true,
        hot: wsolEntry?.hot ?? 0n,
        cold: wsolEntry?.cold ?? 0n,
        spl: BigInt(solLamports),
        t22: wsolEntry?.t22 ?? 0n,
        unified: (wsolEntry?.hot ?? 0n) + (wsolEntry?.cold ?? 0n),
      });

      // Token entries
      for (const [mintStr, entry] of mintMap) {
        result.push({
          mint: mintStr,
          decimals: entry.decimals,
          isNative: false,
          hot: entry.hot,
          cold: entry.cold,
          spl: entry.spl,
          t22: entry.t22,
          unified: entry.hot + entry.cold,
        });
      }

      setBalances(result);
    } catch (error) {
      console.error('Failed to fetch balances:', error);
      setBalances([]);
    } finally {
      setIsLoading(false);
    }
  }, []);

  return { balances, isLoading, fetchBalances };
}

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;
}

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 { useState, useCallback } from 'react';
import { PublicKey } from '@solana/web3.js';
import { createRpc } from '@lightprotocol/stateless.js';

export interface Transaction {
  signature: string;
  slot: number;
  blockTime: number;
  timestamp: string;
}

export function useTransactionHistory() {
  const [transactions, setTransactions] = useState<Transaction[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const fetchTransactionHistory = useCallback(
    async (
      ownerAddress: string,
      limit: number = 10,
    ) => {
      if (!ownerAddress) {
        setTransactions([]);
        return;
      }

      setIsLoading(true);
      setError(null);

      try {
        const rpc = import.meta.env.VITE_LOCALNET === 'true'
          ? createRpc()
          : createRpc(import.meta.env.VITE_HELIUS_RPC_URL);
        const owner = new PublicKey(ownerAddress);

        const result = await rpc.getSignaturesForOwnerInterface(owner);

        if (!result.signatures || result.signatures.length === 0) {
          setTransactions([]);
          return;
        }

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

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

        setTransactions(basicTransactions);
      } catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        setError(message);
        setTransactions([]);
      } finally {
        setIsLoading(false);
      }
    },
    []
  );

  return { transactions, isLoading, error, fetchTransactionHistory };
}

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
);

Didn’t find what you were looking for?

Reach out! Telegram | email | Discord