Calling the Token Program via CPI

On-chain programs move tokens by issuing a Cross Program Invocation (CPI) into the Token Program. Your program builds a Token Program instruction, supplies the accounts it needs, and calls invoke (or invoke_signed when a Program Derived Address signs). The Token Program then runs that instruction with the privileges your program extends to it.

This page covers the token-specific side of that flow. For the CPI mechanism itself — how invoke and invoke_signed work, how signer and writable privileges propagate, and the per-CPI cost model — see Cross Program Invocation.

Common patterns

Most token CPIs follow the same shape: build the instruction, pass the token accounts and an authority, and invoke. The example below transfers tokens with TransferChecked, which verifies the mint and decimals as part of the transfer.

use anchor_lang::prelude::*;
use anchor_spl::token_interface::{transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked};
declare_id!("11111111111111111111111111111111");
#[program]
pub mod token_cpi {
use super::*;
pub fn transfer(ctx: Context<TokenTransfer>, amount: u64) -> Result<()> {
let cpi_accounts = TransferChecked {
from: ctx.accounts.source.to_account_info(),
mint: ctx.accounts.mint.to_account_info(),
to: ctx.accounts.destination.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
};
let cpi_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
cpi_accounts,
);
transfer_checked(cpi_ctx, amount, ctx.accounts.mint.decimals)?;
Ok(())
}
}
#[derive(Accounts)]
pub struct TokenTransfer<'info> {
#[account(mut)]
pub source: InterfaceAccount<'info, TokenAccount>,
pub mint: InterfaceAccount<'info, Mint>,
#[account(mut)]
pub destination: InterfaceAccount<'info, TokenAccount>,
pub authority: Signer<'info>,
pub token_program: Interface<'info, TokenInterface>,
}

Minting, burning, and closing accounts follow the same pattern with a different instruction — MintTo, Burn, and CloseAccount in Anchor's anchor_spl::token module, or the matching builders in pinocchio-token. For complete, runnable programs that CPI into the Token Program in both Anchor and native Rust, see the token program examples — in particular the "Transfer Tokens" example.

Signing with a PDA authority

When the authority on a token account is a Program Derived Address owned by your program, the program signs the CPI itself with invoke_signed, passing the PDA seeds as signer seeds. In Anchor, use CpiContext::new_with_signer with the seeds instead of CpiContext::new. The instruction is otherwise identical to the examples above. See CPIs with PDA Signers for the full mechanics.

Batching

The Batch instruction executes several Token Program instructions inside a single program invocation. Because each CPI carries a fixed compute cost, running several token operations in one batch CPI uses fewer compute units than issuing a separate CPI per operation — see the CPI cost model for how per-CPI costs add up.

Lower compute usage reduces the priority fees a transaction pays per compute unit and improves its chance of landing — which matters most for programs that perform many token operations in a single instruction. Common uses include fanning out a transfer to many recipients, or running a multi-step token flow (for example sync, transfer, and close) in a single CPI.

Batch only accepts Token Program instructions as children, and a batch cannot contain another batch. For the operations that move tokens (such as transfers, mints, and burns), the program verifies that the affected accounts are owned by the Token Program before executing them.

Source reference

ItemDescriptionSource
BatchThe Batch instruction (discriminator 255).Source
process_batchBatch processor logic.Source

Calling Batch via CPI

The pinocchio-token crate exposes a Batch builder under pinocchio_token::instructions::Batch. You stage each child instruction into the batch's buffers and then issue a single CPI with invoke.

A Batch is backed by three caller-provided buffers: one for the serialized instruction data, one for the per-child instruction account metas, and one for the account views passed to the CPI. Construct them as MaybeUninit slices, hand them to Batch::new, append children, then invoke:

Batch two transfers in one CPI
use core::mem::MaybeUninit;
use pinocchio::{account::AccountView, error::ProgramError, ProgramResult};
use pinocchio_token::instructions::{Batch, IntoBatch, Transfer};
/// Process SwapBatch.
///
/// Performs two transfers in a single batch CPI instead of two separate
/// `invoke()` calls.
///
/// Data layout:
/// [0..8] amount_a_to_b (u64 LE)
/// [8..16] amount_b_to_a (u64 LE)
///
/// Account layout:
/// [0] source_a (writable) — token account A
/// [1] source_b (writable) — token account B
/// [2] authority_a (signer) — authority for account A
/// [3] authority_b (signer) — authority for account B
/// [4] token_program — SPL Token program
pub fn process(accounts: &[AccountView], data: &[u8]) -> ProgramResult {
if data.len() < 16 {
return Err(ProgramError::InvalidInstructionData);
}
if accounts.len() < 5 {
return Err(ProgramError::NotEnoughAccountKeys);
}
let amount_a_to_b = u64::from_le_bytes(data[0..8].try_into().unwrap());
let amount_b_to_a = u64::from_le_bytes(data[8..16].try_into().unwrap());
let source_a = &accounts[0];
let source_b = &accounts[1];
let authority_a = &accounts[2];
let authority_b = &accounts[3];
// Two child transfers, each with 3 accounts and 9 bytes of data
// (1-byte discriminator + 8-byte amount).
const NUM_CHILDREN: usize = 2;
const CHILD_ACCOUNTS: usize = 3;
const CHILD_DATA_LEN: usize = 9;
// Data buffer: 1-byte batch discriminator + per child (2-byte header + data).
const DATA_LEN: usize = 1 + NUM_CHILDREN * (2 + CHILD_DATA_LEN);
// Account buffers: total accounts across all children.
const ACCOUNTS_LEN: usize = NUM_CHILDREN * CHILD_ACCOUNTS;
let mut data_buf = [MaybeUninit::uninit(); DATA_LEN];
let mut ix_accounts_buf = [MaybeUninit::uninit(); ACCOUNTS_LEN];
let mut accounts_buf = [MaybeUninit::uninit(); ACCOUNTS_LEN];
let mut batch = Batch::new(&mut data_buf, &mut ix_accounts_buf, &mut accounts_buf)?;
Transfer::new(source_a, source_b, authority_a, amount_a_to_b).into_batch(&mut batch)?;
Transfer::new(source_b, source_a, authority_b, amount_b_to_a).into_batch(&mut batch)?;
batch.invoke()?;
Ok(())
}

Batch::new writes the batch discriminator into the first byte of the data buffer and returns a Batch that tracks how much of each buffer has been used. Every Token Program instruction builder (such as Transfer, TransferChecked, MintTo, and Burn) implements the IntoBatch trait, so into_batch appends that instruction's data, account metas, and account views to the batch. Calling invoke issues the assembled batch as one CPI into the Token Program.

The buffers bound the batch's capacity. Size the data buffer to 1 + Σ(2 + child_data_len) bytes (the discriminator, plus a 2-byte header and the serialized data for each child) and each account buffer to the total number of accounts across all children. If a child does not fit, into_batch returns ProgramError::InvalidArgument. For reference, Batch::MAX_DATA_LEN is 10 KiB and Batch::MAX_ACCOUNTS_LEN equals the runtime's maximum CPI account count.

For batches whose size is only known at runtime, enable the crate's alloc feature and use BatchState::new(accounts_len, data_len) to allocate the buffers on the heap, then call as_batch to obtain a Batch.

When a child instruction's authority is a PDA owned by your program, sign the CPI with invoke_signed instead of invoke, passing the PDA seeds as Signer entries.

Instruction data layout

A batch encodes its children one after another behind the 255 discriminator. All accounts are passed flat, in order, and sliced by num_accounts for each child. The builder produces exactly this wire format:

Batch Wire Format
[255] // Batch discriminator
// For each child instruction, in order:
// [num_accounts: u8] // accounts this instruction consumes
// [data_len: u8] // length of this instruction's data
// [data: u8; data_len]// the instruction data (begins with its own discriminator)

A batch's capacity is bounded by the buffers passed to Batch::new: the data buffer must hold the discriminator plus every child's header and data, and the account buffers must hold every child's accounts. Size them for the largest batch you build, or use BatchState (with the alloc feature) to size them at runtime. The combined CPI is still subject to the transaction's account and size limits, so for very large fan-outs use Address Lookup Tables to fit more accounts.

Is this page helpful?

Sisällysluettelo

Muokkaa sivua
© 2026 Solana Foundation. Kaikki oikeudet pidätetään.
Calling the Token Program via CPI | Solana