Cross-Program Invocations (CPIs) Deep Dive: Composable Solana Programs
Cross-Program Invocations enable your Solana program to call any other program on the network, unlocking true DeFi composability. Whether you're integrating with token programs, DEXs, or lending protocols, CPIs are the foundation of Solana's programmable economy. This guide covers everything from basic transfers to advanced PDA signing patterns.
TL;DR
- **CPIs allow programs to call other programs** atomically within a single transaction
- **Two invoke methods**: `invoke()` for regular calls, `invoke_signed()` when your PDA must sign
- **CPI depth limit is 4** - plan your architecture accordingly
- **Account validation is critical** - always verify the program you're calling
- **Anchor simplifies CPIs** with type-safe helpers and automatic context generation
Prerequisites
To get the most from this guide, you should have:
- Solid understanding of Solana's account model
- Experience writing Anchor programs
- Familiarity with PDAs (Program Derived Addresses)
- Basic Rust knowledge
Package versions used in this guide:
- `anchor-lang`: 0.32.1
- `solana-program`: 2.1.0
- `@coral-xyz/anchor`: 0.32.1
- `@solana/web3.js`: 1.98.4
What Are Cross-Program Invocations?
A CPI is when one Solana program calls an instruction on another program. Think of it like a function call, but across program boundaries. The called program executes within the same transaction, ensuring atomicity - either everything succeeds or everything reverts.
Transaction
└── Your Program
└── CPI to Token Program
└── Token TransferWhy CPIs Matter
- **Composability** - Build on existing protocols instead of reinventing
- **Atomicity** - Complex multi-step operations happen in one transaction
- **Security** - Leverage audited programs for common operations
- **Efficiency** - No need for multiple transactions or off-chain coordination
CPI Fundamentals
The Two Invoke Methods
Solana provides two functions for CPIs:
// For regular calls where your program doesn't need to sign
solana_program::program::invoke(
instruction: &Instruction,
account_infos: &[AccountInfo]
) -> ProgramResult
// For calls where a PDA from your program must sign
solana_program::program::invoke_signed(
instruction: &Instruction,
account_infos: &[AccountInfo],
signers_seeds: &[&[&[u8]]]
) -> ProgramResultBasic CPI: SOL Transfer
Let's start with a simple SOL transfer using native Solana:
use solana_program::{
account_info::{next_account_info, AccountInfo},
entrypoint::ProgramResult,
program::invoke,
pubkey::Pubkey,
system_instruction,
};
pub fn transfer_sol(
accounts: &[AccountInfo],
amount: u64,
) -> ProgramResult {
let account_iter = &mut accounts.iter();
let sender = next_account_info(account_iter)?;
let recipient = next_account_info(account_iter)?;
let system_program = next_account_info(account_iter)?;
// Create the transfer instruction
let transfer_ix = system_instruction::transfer(
sender.key,
recipient.key,
amount,
);
// Execute the CPI
invoke(
&transfer_ix,
&[
sender.clone(),
recipient.clone(),
system_program.clone(),
],
)?;
Ok(())
}CPIs with Anchor
Anchor dramatically simplifies CPIs with type-safe helpers and automatic context generation.
Basic Anchor CPI: SOL Transfer
use anchor_lang::prelude::*;
use anchor_lang::system_program::{transfer, Transfer};
declare_id!("YOUR_PROGRAM_ID");
#[program]
pub mod cpi_example {
use super::*;
pub fn sol_transfer(ctx: Context<SolTransfer>, amount: u64) -> Result<()> {
// Create the CPI context
let cpi_context = CpiContext::new(
ctx.accounts.system_program.to_account_info(),
Transfer {
from: ctx.accounts.sender.to_account_info(),
to: ctx.accounts.recipient.to_account_info(),
},
);
// Execute the transfer
transfer(cpi_context, amount)?;
msg!("Transferred {} lamports", amount);
Ok(())
}
}
#[derive(Accounts)]
pub struct SolTransfer<'info> {
#[account(mut)]
pub sender: Signer<'info>,
/// CHECK: Recipient for the SOL transfer
#[account(mut)]
pub recipient: AccountInfo<'info>,
pub system_program: Program<'info, System>,
}SPL Token Transfer CPI
use anchor_lang::prelude::*;
use anchor_spl::token::{self, Token, TokenAccount, Transfer};
#[program]
pub mod token_cpi {
use super::*;
pub fn transfer_tokens(ctx: Context<TransferTokens>, amount: u64) -> Result<()> {
let cpi_accounts = Transfer {
from: ctx.accounts.from_token_account.to_account_info(),
to: ctx.accounts.to_token_account.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
};
let cpi_program = ctx.accounts.token_program.to_account_info();
let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);
token::transfer(cpi_ctx, amount)?;
Ok(())
}
}
#[derive(Accounts)]
pub struct TransferTokens<'info> {
#[account(mut)]
pub from_token_account: Account<'info, TokenAccount>,
#[account(mut)]
pub to_token_account: Account<'info, TokenAccount>,
pub authority: Signer<'info>,
pub token_program: Program<'info, Token>,
}PDA Signing with invoke_signed
When your program owns a PDA that needs to authorize an action, use invoke_signed (or CpiContext::new_with_signer in Anchor).
Scenario: Vault Withdrawal
use anchor_lang::prelude::*;
use anchor_lang::system_program::{transfer, Transfer};
declare_id!("YOUR_PROGRAM_ID");
#[program]
pub mod vault_program {
use super::*;
pub fn initialize_vault(ctx: Context<InitializeVault>) -> Result<()> {
let vault = &mut ctx.accounts.vault;
vault.authority = ctx.accounts.authority.key();
vault.bump = ctx.bumps.vault;
Ok(())
}
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
let cpi_ctx = CpiContext::new(
ctx.accounts.system_program.to_account_info(),
Transfer {
from: ctx.accounts.depositor.to_account_info(),
to: ctx.accounts.vault.to_account_info(),
},
);
transfer(cpi_ctx, amount)?;
Ok(())
}
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
let vault = &ctx.accounts.vault;
// Build the signer seeds for the PDA
let authority_key = ctx.accounts.authority.key();
let seeds = &[
b"vault".as_ref(),
authority_key.as_ref(),
&[vault.bump],
];
let signer_seeds = &[&seeds[..]];
// Create CPI context with PDA signer
let cpi_ctx = CpiContext::new_with_signer(
ctx.accounts.system_program.to_account_info(),
Transfer {
from: ctx.accounts.vault.to_account_info(),
to: ctx.accounts.authority.to_account_info(),
},
signer_seeds,
);
transfer(cpi_ctx, amount)?;
msg!("Withdrew {} lamports from vault", amount);
Ok(())
}
}
#[derive(Accounts)]
pub struct InitializeVault<'info> {
#[account(
init,
payer = authority,
space = 8 + Vault::INIT_SPACE,
seeds = [b"vault", authority.key().as_ref()],
bump
)]
pub vault: Account<'info, Vault>,
#[account(mut)]
pub authority: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct Deposit<'info> {
#[account(
mut,
seeds = [b"vault", vault.authority.as_ref()],
bump = vault.bump
)]
pub vault: Account<'info, Vault>,
#[account(mut)]
pub depositor: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct Withdraw<'info> {
#[account(
mut,
seeds = [b"vault", authority.key().as_ref()],
bump = vault.bump,
has_one = authority
)]
pub vault: Account<'info, Vault>,
#[account(mut)]
pub authority: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[account]
#[derive(InitSpace)]
pub struct Vault {
pub authority: Pubkey,
pub bump: u8,
}Advanced: Calling Another Anchor Program
Use declare_program! to generate type-safe CPI interfaces for other Anchor programs.
The Callee Program (Counter)
// counter_program/src/lib.rs
use anchor_lang::prelude::*;
declare_id!("CounterProgramID111111111111111111111111111");
#[program]
pub mod counter_program {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let counter = &mut ctx.accounts.counter;
counter.count = 0;
counter.authority = ctx.accounts.authority.key();
counter.bump = ctx.bumps.counter;
Ok(())
}
pub fn increment(ctx: Context<Increment>) -> Result<()> {
let counter = &mut ctx.accounts.counter;
counter.count = counter.count.checked_add(1)
.ok_or(ErrorCode::Overflow)?;
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(
init,
payer = authority,
space = 8 + Counter::INIT_SPACE,
seeds = [b"counter", authority.key().as_ref()],
bump
)]
pub counter: Account<'info, Counter>,
#[account(mut)]
pub authority: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct Increment<'info> {
#[account(
mut,
seeds = [b"counter", counter.authority.as_ref()],
bump = counter.bump
)]
pub counter: Account<'info, Counter>,
}
#[account]
#[derive(InitSpace)]
pub struct Counter {
pub count: u64,
pub authority: Pubkey,
pub bump: u8,
}
#[error_code]
pub enum ErrorCode {
#[msg("Counter overflow")]
Overflow,
}The Caller Program
// caller_program/src/lib.rs
use anchor_lang::prelude::*;
declare_id!("CallerProgramID1111111111111111111111111111");
// Generate CPI interface from the counter program's IDL
declare_program!(counter_program);
use counter_program::{
accounts::Counter,
cpi::{self, accounts::Increment},
program::CounterProgram,
};
#[program]
pub mod caller_program {
use super::*;
pub fn increment_counter(ctx: Context<IncrementCounter>) -> Result<()> {
// Create CPI context
let cpi_ctx = CpiContext::new(
ctx.accounts.counter_program.to_account_info(),
Increment {
counter: ctx.accounts.counter.to_account_info(),
},
);
// Call the counter program's increment instruction
cpi::increment(cpi_ctx)?;
msg!("Counter incremented via CPI!");
Ok(())
}
pub fn increment_twice(ctx: Context<IncrementCounter>) -> Result<()> {
let cpi_ctx = CpiContext::new(
ctx.accounts.counter_program.to_account_info(),
Increment {
counter: ctx.accounts.counter.to_account_info(),
},
);
// Call increment twice in one transaction
cpi::increment(cpi_ctx.clone())?;
cpi::increment(cpi_ctx)?;
msg!("Counter incremented twice!");
Ok(())
}
}
#[derive(Accounts)]
pub struct IncrementCounter<'info> {
#[account(mut)]
pub counter: Account<'info, Counter>,
pub counter_program: Program<'info, CounterProgram>,
}CPI Depth and Compute Limits
Understanding CPI Depth
Solana limits CPI depth to 4 levels:
Transaction (depth 0)
└── Program A (depth 1)
└── CPI to Program B (depth 2)
└── CPI to Program C (depth 3)
└── CPI to Program D (depth 4)
└── ❌ FAILS - depth exceededCompute Unit Considerations
CPIs consume additional compute units:
// Set higher compute budget for CPI-heavy operations
use anchor_lang::solana_program::compute_budget::ComputeBudgetInstruction;
// In your client code:
const modifyComputeUnits = ComputeBudgetProgram.setComputeUnitLimit({
units: 400_000
});
const tx = new Transaction()
.add(modifyComputeUnits)
.add(yourInstruction);Security Best Practices
1. Always Validate the Program Being Called
use anchor_lang::prelude::*;
#[derive(Accounts)]
pub struct SecureCpi<'info> {
// SECURE: Program<'info, Token> validates program ID
pub token_program: Program<'info, Token>,
// If using AccountInfo, validate manually:
/// CHECK: Validated as system program
#[account(
constraint = system_program.key() == anchor_lang::system_program::ID
@ ErrorCode::InvalidProgram
)]
pub system_program: AccountInfo<'info>,
}2. Verify Account Ownership Before CPI
pub fn secure_transfer(ctx: Context<SecureTransfer>) -> Result<()> {
// Verify the token account belongs to the expected owner
require_keys_eq!(
ctx.accounts.source_token.owner,
ctx.accounts.authority.key(),
ErrorCode::InvalidOwner
);
// Now safe to proceed with CPI
// ...
Ok(())
}3. Handle CPI Errors Gracefully
pub fn safe_cpi(ctx: Context<SafeCpi>) -> Result<()> {
let result = token::transfer(cpi_ctx, amount);
match result {
Ok(_) => msg!("Transfer successful"),
Err(e) => {
msg!("CPI failed: {:?}", e);
return Err(ErrorCode::CpiFailed.into());
}
}
Ok(())
}4. Never Expose PDA Seeds Unnecessarily
// BAD: Seeds exposed in public function
pub fn bad_withdraw(ctx: Context<Withdraw>, bump: u8) -> Result<()> {
let seeds = &[b"vault", &[bump]]; // Attacker can pass any bump!
// ...
}
// GOOD: Store bump in account, derive seeds internally
pub fn good_withdraw(ctx: Context<Withdraw>) -> Result<()> {
let seeds = &[
b"vault".as_ref(),
ctx.accounts.vault.authority.as_ref(),
&[ctx.accounts.vault.bump],
];
// ...
}Client-Side CPI Testing
TypeScript Test Example
import * as anchor from '@coral-xyz/anchor';
import { Program } from '@coral-xyz/anchor';
import { VaultProgram } from '../target/types/vault_program';
import { expect } from 'chai';
describe('Vault CPI Tests', () => {
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.VaultProgram as Program<VaultProgram>;
const authority = provider.wallet;
let vaultPda: anchor.web3.PublicKey;
let vaultBump: number;
before(async () => {
[vaultPda, vaultBump] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from('vault'), authority.publicKey.toBuffer()],
program.programId
);
});
it('Initializes the vault', async () => {
await program.methods
.initializeVault()
.accounts({
vault: vaultPda,
authority: authority.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
const vault = await program.account.vault.fetch(vaultPda);
expect(vault.authority.toString()).to.equal(authority.publicKey.toString());
});
it('Deposits SOL via CPI', async () => {
const depositAmount = new anchor.BN(1_000_000_000); // 1 SOL
const vaultBalanceBefore = await provider.connection.getBalance(vaultPda);
await program.methods
.deposit(depositAmount)
.accounts({
vault: vaultPda,
depositor: authority.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
const vaultBalanceAfter = await provider.connection.getBalance(vaultPda);
expect(vaultBalanceAfter - vaultBalanceBefore).to.equal(
depositAmount.toNumber()
);
});
it('Withdraws SOL via PDA-signed CPI', async () => {
const withdrawAmount = new anchor.BN(500_000_000); // 0.5 SOL
const authorityBalanceBefore = await provider.connection.getBalance(
authority.publicKey
);
await program.methods
.withdraw(withdrawAmount)
.accounts({
vault: vaultPda,
authority: authority.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
const authorityBalanceAfter = await provider.connection.getBalance(
authority.publicKey
);
// Account for transaction fee
expect(authorityBalanceAfter).to.be.greaterThan(authorityBalanceBefore);
});
});Common Pitfalls
- **Forgetting to pass all required accounts** - CPIs fail silently when accounts are missing
- **Wrong account order** - The called program expects accounts in a specific order
- **Missing signer seeds** - PDAs that need to sign must have their seeds passed to `invoke_signed`
- **Incorrect bump seed** - Always store and reuse the canonical bump
- **CPI depth overflow** - Design your architecture to stay within 4 levels
- **Compute exhaustion** - CPIs add overhead; request more compute units when needed
Performance Tips
- **Minimize CPI depth** - Flatten operations where possible
- **Batch operations** - Multiple transfers in one instruction vs. multiple CPIs
- **Use remaining_accounts** - For variable-length account lists
- **Cache computed values** - Don't recalculate PDA seeds repeatedly
Next Steps and Resources
Deepen Your Knowledge:
- [Solana CPI Documentation](https://solana.com/docs/core/cpi)
- [Anchor CPI Guide](https://www.anchor-lang.com/docs/basics/cpi)
- [Sealevel Attacks](https://github.com/coral-xyz/sealevel-attacks) - Security examples
Build Something:
- Create a multi-hop swap using DEX CPIs
- Build a yield aggregator that deposits to multiple protocols
- Implement a governance system calling token and voting programs
Need help architecting composable Solana programs? Builderz specializes in complex CPI integrations. Contact us to discuss your protocol.



