The Solana closeAccount instruction: complete developer reference

Published 2026-09-29 · 6 min read

The Solana closeAccount instruction: complete developer reference

This is the technical reference for closeAccount — the SPL Token / Token-2022 instruction that returns the rent-exempt deposit from a token account back to a chosen destination. If you're building a rent-recovery tool, a wallet cleanup feature, or debugging a "cannot close" error, this page has the details.

Instruction discriminator

Both SPL Token and Token-2022 use the same 1-byte discriminator:

// SPL Token program
pub const CLOSE_ACCOUNT_DISCRIMINATOR: u8 = 9;

The instruction data is exactly 1 byte: [9]. No arguments.

Accounts (in order)

Index Account Signer Writable Purpose
0 Token account to close No Yes The ATA (or arbitrary token account) being closed
1 Rent destination No Yes Where the reclaimed SOL goes (usually the owner's main wallet)
2 Owner authority Yes No The signer authorized to close the account
3+ Multisig signers Yes No Only if the account is owned by a multisig

Preconditions

The instruction fails unless:

  1. Balance is 0 — the account's token balance must be exactly zero. Otherwise: 0x0011 / TokenError::NonNativeHasBalance.
  2. Account is initialized — must be an actual token account, not a raw account.
  3. Owner authorization — the signer must match the account's owner field (or be a multisig with quorum).
  4. Not a wrapped SOL account with balance — WSOL accounts with balance must be handled specially (see below).

Program IDs

// Legacy SPL Token
pub const TOKEN_PROGRAM_ID: Pubkey =
    pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");

// Token-2022
pub const TOKEN_2022_PROGRAM_ID: Pubkey =
    pubkey!("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb");

The exact same discriminator ([9]) works for both programs — but the transaction must reference the correct program ID for each account. Sending a Token-2022 close to the legacy Token program (or vice versa) fails silently on some SDKs, or with an Incorrect program id error on others.

Rust (native / on-chain)

use solana_program::{
    account_info::AccountInfo,
    entrypoint::ProgramResult,
    program::invoke,
    pubkey::Pubkey,
};
use spl_token::instruction::close_account;

pub fn close_ata(
    token_program: &Pubkey,
    ata: &Pubkey,
    destination: &Pubkey,
    owner: &Pubkey,
    accounts: &[AccountInfo],
) -> ProgramResult {
    let ix = close_account(
        token_program,
        ata,
        destination,
        owner,
        &[],  // no multisig signers
    )?;
    invoke(&ix, accounts)?;
    Ok(())
}

TypeScript (client-side, @solana/web3.js)

import {
  Connection,
  Keypair,
  Transaction,
  sendAndConfirmTransaction,
  PublicKey,
} from '@solana/web3.js';
import {
  createCloseAccountInstruction,
  TOKEN_PROGRAM_ID,
  TOKEN_2022_PROGRAM_ID,
} from '@solana/spl-token';

async function closeEmptyAta(
  connection: Connection,
  wallet: Keypair,
  ataAddress: PublicKey,
  programId: PublicKey = TOKEN_PROGRAM_ID
): Promise<string> {
  const ix = createCloseAccountInstruction(
    ataAddress,        // account to close
    wallet.publicKey,  // destination for rent
    wallet.publicKey,  // authority
    [],                // multisig signers
    programId          // TOKEN_PROGRAM_ID or TOKEN_2022_PROGRAM_ID
  );

  const tx = new Transaction().add(ix);
  return await sendAndConfirmTransaction(connection, tx, [wallet]);
}

Batching (fit as many closes as possible in one tx)

Solana caps transactions at 1232 bytes. Each closeAccount instruction costs ~40 bytes (3 accounts × 32 bytes + overhead, deduplicated by the tx encoding). Realistic batch: 12 close instructions per transaction with room for a compute budget instruction.

import { Transaction, ComputeBudgetProgram } from '@solana/web3.js';

async function batchCloseAtas(
  connection: Connection,
  wallet: Keypair,
  ataAddresses: PublicKey[]
): Promise<string[]> {
  const CHUNK_SIZE = 12;
  const txids: string[] = [];

  for (let i = 0; i < ataAddresses.length; i += CHUNK_SIZE) {
    const chunk = ataAddresses.slice(i, i + CHUNK_SIZE);
    const tx = new Transaction();
    tx.add(ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }));
    for (const ata of chunk) {
      tx.add(createCloseAccountInstruction(ata, wallet.publicKey, wallet.publicKey));
    }
    const sig = await sendAndConfirmTransaction(connection, tx, [wallet]);
    txids.push(sig);
  }
  return txids;
}

Python (solders + solana-py)

from solana.rpc.async_api import AsyncClient
from solders.pubkey import Pubkey
from solders.instruction import Instruction, AccountMeta
from solders.transaction import Transaction
from solders.message import Message

TOKEN_PROGRAM = Pubkey.from_string("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
CLOSE_DISC = bytes([9])

def build_close_ix(ata: Pubkey, destination: Pubkey, owner: Pubkey) -> Instruction:
    accounts = [
        AccountMeta(ata,         is_signer=False, is_writable=True),
        AccountMeta(destination, is_signer=False, is_writable=True),
        AccountMeta(owner,       is_signer=True,  is_writable=False),
    ]
    return Instruction(TOKEN_PROGRAM, CLOSE_DISC, accounts)

Wrapped SOL (special case)

Wrapped SOL (WSOL) accounts are token accounts holding wrapped native SOL. Closing them has an extra behavior: instead of returning just the rent-exempt deposit, closeAccount returns rent + token balance as native SOL to the destination.

This is intentional — it's the "unwrap" mechanism. If you have 5 WSOL in a WSOL ATA and close it, you get 5 SOL + 0.00204 SOL rent back.

Common errors

Error code Meaning Fix
0x0011 (17) Non-native has balance Balance is not zero — transfer or burn tokens first
0x0004 (4) Owner mismatch The signer isn't the account owner
0xC (12) Account not initialized Not a valid token account
Program 0x0 Incorrect program id You sent Token-2022 tx to legacy Token program (or vice versa)
Tx too large > 1232 bytes Reduce batch size (12 closes max per tx)

Under the hood: what happens on close

The runtime does exactly:

  1. Verify signer matches owner
  2. Verify balance == 0
  3. Transfer the account's lamports (rent + any accumulated balance) to destination
  4. Wipe the account's data
  5. Mark the account as system-owned again (data length = 0)

The account is genuinely closed — it can be re-created later (with fresh rent deposit) if needed.

Ecosystem tools that batch closeAccount


Scan your wallet on solduster.com →