If you're building on Solana or maintaining your own wallet tooling, understanding how to programmatically find and close empty token accounts is essential knowledge. The process involves querying the RPC for token accounts, filtering for closeable ones, batching close instructions into transactions, and handling the constraints of Solana's transaction format. This guide walks through the entire pipeline with production-ready TypeScript code.

TL;DR: Use getTokenAccountsByOwner to find all token accounts, filter for zero-balance entries, batch closeAccount instructions (max ~20 per transaction), and send. This guide provides complete TypeScript code for each step, plus gotchas around Token-2022, transaction limits, and error handling.

Step 1: Find All Token Accounts for a Wallet

The first step is retrieving every token account associated with a wallet address. Solana's RPC provides getTokenAccountsByOwner for this purpose.

import { Connection, PublicKey } from "@solana/web3.js";
import { TOKEN_PROGRAM_ID } from "@solana/spl-token";

const connection = new Connection(
  "https://api.mainnet-beta.solana.com",
  "confirmed"
);

async function getTokenAccounts(walletAddress: PublicKey) {
  const response = await connection.getTokenAccountsByOwner(walletAddress, {
    programId: TOKEN_PROGRAM_ID,
  });

  return response.value.map((item) => ({
    pubkey: item.pubkey,
    lamports: item.account.lamports,
    data: item.account.data,
  }));
}

This returns every token account owned by the wallet, including accounts with balances. The lamports field tells you how much SOL (rent deposit) is locked in each account.

Important: This only returns accounts under the standard SPL Token Program. For Token-2022 accounts, you need a separate query (covered in the Token-2022 section below).

Step 2: Filter for Empty (Closeable) Accounts

Not all token accounts should be closed. You need to filter for accounts with a zero token balance. Using the parsed JSON format makes this straightforward.

import { AccountLayout } from "@solana/spl-token";

interface CloseableAccount {
  pubkey: PublicKey;
  lamports: number;
  mint: PublicKey;
}

function filterEmptyAccounts(
  accounts: { pubkey: PublicKey; lamports: number; data: Buffer }[]
): CloseableAccount[] {
  const closeable: CloseableAccount[] = [];

  for (const account of accounts) {
    const parsed = AccountLayout.decode(account.data);
    const amount = parsed.amount;

    // BigInt comparison — zero balance means closeable
    if (amount === BigInt(0)) {
      closeable.push({
        pubkey: account.pubkey,
        lamports: account.lamports,
        mint: parsed.mint,
      });
    }
  }

  return closeable;
}

Alternatively, you can use the parsed JSON encoding from the RPC to avoid manual deserialization:

async function getEmptyAccountsParsed(walletAddress: PublicKey) {
  const response = await connection.getParsedTokenAccountsByOwner(
    walletAddress,
    { programId: TOKEN_PROGRAM_ID }
  );

  return response.value
    .filter((item) => {
      const amount = item.account.data.parsed.info.tokenAmount;
      return amount.uiAmount === 0 || amount.amount === "0";
    })
    .map((item) => ({
      pubkey: item.pubkey,
      lamports: item.account.lamports,
      mint: new PublicKey(item.account.data.parsed.info.mint),
    }));
}

The parsed approach is simpler but slightly slower due to the JSON processing on the RPC side. For production use, the binary deserialization approach is more efficient.

Step 3: Calculate Recoverable SOL

Before building transactions, calculate the total recoverable amount so you can present it to the user.

function calculateRecoverable(accounts: CloseableAccount[]): {
  totalLamports: number;
  totalSol: number;
  accountCount: number;
} {
  const totalLamports = accounts.reduce(
    (sum, account) => sum + account.lamports,
    0
  );
  return {
    totalLamports,
    totalSol: totalLamports / 1e9,
    accountCount: accounts.length,
  };
}

For context, each standard token account holds a rent deposit of ~0.00204 SOL (2,039,280 lamports). If you see accounts with different lamport amounts, they may have received additional SOL transfers or may be non-standard accounts.

Step 4: Batch Close Instructions

This is where the real work happens. Solana transactions are limited to 1,232 bytes, and each closeAccount instruction takes approximately 55 bytes. After accounting for the transaction header (3 bytes), compact array headers, the blockhash (32 bytes), and the signature (64 bytes), you can fit roughly 20 close instructions per transaction.

import {
  Transaction,
  TransactionInstruction,
} from "@solana/web3.js";
import { createCloseAccountInstruction } from "@solana/spl-token";

const MAX_CLOSES_PER_TX = 20;

function buildCloseTransactions(
  accounts: CloseableAccount[],
  owner: PublicKey,
  destination: PublicKey
): Transaction[] {
  const transactions: Transaction[] = [];

  for (let i = 0; i < accounts.length; i += MAX_CLOSES_PER_TX) {
    const batch = accounts.slice(i, i + MAX_CLOSES_PER_TX);
    const tx = new Transaction();

    for (const account of batch) {
      tx.add(
        createCloseAccountInstruction(
          account.pubkey,  // account to close
          destination,     // where to send lamports
          owner,           // account owner (signer)
          [],              // multisig signers (empty)
          TOKEN_PROGRAM_ID
        )
      );
    }

    transactions.push(tx);
  }

  return transactions;
}

Why 20 and not more? The limit is dictated by the transaction size constraint. Each close instruction includes three account references (the token account, destination, and owner). The account list in a transaction is deduplicated, so the destination and owner only appear once in the accounts array. But each instruction still adds its own overhead. Testing shows that 20 is a safe maximum; 22-23 sometimes works but risks hitting the size limit depending on account key ordering.

Step 5: Send Transactions

For a backend script with a keypair, sending is straightforward:

import { sendAndConfirmTransaction, Keypair } from "@solana/web3.js";

async function executeCleanup(
  transactions: Transaction[],
  wallet: Keypair
): Promise<string[]> {
  const signatures: string[] = [];

  for (const tx of transactions) {
    try {
      const sig = await sendAndConfirmTransaction(
        connection,
        tx,
        [wallet],
        { commitment: "confirmed" }
      );
      signatures.push(sig);
      console.log(`Closed batch: ${sig}`);
    } catch (err) {
      console.error("Transaction failed:", err);
      // Continue with remaining batches
    }
  }

  return signatures;
}

For a frontend application using Wallet Adapter (like SolRecover does), you'd use the wallet's signTransaction method instead:

// With Wallet Adapter (browser context)
async function executeCleanupWalletAdapter(
  transactions: Transaction[],
  wallet: WalletContextState,
  connection: Connection
): Promise<string[]> {
  const signatures: string[] = [];
  const { blockhash, lastValidBlockHeight } =
    await connection.getLatestBlockhash("confirmed");

  for (const tx of transactions) {
    tx.recentBlockhash = blockhash;
    tx.feePayer = wallet.publicKey!;

    const signed = await wallet.signTransaction!(tx);
    const sig = await connection.sendRawTransaction(
      signed.serialize()
    );
    await connection.confirmTransaction({
      signature: sig,
      blockhash,
      lastValidBlockHeight,
    });
    signatures.push(sig);
  }

  return signatures;
}

SolRecover implements this exact pipeline — scan, filter, batch, and close — in a production-ready browser application. If you want the consumer-facing version of this developer workflow, try it out.

Try SolRecover

Handling Token-2022 Accounts

Token-2022 (the newer SPL token standard) uses a different program ID and can have extension data that increases account size. You need to handle these separately.

import { TOKEN_2022_PROGRAM_ID } from "@solana/spl-token";

async function getToken2022Accounts(walletAddress: PublicKey) {
  const response = await connection.getParsedTokenAccountsByOwner(
    walletAddress,
    { programId: TOKEN_2022_PROGRAM_ID }
  );

  return response.value
    .filter((item) => {
      const amount = item.account.data.parsed.info.tokenAmount;
      return amount.uiAmount === 0 || amount.amount === "0";
    })
    .map((item) => ({
      pubkey: item.pubkey,
      lamports: item.account.lamports,
      mint: new PublicKey(item.account.data.parsed.info.mint),
      programId: TOKEN_2022_PROGRAM_ID,
    }));
}

When building close instructions for Token-2022 accounts, pass TOKEN_2022_PROGRAM_ID instead of TOKEN_PROGRAM_ID:

createCloseAccountInstruction(
  account.pubkey,
  destination,
  owner,
  [],
  TOKEN_2022_PROGRAM_ID  // Important: use Token-2022 program
);

Gotcha: Token-2022 accounts can have extensions (transfer fees, confidential transfers, etc.) that may prevent closure in certain states. Check the account's extension data before attempting to close.

Error Handling and Edge Cases

Production-ready cleanup code needs to handle several edge cases:

Account has a delegate. If a token account has an active delegate with an approved amount, the close will fail even if the balance is zero. Revoke the delegate first with revokeInstruction.

Account is frozen. Frozen accounts cannot be closed. This is rare for user accounts but possible with certain token mints that have a freeze authority.

Insufficient SOL for transaction fees. Each transaction costs ~0.000005 SOL. If the wallet doesn't have enough SOL to pay fees, the transaction will fail. For wallets with very low balances, you may need to close a small batch first to free up SOL for subsequent batches.

RPC rate limits. Querying getTokenAccountsByOwner on public RPCs may be rate-limited for wallets with thousands of accounts. Use a dedicated RPC provider (Helius, QuickNode, Triton) for production applications.

Transaction simulation failures. Always simulate transactions before sending them. Solana's simulateTransaction RPC method will catch issues like closed accounts (race conditions), insufficient balances, and program errors before you spend a transaction fee.

const simulation = await connection.simulateTransaction(tx);
if (simulation.value.err) {
  console.error("Simulation failed:", simulation.value.err);
  // Remove the failing instruction and retry
}

Complete Solana Token Account Cleanup Pipeline

Here's the complete pipeline in a single function:

async function cleanupWallet(walletAddress: PublicKey, signer: Keypair) {
  // 1. Fetch all token accounts (both programs)
  const splAccounts = await getEmptyAccountsParsed(walletAddress);
  const t22Accounts = await getToken2022Accounts(walletAddress);
  const allEmpty = [...splAccounts, ...t22Accounts];

  if (allEmpty.length === 0) {
    console.log("No empty accounts found.");
    return;
  }

  // 2. Calculate recoverable
  const { totalSol, accountCount } = calculateRecoverable(allEmpty);
  console.log(`Found ${accountCount} empty accounts.`);
  console.log(`Recoverable: ${totalSol.toFixed(6)} SOL`);

  // 3. Build batched transactions
  const transactions = buildCloseTransactions(
    allEmpty,
    walletAddress,
    walletAddress
  );
  console.log(`Built ${transactions.length} transaction(s).`);

  // 4. Execute
  const signatures = await executeCleanup(transactions, signer);
  console.log(`Completed: ${signatures.length} transaction(s).`);
}

This is fundamentally the same logic that SolRecover runs for every user — scan, filter, batch, close. The difference is that SolRecover wraps it in a polished UI with wallet adapter integration, transparent fee handling, and edge case management so that non-technical users can recover their SOL without writing code. For a walkthrough of the user experience, see our recovery guide.

Building your own cleanup tool? Great. Want to just recover your SOL right now? SolRecover has you covered — connect any wallet and recover in under a minute.

Recover Your SOL

How Recovery Tool Fees Compare

Fees vary dramatically across SOL recovery tools. Here's how they compare on a typical 30-account cleanup at SOL's January 2025 peak of $295 (0.0612 SOL / $18.06 USD recoverable):

Tool Fee Cost on 30 Accounts (USD) You Keep (USD)
SolRecover 1.9% $0.34 USD $17.72 USD
PandaTool 4.88% $0.88 $17.18
ReclaimSOL 5% $0.90 $17.16
SlerfTools 8% $1.44 $16.62
RefundYourSOL 15% (base) $2.71 $15.35
SolRefunds 20% $3.61 $14.45
RentSolana 20% $3.61 $14.45

Competitor fees last verified: March 12, 2026. With SolRecover, you pay just $0.34 USD on a 30-account cleanup — over 10x less than the $3.61 USD charged by 20% tools like SolRefunds or RentSolana. That's a $3.27 USD difference for the exact same operation. SolRecover also runs fully client-side (your browser connects directly to Helius RPC with no backend server), and offers a generous referral program where the referrer earns 1% while the platform keeps just 0.9%.

Solana Wallet Cleanup Developer FAQ

How do I find all empty token accounts for a wallet programmatically?

Use getTokenAccountsByOwner with the TOKEN_PROGRAM_ID to fetch all token accounts, then filter for accounts where the parsed token amount is zero. This returns both the account address and its lamport balance.

How many closeAccount instructions fit in one Solana transaction?

Approximately 20. Each closeAccount instruction is ~55 bytes, and Solana transactions are limited to 1,232 bytes total. After headers, signatures, and blockhash, you have room for about 20 instructions.

Do I need to handle Token-2022 accounts differently?

Yes. Token-2022 accounts are owned by a different program ID (TOKEN_2022_PROGRAM_ID). You need to query for them separately and use the Token-2022 closeAccount instruction. The logic is the same, but the program ID differs.

Is there a consumer-facing tool built on these principles?

Yes. SolRecover implements this exact scanning, filtering, and batching logic in a browser-based tool that works with any Wallet Adapter compatible wallet. It handles edge cases, Token-2022 accounts, and transaction batching automatically.