Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.chainstack.com/llms.txt

Use this file to discover all available pages before exploring further.

TLDR:
  • p-token is a compute-optimized, drop-in rewrite of the SPL Token program. It runs at the same program ID, so your existing code and Chainstack endpoints already use it — a transfer now costs ~76 compute units instead of ~4,645.
  • It adds a batch instruction that bundles several token operations into one token-program call.
  • Batching only pays off through cross-program invocation (CPI): it amortizes the fixed per-CPI base cost across all the bundled operations.
  • At the top level, batching is pure overhead — instructions there are already cheap, so send them separately.

What p-token changes

p-token is a Pinocchio-based reimplementation of the SPL Token program from Anza, shipped through SIMD-0266. It went live on Solana mainnet at epoch 971 and is fully backwards compatible. p-token activates as a feature-gate swap at the same program ID (TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA). Account layouts, instruction discriminators, and account structures are byte-for-byte identical to the old program. You do not change a single address, and transactions you send through your Chainstack node already run the optimized program. What you get is a large drop in compute units. The figures below are from the SIMD-0266 governance proposal:
InstructionSPL Token (CU)p-token (CU)
Transfer4,64576
TransferChecked6,200105
MintTo4,538119
Burn4,753126
InitializeAccount4,527154
CloseAccount2,916120
SyncNative3,04561
The upgrade also adds three new instructions: batch, withdraw_excess_lamports, and unwrap_lamports. This page focuses on batch.

The batch instruction

batch (discriminator 255) lets you pack several token-program instructions into a single instruction. The wire format is a flat sequence: the discriminator, then one entry per sub-instruction, repeating until the data buffer is exhausted. There is no count prefix — the program loops until it runs out of bytes. Each sub-instruction entry is a 2-byte header followed by its data:
  • numberOfAccounts — how many accounts this sub-instruction consumes from the flat account list.
  • dataLength — the byte length of the instruction data that follows.
  • data — the raw token instruction bytes, including that instruction’s own discriminator.
All accounts for all sub-instructions are passed as one flat list, consumed left to right: sub-instruction 1 takes the first numberOfAccounts accounts, sub-instruction 2 takes the next batch, and so on.

What you can batch

Almost every token-program instruction can go into a batch — transfer and transferChecked, mintTo and burn, approve and revoke, freeze and thaw, the initializeMint and initializeAccount variants, closeAccount, syncNative, and withdrawExcessLamports. They execute sequentially, in the order you add them. Two things you cannot batch:
  • A batch itself — the batch instruction is not batchable, so no nesting.
  • Associated token account (ATA) creation — the Associated Token Account program is a separate program that in turn calls the token program, so its instructions cannot be folded into a token-program batch. If you want account creation inside the same batch, create a plain token account (the System program’s create-account plus initializeAccount) rather than an ATA.
There is no fixed cap on how many instructions one batch holds. Two Solana constraints bind instead:
  • The 255-account-reference limit — a single instruction can reference at most 255 accounts, and a batch counts accounts across all its sub-instructions (they are not deduplicated). Instructions that touch few accounts let you pack the most in.
  • The ~1,232-byte transaction size — each sub-instruction’s header, data, and account references add up.
Which limit you hit first depends on the instruction. Account-light operations (a revoke touches two accounts) are bound by the account cap, so well over a hundred fit; a batch of plain transfers hits the transaction-size limit first, at around 70 per batch.

When batching pays off

The rule is simple: batch is only worth it when you call the token program through CPI. Every invoke call on Solana carries a fixed base cost of about 1,000 compute units, regardless of the instruction inside it. When a program transfers tokens twice, it pays that base cost twice — once per CPI. batch collapses those into a single CPI, so you pay the 1,000 CU base cost once and then only the (now tiny) cost of each token operation:
  • Two separate token CPIs — 2 × 1,000 base CU + 2 × operation cost.
  • One batch CPI — 1 × 1,000 base CU + 2 × operation cost.
The savings grow with each operation you fold in. This matters because almost every DeFi program issues multiple token CPIs in a single instruction — an AMM transfers two tokens per swap, an LP deposit transfers one token and mints another. Folding those operations into one batch CPI eliminates the redundant per-CPI base costs. The effect compounds: bundling about a dozen token operations into a single CPI rather than a dozen separate CPIs has been measured to cut total compute roughly threefold — from around 18,000 CU to about 6,000. A batch’s single CPI is not free, and its cost rises with the number of accounts it carries, but it is far cheaper than paying the base cost on every invocation. At the top level of a transaction there is no CPI, so there is nothing to amortize. A batch of top-level instructions just adds the wrapper overhead and runs slower than sending the instructions separately. Use batch from inside a program, not from your client, unless you have a specific reason to. In practice the gains are real but modest, so treat batch as a compute optimization, not a necessity — the big efficiency win on token operations came from p-token itself, which you already get for free. Reach for batch when your program issues two or more token instructions back-to-back through CPI with nothing else in between. Because they run sequentially in order, you cannot interleave other logic between batched operations.

Batch via CPI in Rust

For new programs, the pinocchio-token crate gives you an idiomatic Batch builder and an IntoBatch trait implemented for every instruction type.
Cargo.toml
[dependencies]
pinocchio = "0.7"
pinocchio-token = "0.6"
Rust
use pinocchio::{account_info::AccountInfo, program_error::ProgramError, ProgramResult};
use pinocchio_token::instructions::{Batch, IntoBatch, Transfer};

pub fn process_swap(accounts: &[AccountInfo]) -> ProgramResult {
    let [src_a, dst_a, src_b, dst_b, authority, _rest @ ..] = accounts else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };

    // Pre-allocate the batch buffers on the stack.
    // Data buffer = header_data_len(N) + sum of each sub-instruction's data size.
    // A Transfer is 9 bytes (1 discriminator + 8 amount); here N = 2.
    let mut data_buf = [core::mem::MaybeUninit::<u8>::uninit(); Batch::header_data_len(2) + 9 + 9];
    let mut ix_accounts_buf = [core::mem::MaybeUninit::uninit(); 6]; // 3 + 3 accounts
    let mut cpi_accounts_buf = [core::mem::MaybeUninit::uninit(); 4]; // unique accounts

    let mut batch = Batch::new(&mut data_buf, &mut ix_accounts_buf, &mut cpi_accounts_buf)?;

    // First transfer: src_a -> dst_a
    Transfer { from: src_a, to: dst_a, authority, amount: 1_000_000 }.into_batch(&mut batch)?;

    // Second transfer: src_b -> dst_b
    Transfer { from: src_b, to: dst_b, authority, amount: 500_000 }.into_batch(&mut batch)?;

    // One CPI for both transfers — the 1,000 CU base cost is paid only once.
    batch.invoke()
}
Programs already built on the spl-token crate can still use batch by constructing the instruction data in the wire format above and issuing a normal invoke to the token program ID — it works because p-token runs at that same address. For new code, pinocchio-token is the cleaner path.
Batch::new requires you to pre-size three stack buffers before building the batch. Get the data-buffer size wrong (header_data_len(N) plus each sub-instruction’s data length) and you get a runtime ProgramError, not a compile error.

Build a batch instruction in TypeScript

If you do need to construct a batch from a client, the @solana-program/token client exposes getBatchInstruction. This is a low-level API — you supply each sub-instruction’s raw data and account count, and you attach the flat account list yourself.
npm install @solana-program/token @solana/kit
TypeScript
import { getBatchInstruction } from "@solana-program/token";

// Encode a Transfer sub-instruction: discriminator (3) + amount (u64 LE) = 9 bytes.
function encodeTransfer(amount: bigint): Uint8Array {
  const data = new Uint8Array(9);
  data[0] = 3; // Transfer discriminator
  new DataView(data.buffer).setBigUint64(1, amount, true);
  return data;
}

const batchIx = getBatchInstruction({
  data: [
    // Transfer uses 3 accounts: source, destination, authority.
    { numberOfAccounts: 3, instructionData: encodeTransfer(1_000_000n) },
    { numberOfAccounts: 3, instructionData: encodeTransfer(500_000n) },
  ],
});

// Attach the flat account list in sub-instruction order:
// [source1, destination1, authority1, source2, destination2, authority2]
// as the instruction's accounts, then add batchIx to your transaction message
// and send it through your Chainstack endpoint.
The client adds the per-sub-instruction length prefix for you — pass the raw instruction bytes (including each instruction’s own discriminator) in instructionData. Remember that the account count per entry must match the accounts you append, in order.

Caveats and gotchas

  • No instruction-name logs — p-token does not emit the Program log: Instruction: Transfer lines the old program logged, because logging cost ~103 CU and was a large share of each instruction’s total. If you parse logsSubscribe output or Geyser streams, parse instruction data instead of instruction-name logs.
  • Indexers must look inside batches — a batch is a single top-level instruction (discriminator 255) that hides its real operations as sub-instructions. To catch every transfer or mint, descend into discriminator-255 instructions and walk their sub-instructions; scanning only top-level instructions will miss anything bundled in a batch.
  • 255-byte data cap per sub-instruction — the dataLength header is a single byte, so each bundled instruction’s data must be 255 bytes or less. Transfers (9 bytes) are far under this, but parameter-heavy instructions can approach it.
  • Account ordering is positional — accounts are consumed in sub-instruction order from one flat list. A misordered list is a silent bug, not an error.
  • Token-2022 has no equivalent — batch exists in p-token only. Token-2022 (TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb) is a separate program with no batch mechanism.

The other new instructions

  • withdraw_excess_lamports — recovers SOL mistakenly sent to a mint or multisig account above its rent-exemption minimum, which the old program left stranded.
  • unwrap_lamports — moves lamports directly out of a wrapped-SOL token account to a destination, without the create-and-close dance.

Get your own node endpoint today

Start for free and get your app to production levels immediately. No credit card required.You can sign up with your GitHub, X, Google, or Microsoft account.

Additional resources

Last modified on May 29, 2026