CPI on Solana: The Mental Model I Wish I Had on Day 71
The One Sentence That Unlocks All of It
A CPI is a function call with a guest list. You name the program you want to call, you pass it the accounts it needs, and you prove who is allowed to sign. Every line of CPI code is doing one of those three things.
What the Three Pieces Actually Are
The program being called
Every program on Solana lives at a public key. When you write CpiContext::new(ctx.accounts.system_program.key(), ...), that first argument is the on-chain address of the program you want to hand execution over to. You are not importing a library. You are calling an address that holds executable bytecode. Once that framing clicked for me, the rest of the pattern made sense.
The accounts it needs
The callee has its own #[derive(Accounts)] struct with expectations. Your job as the caller is to satisfy those expectations by passing the right accounts through. Anchor ships typed structs for common programs - Transfer { from, to } for the System Program, MintTo { mint, to, authority } for Token-2022. You pull those from your own ctx.accounts, fill in the struct, and bundle it into a CpiContext.
The rule I missed on Day 71: every program you CPI into must appear as an account in your own #[derive(Accounts)]. It is not enough to know the address. You need pub system_program: Program<'info, System> in your struct or Anchor will not compile.
Who is authorised to sign
There are exactly two cases. Case one is a real user wallet. The user already signed the outer transaction and the Solana runtime carries that authority down into every CPI automatically. You write zero extra code.
Case two is a PDA. A PDA has no private key so it cannot sign the normal way. Instead you re-supply the same seeds you used to derive the PDA. The runtime re-derives the address from those seeds, checks it against the account you passed, and if they match that counts as the signature. Seeds stand in for a private key. That is the entire trick behind CpiContext::new_with_signer.
Day 71: The smallest possible CPI - SOL transfer to the System Program
This is the complete sol-mover handler from my repo. This is already as small as a CPI gets.
use anchor_lang::prelude::*;
use anchor_lang::system_program::{transfer, Transfer};
declare_id!("2RuhecMfTQqGwfgEC47ca965VqGUbTGefypkSY5Re6ob");
#[program]
pub mod sol_mover {
use super::*;
pub fn sol_transfer(ctx: Context<SolTransfer>, amount: u64) -> Result<()> {
let cpi_accounts = Transfer {
from: ctx.accounts.sender.to_account_info(),
to: ctx.accounts.recipient.to_account_info(),
};
let cpi_context = CpiContext::new(
ctx.accounts.system_program.key(),
cpi_accounts,
);
transfer(cpi_context, amount)?;
Ok(())
}
}
#[derive(Accounts)]
pub struct SolTransfer<'info> {
#[account(mut)]
pub sender: Signer<'info>,
#[account(mut)]
pub recipient: SystemAccount<'info>,
pub system_program: Program<'info, System>,
}
Map it to the three pieces: system_program.key() is the program, Transfer { from: sender, to: recipient } is the guest list, sender: Signer is the authority - the user signed the outer transaction so the runtime forwards it automatically.
Full source: day-71-sol-cpi
Day 72: The same pattern, different callee - Token-2022
Day 72 proved that CpiContext is callee-agnostic. Instead of calling the System Program, this calls Token-2022's mint_to instruction. The struct changes to MintTo { mint, to, authority } and the program account becomes Interface<'info, TokenInterface> - which accepts both classic SPL Token and Token-2022 at runtime without a code change.
use anchor_lang::prelude::*;
use anchor_spl::token_interface::{
mint_to, Mint, MintTo, TokenAccount, TokenInterface,
};
#[program]
pub mod token_cpi {
use super::*;
pub fn mint_tokens(ctx: Context<MintTokens>, amount: u64) -> Result<()> {
let cpi_accounts = MintTo {
mint: ctx.accounts.mint.to_account_info(),
to: ctx.accounts.token_account.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
};
let cpi_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
cpi_accounts,
);
mint_to(cpi_ctx, amount)?;
Ok(())
}
}
#[derive(Accounts)]
pub struct MintTokens<'info> {
#[account(mut)]
pub mint: InterfaceAccount<'info, Mint>,
#[account(mut)]
pub token_account: InterfaceAccount<'info, TokenAccount>,
pub authority: Signer<'info>,
pub token_program: Interface<'info, TokenInterface>,
}
The test minted 1_000_000_000 base units and confirmed the balance. Same CpiContext::new shape, different program and accounts struct.
Full source: day-72-token-cpi
Day 73: PDA-signed CPI - the vault withdraw
Day 73 introduced CpiContext::new_with_signer. The vault PDA holds SOL and the program needs to sign for it without a private key. The only thing that changes from Day 71 is new becomes new_with_signer and you pass signer_seeds.
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
let user_key = ctx.accounts.user.key();
let bump = ctx.bumps.vault;
let signer_seeds: &[&[&[u8]]] = &[&[b"vault", user_key.as_ref(), &[bump]]];
let cpi_ctx = CpiContext::new_with_signer(
ctx.accounts.system_program.key(),
Transfer {
from: ctx.accounts.vault.to_account_info(),
to: ctx.accounts.user.to_account_info(),
},
signer_seeds,
);
transfer(cpi_ctx, amount)?;
Ok(())
}
#[derive(Accounts)]
pub struct Withdraw<'info> {
#[account(mut)]
pub user: Signer<'info>,
#[account(
mut,
seeds = [b"vault", user.key().as_ref()],
bump,
)]
pub vault: SystemAccount<'info>,
pub system_program: Program<'info, System>,
}
The seeds in signer_seeds and the seeds in #[account(seeds = [...])] must match byte for byte. The bump comes from ctx.bumps.vault - Anchor finds the canonical bump during account validation and stores it there. Never hardcode it.
Test output: vault balance after deposit was 500_000_000 lamports, vault balance after withdraw was 0.
Full source: day-73-vault
Day 74: Program calling another program - compose-lab
Day 74 pushed the pattern one step further. Instead of calling a system program, compose-lab calls another Anchor program I wrote called counter. Anchor 1.0's declare_program! macro imports the callee's full type system from its IDL - accounts structs, CPI modules, and program type - giving compile-time safety with no raw instruction building.
counter/src/lib.rs (the callee)
use anchor_lang::prelude::*;
declare_id!("8GM63Fe2dFnGEhxhJLh162GJ4cSpWgb927uW6sd2vzbx");
#[program]
pub mod counter {
use super::*;
pub fn increment(ctx: Context<Increment>) -> Result<()> {
ctx.accounts.tally.count += 1;
msg!("counter is now {}", ctx.accounts.tally.count);
Ok(())
}
}
#[derive(Accounts)]
pub struct Increment<'info> {
#[account(mut)]
pub tally: Account<'info, Tally>,
}
#[account]
#[derive(InitSpace)]
pub struct Tally {
pub count: u64,
}
compose-lab/src/lib.rs (the caller)
use anchor_lang::prelude::*;
declare_program!(counter);
use counter::{
accounts::Tally,
cpi::{self, accounts::Increment},
program::Counter,
};
declare_id!("Gs4H4zaqUtRC1iPJhcvYxcQwpq4sakkHoiJBiRpCRALM");
#[program]
pub mod compose_lab {
use super::*;
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>,
}
Two independent programs composing atomically. If the CPI fails, the whole outer transaction rolls back - no partial state. The caller never owns the Tally account. It just passes it through. Ownership stays with counter.
Test output: counter value set by the caller: 1 - one passing test.
Full source: day-74-compose-lab
Day 75: The error that taught me the most
On Day 75 I deliberately broke three CPIs to learn how to read the logs. The most useful failure was changing b"vault" to b"vaultX" in the signer seeds on the Day 73 vault withdraw. My terminal printed:
Program log: AnchorError caused by account: vault. Error Code: ConstraintSeeds
Error Number: 2006
Error Message: A seeds constraint was violated.
Program 11111111111111111111111111111111 invoke
Program 11111111111111111111111111111111 failed: privilege escalation
ConstraintSeeds is Anchor telling you the seeds you passed do not reproduce the PDA it expects. The privilege escalation below it is the System Program refusing to move lamports from an account you do not control. These two errors arrive together every time this happens. Start by checking that every byte in your signer_seeds matches the corresponding byte in your #[account(seeds = [...])] attribute, including the bump, and that the bump comes from ctx.bumps not a hardcoded value.
The three-category mental model I built from that session
| What you see | Where to look |
|---|---|
ConstraintSeeds + privilege escalation |
Seeds or bump mismatch in signer_seeds |
ConstraintHasOne with an account name |
Caller did not satisfy callee's has_one constraint |
invalid instruction data from a wrong program |
CpiContext is pointing at the wrong program ID |
Full source: day-75-cpi-failures
The pattern is the same every time
After five days across four different callees the CpiContext shape never changed. What changed was which accounts struct you fill in and whether you use new or new_with_signer. That is the whole surface area of the API.
If you want the full picture from the official sources:
- Solana docs: Cross-Program Invocations
- Anchor docs: CPI
- Anchor docs: declare_program!
- anchor_lang::system_program
- Token-2022 docs
This post draws from Days 71 through 75 of my #100DaysOfSolana journey. Day 71 was the first CPI to the System Program, Day 72 was Token-2022, Day 73 was the PDA vault with a PDA-signed withdraw, Day 74 was one Anchor program calling another via declare_program!, and Day 75 was breaking each of those deliberately and reading the logs.
All the code is at github.com/gopichandchalla16/100-days-of-solana.
Comments
No comments yet. Start the discussion.