On Day 71, I thought CPI was a magic black box. I knew it stood for Cross-Program Invocation, but I didn't understand why the runtime needed a program ID, accounts, and sometimes signer seeds. After working through this arc, I realized the pattern is always the same. The program ID says which program to call, the accounts say what data to read or write, and the signer seeds say who's authorized. Whether you're moving SOL through the System Program, minting tokens through Token-2022, or calling your own counter program, the CpiContext shape never changes.
The mental model
A CPI is how one program calls another. Think of it like a function call, but with three explicit pieces.
The program ID tells the runtime which program you're calling. This is like a function name in a library.
The accounts are the data the callee needs. This is like passing arguments, but the arguments are accounts on-chain.
The signer authority is who is authorizing this call. This is either a real wallet (the signature flows through) or a PDA (the program signs with seeds).
The same CpiContext struct bundles all three. You build it once and pass it to the helper function. The runtime handles the rest.
A real code snippet
This is the smallest CPI I wrote this week. The caller program calls the counter program's increment instruction.
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(())
}
The first argument is the program ID. counter_program.key() tells the runtime which program to call.
The second argument is the accounts struct. Increment { tally: ... } provides the account the counter program needs.
The signer authority is implied here. The user signed the outer transaction, and that signature flows through to the CPI automatically.
If the signer was a PDA, I would need .with_signer(signer_seeds) to prove the program owns the PDA. But for a regular wallet, the signature just works.
The cpi::increment helper does the actual invocation. If the counter program's increment fails, this function returns an error and the entire transaction rolls back.
What tripped me up
The error that tripped me up most was ConstraintSeeds. I changed one byte in the signer seeds — from b"vault" to b"voult" — and the transaction failed with:
Program log: AnchorError caused by account: vault. Error Code: ConstraintSeeds.
The error meant the runtime re-derived the PDA address from my seeds and got a different address than the account I passed. The seeds must match exactly. One byte off and the runtime refuses to sign.
The fix was simple: match the seeds in the CPI to the seeds in the #[account(seeds = ...)] constraint. If they match, the runtime treats the PDA as a signer. If they don't, you get ConstraintSeeds.
Closing
CPIs are how Solana programs compose. The pattern is always the same: program ID, accounts, signer. Whether you're calling the System Program, Token-2022, or your own code, the CpiContext shape doesn't change.
If you're stuck, check the three pieces. The program ID tells the runtime where to go. The accounts tell it what data to touch. The signer tells it who authorized the call. When all three line up, the CPI works.
This post draws from Days 71-75 of #100DaysOfSolana.
Top comments (0)