For a long time, I assumed every signature on Solana came from someone's wallet. That seemed obvious. If a transaction needed authorization, the owner of the account would sign it with their private key.
Then I built a program that withdrew SOL from a PDA-owned vault. The vault had no private key, yet the transfer succeeded. That forced me to rethink what "signing" actually means on Solana.
My mental model of CPIs
When I first learned about Cross-Program Invocations (CPIs), I imagined them as a way of doing familiar things programmatically. If I wanted to mint tokens, my program would somehow mint the tokens itself instead of me running CLI commands.That wasn't what was happening at all.
Instead, my program was asking another program to perform the operation. The first CPI I built transferred SOL by calling the System Program, my program never moved the SOL directly. It simply constructed the instruction, passed in the required accounts, and delegated the work to the System Program.
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(())
}
pub struct SolTransfer<'info> {
#[account(mut)]
pub sender: Signer<'info>,
#[account(mut)]
pub recipient: SystemAccount<'info>,
pub system_program: Program<'info, System>,
}
That taught me something I hadn't appreciated before:
Solana programs don't duplicate functionality. They build on top of one another.
That design keeps programs small and composable. Instead of every program implementing the same logic, they can reuse trusted programs that already exist.
I used exactly the same idea to mint Token-2022 tokens. The destination program changed, but the pattern didn't. Instead of calling the System Program, my Anchor program called the Token-2022 program.
I took it one step further by making one Anchor program call another using declare_program!. That was when I realized something important, a CPI isn't a feature tied to one program, it's a general communication pattern. Once I understood how one program invokes another, the specific program being called became almost incidental. Whether it's the System Program, the Token-2022 program, or another Anchor program, the idea stays the same.
Authority without private keys
The biggest shift happened when I built a PDA-owned vault. The vault held SOL. Eventually, the program needed to withdraw those funds. At first, that raised an obvious question.
How can a PDA authorize a transfer if it doesn't have a private key?
That was the assumption I had been carrying until I came across with_signer().
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(
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(())
}
Instead of providing a private key, the program supplies the PDA's signer seeds. The Solana runtime uses those seeds and the program ID to derive the PDA again. If everything matches, the runtime temporarily recognizes that PDA as the signer for that specific Cross-Program Invocation and no private key is involved. The program isn't forging a signature, it's proving that it has the authority to act on behalf of the PDA it originally derived. That distinction completely changed how I thought about program authority.
If someone had asked me who signs transactions on Solana, my answer would have been simple: "The owner of the account."
Now I'd answer differently. Sometimes the signer is a keypair-controlled account. Sometimes the authority belongs to a Program Derived Address. The important difference is that a PDA isn't another wallet. It has no private key. Instead, the runtime verifies that the program knows the correct seeds used to derive that PDA before allowing it to authorize the CPI. The authorization comes from deterministic derivation, not from possession of a secret key.
Breaking things made the logs make sense
One of the most useful exercises this week was deliberately breaking working CPIs to understand how the runtime enforces authority.
One error I ran into was:
compose-lab
the caller bumps the counter through a CPI:
Error: AnchorError caused by account: tally. Error Code: ConstraintSeeds. Error Number: 2006. Error Message: A seeds constraint was violated.
Program log: Left:
FsYY6DU3FZf7j1HnpfVEAMPr1p4HJbXEdgktN4bYJjmq
Program log: Right:
AAnoAEWwp4YgADN1Svd963buvUuWyuDdrJPXaYWi55Ck
After reading it more carefully, I realized it meant that the account I passed into the instruction didn't match the PDA Anchor derived from the seeds declared in my #[derive(Accounts)] constraint. Because the derived address and the supplied account were different, Anchor rejected the instruction before any of my program logic executed.
The fix was to make sure the client and the program were deriving the PDA from the exact same seeds (and bump). Once they matched, the constraint passed and the instruction executed normally.
What I know now
- A CPI isn't another type of instruction. It's one program asking another program to perform work.
- Programs don't bypass authorization just because they're programs.
- A PDA doesn't have a private key. It authorizes actions by proving it was derived from the correct seeds.
-
with_signer()doesn't create a signature. It tells the runtime how to verify the PDA's authority. - Runtime errors become much easier to understand once you know what the runtime is actually validating.
This post is part of my #100DaysOfSolana journey and covers Days 71–75.
If you've been following along, it builds on my previous post about Program Derived Addresses (PDAs) and continues the transition from understanding how programs find state to how they safely act on it.
Top comments (0)