I stared at CpiContext::new(...) for a solid ten minutes on Day 71. I knew
the arguments existed. I had no idea what they meant. I copied working code,
ran the test, watched it go green, and moved on. Three days later a PDA-signed
withdraw blew up on me and I realised I had been cargo-culting the pattern
without actually understanding it.
Here is everything I wish someone had told me before I wrote a single line of CPI code.
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.