The thing I wish someone had told me on Day 71
Five days ago, CPI was three letters I kept skimming past in the Anchor docs. I nodded along, assumed it meant "one program calls another," and moved on before the details could embarrass me. That worked right up until I had to write one myself, stared at CpiContext::new, and realized I couldn't explain which argument was which or why the compiler kept telling me it wanted a Pubkey where I'd handed it an AccountInfo.
Here's the sentence I wish past-me had pinned to the top of a notebook: a CPI is a function call with a guest list. Your program doesn't just say "run this instruction on my behalf" — it has to name the exact program it's calling, hand over every account that instruction touches, and prove that whoever is authorizing the change is actually allowed to. Miss any one of those and the runtime stops you cold, every time, no exceptions.
The mental model
Every CPI you'll ever write, no matter which program is on the other end, comes down to the same three pieces:
The program being called — identified by its program ID. This is who you're calling, not what you're asking for.
The accounts that program needs — your program doesn't own these accounts, it just passes them through. The callee decides what it needs; you supply exactly that, no more, no less.
The signer authority — either a real wallet that already signed your outer transaction (its signature flows down into the CPI automatically, no extra code required), or a PDA that has no private key at all. For a PDA, your program "signs" by handing the runtime the exact seeds used to derive that address. The runtime re-derives the address from those seeds; if it matches, the PDA is treated as a signer for the length of that one call, and never again after.
Once those three pieces are separate in your head, every CPI reads the same way: bundle a program ID and an accounts struct into a CpiContext, then hand that context to a helper function that fires the actual invocation.
One CPI, trimmed down
The cleanest example I've got is a vault that signs for itself. A user deposits SOL into a PDA, and later the program — not the user — authorizes the withdrawal, because the vault has no keypair of its own:
rust
pub fn withdraw(ctx: Context, amount: u64) -> Result<()> {
let user_key = ctx.accounts.user.key();
let bump = ctx.bumps.vault;
// The recipe for the vault: literal seed, owner key, canonical bump.
let signer_seeds: &[&[&[u8]]] = &[&[b"vault", user_key.as_ref(), &[bump]]];
// The vault has no private key, so the program signs for it.
let cpi_ctx = CpiContext::new(
ctx.accounts.system_program.key(),
Transfer {
from: ctx.accounts.vault.to_account_info(),
to: ctx.accounts.user.to_account_info(),
},
)
.with_signer(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>,
}
Map that back to the three pieces: the System Program is who's being called, Transfer { from, to } is the guest list it expects, and signer_seeds is the proof of authority — a recipe instead of a signature. The seeds on the #[account(...)] constraint and the signer_seeds slice have to describe the exact same PDA, because the runtime re-derives the address from both and checks they land on the same account.
What tripped me up
Partway through deliberately breaking this same withdraw function, I fed it the wrong bump and got this back:
AnchorError caused by account: tally. Error Code: ConstraintSeeds. Error Number: 2006. Error Message: A seeds constraint was violated.
Program log: Left: 4Ea3EzynSnP5B7G5ad3tQwz2Go5u9LGy6ywMjyujy7Vn
Program log: Right: 2ALUabpuiAzAdbm8D5h3kBVMiuTTzC1iN8NyXmLucx5T
ConstraintSeeds means Anchor re-derived the PDA from the seeds you declared on the account struct and got a pubkey that doesn't match what the client actually passed in — the Left:/Right: pair in the logs is literally "expected vs. got." The fix was boring once I understood it: my seeds recipe on the Rust side and the derivation on the client side have to be byte-for-byte identical, because the runtime doesn't trust intent, only the math.
That error is easy to misread as "something is broken in my program logic." It isn't. It's Anchor telling you, with total precision, that the account you handed it isn't the account its own constraints say it should be — and it catches that before your instruction body ever runs.
Where to go from here
If you want the full picture, the Anchor CPI documentation covers CpiContext and invoke_signed in more depth than a 900-word post can, and the Token-2022 docs are worth having open the first time you CPI into a token program instead of the System Program.
This post draws from Day 73 through Day 75 of #100DaysOfSolana — moving SOL with a CPI, minting Token-2022 tokens from inside a program, signing for a PDA-owned vault, composing two of my own programs, and deliberately breaking all of it to see what the failures actually look like.
Top comments (0)