When I started learning Solana, Cross-Program Invocation (CPI) sounded far more complicated than it actually is.
I kept seeing CpiContext, invoke_signed, program IDs, account structs, and signer seeds everywhere. I could copy the examples, but if someone had asked me what was really happening under the hood, I probably wouldn't have had a good answer.
A few days later, after building a vault program, making one program call another, and intentionally breaking my own code just to understand the errors, everything finally clicked.
If I could go back to Day 71 and explain CPI to myself in one sentence, it would be this:
A CPI is just one Solana program asking another program to do some work, but it must bring the right program, the right accounts, and the right signer.
That simple idea made everything else easier to understand.
The mental model that finally made sense
Coming from Web2, I started thinking of a CPI like calling a function from another module.
Except on Solana, you can't just call the function.
You have to arrive with three things:
- The program you want to call (identified by its Program ID).
- Every account that program expects.
- Who has the authority to approve the action.
Miss any one of those, and the runtime immediately complains.
That's really all a CpiContext is doing.
It packages those three pieces together before handing execution over to another program.
Once I stopped seeing CpiContext as some mysterious Anchor feature and started seeing it as a container for "who, what, and with what authority," the code became much easier to read.
A real example from my project
One of the exercises I built this week was a simple program that calls another program to increment a counter.
The handler was surprisingly small:
pub fn bump(ctx: Context<Bump>) -> Result<()> {
let cpi_ctx = CpiContext::new(
ctx.accounts.counter_program.key(),
compose_lab::cpi::accounts::Increment {
tally: ctx.accounts.tally.to_account_info(),
},
);
compose_lab::cpi::increment(cpi_ctx)?;
Ok(())
}
There isn't much happening here.
The caller simply says:
- "Here's the program I want to call."
- "Here's the account it needs."
- "Go execute its
incrementinstruction."
Earlier in the week, I also built a vault where a PDA signed on behalf of the program.
That was another moment where things finally clicked.
A PDA doesn't have a private key sitting somewhere waiting to sign a transaction.
Instead, the runtime recomputes the PDA using the same seeds and bump your program provides. If they match the PDA's address, the runtime treats that PDA as an authorized signer.
That made invoke_signed() feel a lot less magical.
What tripped me up
One of the best parts of this week's challenge was intentionally breaking working code.
I changed the PDA seeds.
I changed account constraints.
I even pointed a CPI at the System Program instead of my own program.
Each failure taught me something different.
One error I saw was:
Error Code: ConstraintSeeds.
Error Message: A seeds constraint was violated.
At first glance it looked intimidating.
But after slowing down and reading it, the message was actually telling me exactly what was wrong.
The PDA the caller passed didn't match the PDA the callee expected.
Later, I intentionally pointed my CPI to the System Program and got:
Program 11111111111111111111111111111111 failed:
invalid instruction data
That wasn't because my instruction was bad.
It was because I had sent perfectly valid instruction data to the wrong program.
Once I understood what each error was trying to tell me, debugging became much less frustrating.
Instead of random red text, the logs started feeling like clues.
My biggest takeaway
Looking back, CPI wasn't the hard part.
The hard part was trying to understand the code before I understood the mental model.
Once I realized every CPI comes down to the program, the accounts, and the signer, everything else started fitting together.
Even the errors became useful.
That's probably the biggest lesson I've learned so far in this Solana journey.
Top comments (0)