Five days ago CPI was three letters in the docs. Now I have called the System Program to move SOL, Token-2022 to mint tokens, a PDA vault to release lamports, and my own counter program to bump a tally — all through the same mechanism.
Thesis: A cross-program invocation is a function call with a guest list. The runtime does not care whether the callee is built into Solana or forty lines you wrote this morning. Your job is to bring the right program ID, the right accounts, and the right signer authority.
The mental model
Strip away the syntax. Every CPI answers three questions.
Which program are you calling?
A program ID — a public key. In Anchor 1.0, CpiContext::new takes that Pubkey as its first argument: ctx.accounts.counter_program.key(), not to_account_info(). Point at the wrong program and a real one still runs — just not the one you meant. I once passed the System Program ID (11111111…) while sending counter instruction bytes. The log said invalid instruction data. Fair enough. The System Program was not broken; my target was.
Which accounts does it need?
The callee owns an accounts struct: Transfer for SOL, MintTo for tokens, Increment for my counter. You pass every account it expects, wired to AccountInfo. Skip one and Anchor names it: AccountNotEnoughKeys on admin_config means the callee added an account your CPI did not include. Pass the wrong account and you get ConstraintSeeds or has_one instead — same idea, different constraint.
Who authorizes the call?
Two paths, and mixing them up causes most CPI pain.
When a wallet signs the outer transaction, the runtime carries that signature into the inner call. No extra signing code. That is how my Day 71 SOL transfer and Day 72 token mint worked.
When a PDA must authorize, there is no private key. Your program calls invoke_signed with the exact seed bytes plus canonical bump. In Anchor that is .with_signer(signer_seeds) on the CpiContext. Those seeds must match #[account(seeds = ..., bump)] on the PDA account — byte for byte, bump included.
The helper function — transfer, mint_to, cpi::increment — is just the envelope.
The code
Here is compose_lab calling my own counter. Same shape as calling the System Program on Day 71:
pub fn bump(ctx: Context<Bump>) -> Result<()> {
let cpi_ctx = CpiContext::new(
ctx.accounts.counter_program.key(),
Increment {
tally: ctx.accounts.tally.to_account_info(),
},
);
cpi::increment(cpi_ctx)?;
Ok(())
}
#[derive(Accounts)]
pub struct Bump<'info> {
#[account(mut)]
pub tally: Account<'info, Tally>,
pub counter_program: Program<'info, Counter>,
}
declare_program!(counter) read my counter's IDL and generated cpi::increment — the same role system_program::transfer plays for built-in programs. The caller never compiled counter's source. Change counter's internals tomorrow; if the IDL holds, the caller stays the same.
The PDA-signed variant adds one thing — .with_signer — on top of the same pattern:
let signer_seeds: &[&[&[u8]]] = &[&[b"vault", user_key.as_ref(), &[bump]]];
let cpi_ctx = CpiContext::new(
ctx.accounts.system_program.key(),
Transfer { from: vault, to: user },
)
.with_signer(signer_seeds);
transfer(cpi_ctx, amount)?;
User deposit: plain CpiContext::new — the wallet signed. Vault withdraw: .with_signer — the vault is a PDA with no keypair.
What tripped me up
On Day 75 I broke vault withdraw on purpose by using the wrong bump. The terminal was specific:
AvSWLkjNFjJ5wrw4jL6342nBUygcwn2By745qHci5gtw's signer privilege escalated
Program failed: Cross-program invocation with unauthorized signer or writable account
The runtime re-derived an address from my seeds, it did not match the vault in the instruction, and it refused to authorize the transfer. Fix: ctx.bumps.vault in signer_seeds — the same bump as the account constraint.
That line is not noise. It is a diagnosis. Read program logs top to bottom; [1] and [2] show which program was running when things broke.
None of my CPI failures were logic bugs inside handlers. They were mismatches at the boundary: accounts the caller passed versus constraints the callee enforced. When a CPI breaks, one of those two sides moved.
This draws from #100DaysOfSolana Days 71–75. Code: solana-100-days — sol-mover, token_cpi, vault, compose-lab.