Solana CPI: how one program calls another, the actual mechanics
Cross-Program Invocation lets a Solana program call another program. Here's invoke vs invoke_signed, account propagation, the 4-deep limit, and CPI return data.
Cross-Program Invocation (CPI) is how Solana programs compose. Your program calls into another program — the Token program for a transfer, an AMM for a swap, your own helper program for shared logic — and the called program runs in the same transaction, with the same compute budget, against the same accounts.
It's the mechanism behind every meaningful on-chain action beyond "move my lamports". Here's how it actually works.
invoke and invoke_signed
Two entry points, identical except for who can sign:
use solana_program::program::{invoke, invoke_signed};
// User signs — no PDA involvement
invoke(
&instruction, // the Instruction you're calling
&account_infos, // accounts the called program needs
)?;
// A PDA owned by your program signs (in addition to any user signers)
invoke_signed(
&instruction,
&account_infos,
&[&[b"vault", user.as_ref(), &[bump]]], // signer seeds
)?;The runtime takes the seeds, derives the PDA from them + your program ID, and grants that PDA signer privileges on the called instruction. The PDA can now satisfy is_signer checks in the called program.
What an Instruction looks like
use solana_program::instruction::{AccountMeta, Instruction};
use spl_token::ID as TOKEN_PROGRAM_ID;
let transfer_ix = Instruction {
program_id: TOKEN_PROGRAM_ID,
accounts: vec![
AccountMeta::new(source_ata, false), // writable, not signer
AccountMeta::new(dest_ata, false), // writable, not signer
AccountMeta::new_readonly(authority, true), // readonly, signer
],
data: spl_token::instruction::TokenInstruction::Transfer { amount }.pack(),
};
invoke(&transfer_ix, &[source_info, dest_info, authority_info, token_program_info])?;Three things matter for every account you pass:
- writable bit — must match what the called program expects, must be authorised by the user's transaction (the runtime checks both)
- signer bit — same rule; the called program requires
is_signer = trueif it's a privileged operation - the AccountInfo in your account_infos slice — the runtime uses this to forward the actual account data
The Anchor wrapper
Anchor generates CPI helpers from the IDL, so you don't hand-construct the Instruction:
use anchor_lang::prelude::*;
use anchor_spl::token::{self, Transfer};
// Inside an instruction handler:
pub fn handle(ctx: Context<MyCtx>, amount: u64) -> Result<()> {
let cpi_accounts = Transfer {
from: ctx.accounts.source.to_account_info(),
to: ctx.accounts.dest.to_account_info(),
authority: ctx.accounts.vault_pda.to_account_info(),
};
let seeds: &[&[u8]] = &[b"vault", ctx.accounts.user.key().as_ref(), &[ctx.bumps.vault_pda]];
let signer = &[seeds];
let cpi_ctx = CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
cpi_accounts,
signer,
);
token::transfer(cpi_ctx, amount)?;
Ok(())
}The 4-deep limit
Solana caps CPI nesting at 4 levels below your program. Your program → A → B → C → D — that's the maximum chain. If D tries to CPI into E, the runtime errors with InvocationDepthExceeded.
In practice this rarely matters for end-user instructions (a swap is typically: your program → Jupiter → AMM → Token program = 3 deep) but it's a real constraint for protocols that compose many programs.
Return data
Programs can return up to 1024 bytes via set_return_data, and the caller can read it with get_return_data right after the CPI returns:
// In the called program:
use solana_program::program::set_return_data;
set_return_data(&result_bytes);
// In the caller, after invoke()/invoke_signed():
use solana_program::program::get_return_data;
if let Some((program, data)) = get_return_data() {
assert_eq!(program, expected_callee);
let result: MyResult = MyResult::try_from_slice(&data)?;
// use result...
}Return data only persists until the next CPI returns — read it immediately or store it.
Account privilege rules
The runtime enforces three rules when forwarding accounts:
- A program can only mark an account
is_signer = truein a CPI if either (a) the account is already a signer of the outer transaction, or (b) the account is a PDA derived from this program and the right seeds were supplied toinvoke_signed. - A program can only mark an account
is_writable = trueif it was writable in the outer transaction. - A program can only pass an account owned by another program as mutable if it has signing authority over that account's owner relationship — i.e., it owns the account.
Violations are rejected by the runtime, not silently. Your CPI either succeeds with all privilege checks satisfied, or the entire transaction reverts.
Compute budget across the call
The called program runs against the same compute budget as the caller — there's no separate quota. If you have 200k CUs left when you call into Jupiter, Jupiter runs against those 200k. If Jupiter uses 50k, you have 150k left for the rest of your instruction.
Plan compute budget for the whole call tree. Set the CU limit on the outer transaction with ComputeBudgetProgram::set_compute_unit_limit to accommodate the deepest path.
Common pitfalls
- Forgetting to pass the program ID's AccountInfo. Every program you CPI into needs its own program AccountInfo in the account_infos slice, even though it's not strictly an "account" you read or write.
- Mismatched account order. The Instruction's accounts vec and the account_infos slice need to be aligned — the runtime matches by pubkey, but errors are confusing if you get this wrong.
- Stale signer_seeds. If you cached signer seeds across instruction handlers, the bump might no longer be canonical for the PDA you're trying to sign for. Re-derive fresh per call.
- CPI to an unverified program ID. Always pin the program ID you're calling against a known constant. A program ID passed in by the caller could be a malicious clone.
References
CPI is the composition primitive — every interesting Solana protocol is a tree of programs calling each other through it. Understand invoke vs invoke_signed, the privilege rules, and the 4-deep limit, and you understand how Solana programs compose.