I spent four days building programs that move SOL and tokens on Solana.
Day 71 was a simple SOL transfer through a CPI. Day 74 was a mint
whose authority belongs to a PDA with no private key — meaning nobody
can ever mint outside the program's rules again.
This post is what I learned between those two points, written for
someone who understands Web2 backend code but has never written a
Solana program.
The One Sentence Version
A CPI is a program calling another program. A PDA signer is how a
program proves it authorized that call without holding a private key.
Everything else is details.
What CPIs Actually Are
In Web2, your backend calls external APIs. A payment service, a
messaging provider, a database driver. You pass credentials, the
service does something, you get a result.
On Solana, programs call other programs the same way — except the
"credentials" are not an API key. They are either a wallet signature
that propagated from the outer transaction, or a set of seeds that
prove the calling program derived a specific address.
The mechanism is CpiContext:
let cpi_ctx = CpiContext::new(
ctx.accounts.system_program.key(), // which program to call
Transfer { // the accounts it needs
from: ctx.accounts.user.to_account_info(),
to: ctx.accounts.vault.to_account_info(),
},
);
transfer(cpi_ctx, amount)?; // the instruction
Three things: the program to call, the accounts it needs, the
instruction to run. The ? makes it atomic — if the CPI fails,
the whole transaction rolls back.
The Two Cases
Case 1: the wallet signs
When a user signs the outer transaction, that signature propagates
automatically into any CPI that uses the wallet as an authority.
You do not have to do anything special.
// User signed the tx → their wallet can authorize this CPI
transfer(CpiContext::new(system_program, Transfer {
from: user_wallet,
to: recipient,
}), amount)?;
This is what I used on Day 71 (SOL transfer) and Day 72 (token mint).
The program is a policy layer — it checks conditions, then forwards
the call.
Case 2: the program signs
When the program needs to authorize something the user never signed
for — like returning SOL from a vault or minting a reward — it uses
PDA signer seeds:
let signer_seeds: &[&[&[u8]]] = &[&[
b"vault",
user_key.as_ref(),
&[bump]
]];
let cpi_ctx = CpiContext::new(system_program, Transfer {
from: vault_pda,
to: user_wallet,
}).with_signer(signer_seeds); // ← this is the entire mechanism
transfer(cpi_ctx, amount)?;
The runtime re-derives the PDA from those seeds and your program ID.
If the result matches the account you passed in, the CPI is
authorized. No private key. No human approval.
The Vault: Deposit In, Program Signs Out
Day 73 was the clearest demonstration. Two instructions:
deposit(amount) → user wallet signs, SOL flows INTO the vault PDA
withdraw(amount) → program signs via seeds, SOL flows OUT of vault PDA
The vault PDA is derived from ["vault", user_pubkey]. This means:
- Every user has their own vault at a unique address
- The program can only withdraw to the user whose key is in the seeds
- Passing a different user's vault produces a seeds mismatch — rejected
The test confirmed it:
vault after deposit: 500000000 lamports
vault after withdraw: 0 lamports
✔ deposits, then the program signs to withdraw
And the failure test confirmed the protection:
Impostor trying to withdraw from original user vault...
✗ FAILURE 2: AnchorError caused by account: vault.
Error Code: ConstraintSeeds. Error Number: 2006.
A seeds constraint was violated.
The seeds are the authorization policy. Not an ownership check in
the handler — the derivation itself.
The PDA Mint Authority: Permanent Program Control
Day 74 was the harder version. I created a Token-2022 mint, then
transferred mint authority to a PDA:
await setAuthority(
connection,
payer,
mint,
payer,
AuthorityType.MintTokens,
mintAuthorityPda, // ← PDA with no private key
[],
undefined,
TOKEN_2022_PROGRAM_ID
);
After this transaction, the only way to mint tokens is through
the program's mint_tokens instruction. No wallet can call
mintTo directly because no private key corresponds to the
PDA address. The program is the only entity that can reconstruct
the signer seeds at runtime.
Mint authority PDA: 8p6j3X6pBDVf4kzXcgk2VnnN3Jo18tJpjWqraMzguSbC
Minted base units: 500000000
✔ PDA signs the mint CPI — no human holds mint authority
Confirmed: only the program can mint — PDA has no private key
What This Pattern Unlocks
Every meaningful DeFi primitive on Solana uses this pattern:
| Protocol type | What the PDA controls |
|---|---|
| Vault / escrow | SOL or token release conditions |
| AMM | Token pool balances |
| Lending protocol | Collateral and liquidation |
| Reward program | When and how much to mint |
| DAO treasury | Fund distribution rules |
In every case, the rules live in the program. The PDA enforces
that only the program can act. The seeds determine what the program
can act on behalf of.
The Three Failures That Made It Real
I wrote a test suite that deliberately triggered three CPI failures:
Failure 1 — Insufficient funds:
Tried to withdraw 5 SOL from a vault holding 0.1 SOL.
✗ FAILURE 1: Simulation failed.
The System Program rejected the transfer before it hit the chain.
Failure 2 — Wrong signer (seeds mismatch):
An impostor wallet tried to withdraw from a different user's vault.
✗ FAILURE 2: ConstraintSeeds. Error Number: 2006.
A seeds constraint was violated.
The PDA derivation produced a different address. Rejected before
the handler ran.
Failure 3 — Wrong program ID:
Passed a fake address instead of the System Program.
✗ FAILURE 3: InvalidProgramId. Error Number: 3008.
Program ID was not as expected.
Anchor validated the program address before the CPI could execute.
All three caught cleanly. All three tell you exactly what went wrong.
What I Would Tell Someone Starting This Week
The wallet signature propagates automatically.
You do not need .with_signer when the user's wallet is the
authority. That is only for PDAs.
.with_signer is the entire PDA signing mechanism.
Everything else — the CpiContext, the accounts struct, the
instruction call — is the same whether a wallet or a PDA signs.
The only difference is that one line.
Store the bump on the account.
Compute it once in the init instruction with ctx.bumps.counter,
store it as a field, reuse it on every subsequent call.
Re-deriving it each time wastes compute.
The seeds are the authorization policy.
A vault derived from ["vault", user_pubkey] can only be drained
to that user. You do not need an explicit ownership check — the
derivation enforces it. If someone passes the wrong account, the
seeds mismatch and Anchor rejects the transaction.
setAuthority is a point of no return.
Once you transfer mint authority to a PDA, no human can mint outside
your program's rules. Make sure those rules are correct before you
call it on mainnet.
Resources
- Cross-Program Invocations — Solana docs
- CPI with signer seeds — Anchor book
- System Program transfer
- Token Interface mint_to
- PDA signing — Solana cookbook
Part of *#100DaysOfSolana*. Building every day.
Top comments (0)