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
| Item | Description | Source |
|---|---|---|
Batch | The Batch instruction (discriminator 255). | Source |
process_batch | Batch 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:
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 programpub 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:
[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?