Cross-Program Invocation (CPI) represents one of Solana's most powerful architectural features, enabling composable program interactions that form the backbone of complex DeFi protocols, NFT marketplaces, and sophisticated on-chain applications. Understanding CPI patterns is essential for any developer building production-grade Solana programs, as it directly impacts security, gas efficiency, and overall system design.
Understanding the CPI Architecture
At its core, Cross-Program Invocation allows one Solana program to call another program's instructions during execution. Unlike traditional smart contract platforms where external calls are straightforward, Solana's account-based model introduces unique considerations around account ownership, signer propagation, and Program Derived Addresses (PDAs). The runtime manages these interactions through a carefully designed system that maintains security while enabling deep composability.
When a program initiates a CPI, it essentially creates a new execution context within the same transaction. The calling program passes a set of accounts and instruction data to the target program, which then executes with its own logic. The critical distinction from other blockchains lies in how Solana handles account permissions—signers from the original transaction can be propagated through the CPI chain, and PDAs can sign on behalf of programs.
The Two Invocation Methods: invoke vs invoke_signed
Solana provides two primary functions for cross-program invocations: invoke andinvoke_signed. Understanding when to use each is fundamental to correct program design.
Standard Invocation with invoke
The invoke function is used when all required signers are present in the original transaction. This is the simpler case where user wallets or other external signers have already authorized the operation.
use solana_program::{
account_info::AccountInfo,
entrypoint::ProgramResult,
program::invoke,
instruction::{AccountMeta, Instruction},
pubkey::Pubkey,
};
pub fn transfer_tokens(
token_program: &AccountInfo,
source: &AccountInfo,
destination: &AccountInfo,
authority: &AccountInfo,
amount: u64,
) -> ProgramResult {
let instruction = spl_token::instruction::transfer(
token_program.key,
source.key,
destination.key,
authority.key,
&[],
amount,
)?;
invoke(
&instruction,
&[
source.clone(),
destination.clone(),
authority.clone(),
token_program.clone(),
],
)
}
In this pattern, the authority account must have signed the original transaction. The runtime verifies that all accounts marked as signers in the instruction are actually signers in the transaction context. If the authority hasn't signed, the CPI will fail with a missing signer error.
PDA Signing with invoke_signed
The invoke_signed function enables programs to sign for Program Derived Addresses they control. This is where Solana's composability truly shines—programs can own accounts and authorize operations on behalf of those accounts without requiring external signatures.
use solana_program::{
account_info::AccountInfo,
entrypoint::ProgramResult,
program::invoke_signed,
pubkey::Pubkey,
};
pub fn transfer_from_vault(
token_program: &AccountInfo,
vault: &AccountInfo,
destination: &AccountInfo,
vault_authority: &AccountInfo,
program_id: &Pubkey,
amount: u64,
bump_seed: u8,
) -> ProgramResult {
let seeds = &[
b"vault_authority",
&[bump_seed],
];
let signer_seeds = &[&seeds[..]];
let instruction = spl_token::instruction::transfer(
token_program.key,
vault.key,
destination.key,
vault_authority.key,
&[],
amount,
)?;
invoke_signed(
&instruction,
&[
vault.clone(),
destination.clone(),
vault_authority.clone(),
token_program.clone(),
],
signer_seeds,
)
}
The signer_seeds parameter contains the seeds used to derive the PDA, including the bump seed. The runtime uses these seeds to verify that the program is authorized to sign for the given address. This verification is cryptographic—the runtime re-derives the address from the seeds and confirms it matches the account being signed for.
Advanced CPI Patterns for Production Systems
The Escrow Pattern
Escrow systems represent a fundamental building block in DeFi, and implementing them correctly requires careful CPI design. The pattern involves a PDA-controlled vault that holds assets until certain conditions are met, at which point the program releases funds through a CPI.
pub struct EscrowState {
pub is_initialized: bool,
pub initializer: Pubkey,
pub temp_token_account: Pubkey,
pub initializer_token_to_receive: Pubkey,
pub expected_amount: u64,
pub bump: u8,
}
pub fn process_exchange(
program_id: &Pubkey,
accounts: &[AccountInfo],
amount_expected_by_taker: u64,
) -> ProgramResult {
let account_info_iter = &mut accounts.iter();
let taker = next_account_info(account_info_iter)?;
let taker_send_token_account = next_account_info(account_info_iter)?;
let taker_receive_token_account = next_account_info(account_info_iter)?;
let pda_temp_token_account = next_account_info(account_info_iter)?;
let initializer_main_account = next_account_info(account_info_iter)?;
let initializer_receive_token_account = next_account_info(account_info_iter)?;
let escrow_account = next_account_info(account_info_iter)?;
let token_program = next_account_info(account_info_iter)?;
let pda_account = next_account_info(account_info_iter)?;
let escrow_info = EscrowState::unpack(&escrow_account.data.borrow())?;
// Verify escrow conditions
if amount_expected_by_taker != escrow_info.expected_amount {
return Err(ProgramError::InvalidArgument);
}
let pda_seeds = &[
b"escrow",
initializer_main_account.key.as_ref(),
&[escrow_info.bump],
];
// Transfer from taker to initializer
invoke(
&spl_token::instruction::transfer(
token_program.key,
taker_send_token_account.key,
initializer_receive_token_account.key,
taker.key,
&[],
escrow_info.expected_amount,
)?,
&[
taker_send_token_account.clone(),
initializer_receive_token_account.clone(),
taker.clone(),
token_program.clone(),
],
)?;
// Transfer from escrow vault to taker (PDA signed)
invoke_signed(
&spl_token::instruction::transfer(
token_program.key,
pda_temp_token_account.key,
taker_receive_token_account.key,
pda_account.key,
&[],
amount_expected_by_taker,
)?,
&[
pda_temp_token_account.clone(),
taker_receive_token_account.clone(),
pda_account.clone(),
token_program.clone(),
],
&[pda_seeds],
)
}
Multi-Hop CPI Chains
Complex DeFi operations often require chaining multiple CPIs together. A swap aggregator, for example, might route through multiple AMMs in a single transaction. Each hop in the chain maintains the CPI depth counter, and Solana enforces a maximum depth to prevent stack overflow attacks.
The current CPI depth limit is 4 levels deep. This means Program A can call Program B, which calls Program C, which calls Program D, but Program D cannot make another CPI. Designing around this constraint requires careful architectural planning, especially for protocols that aggregate functionality from multiple sources.
// Aggregator pattern - routing through multiple DEXs
pub fn execute_multi_hop_swap(
accounts: &[AccountInfo],
route: &[SwapHop],
) -> ProgramResult {
let mut current_amount = route[0].amount_in;
for (i, hop) in route.iter().enumerate() {
let hop_accounts = extract_hop_accounts(accounts, i)?;
current_amount = match hop.dex_type {
DexType::Raydium => {
execute_raydium_swap(&hop_accounts, current_amount, hop.minimum_out)?
},
DexType::Orca => {
execute_orca_swap(&hop_accounts, current_amount, hop.minimum_out)?
},
DexType::Jupiter => {
execute_jupiter_swap(&hop_accounts, current_amount, hop.minimum_out)?
},
};
}
Ok(())
}
Security Considerations in CPI Design
Account Validation Before CPI
One of the most critical security practices in CPI design is thorough account validation before invoking external programs. Failing to verify account ownership, program IDs, or PDA derivations can lead to catastrophic exploits.
pub fn validate_accounts_before_cpi(
token_program: &AccountInfo,
expected_mint: &Pubkey,
token_account: &AccountInfo,
authority_pda: &AccountInfo,
program_id: &Pubkey,
expected_seeds: &[&[u8]],
) -> ProgramResult {
// 1. Verify token program ID
if token_program.key != &spl_token::id() {
return Err(ProgramError::IncorrectProgramId);
}
// 2. Verify token account ownership
if token_account.owner != &spl_token::id() {
return Err(ProgramError::IllegalOwner);
}
// 3. Verify token account mint
let token_data = TokenAccount::unpack(&token_account.data.borrow())?;
if &token_data.mint != expected_mint {
return Err(ProgramError::InvalidAccountData);
}
// 4. Verify PDA derivation
let (expected_pda, bump) = Pubkey::find_program_address(expected_seeds, program_id);
if authority_pda.key != &expected_pda {
return Err(ProgramError::InvalidSeeds);
}
Ok(())
}
Reentrancy Protection
While Solana's programming model is inherently resistant to traditional reentrancy attacks due to its single-threaded execution within a transaction, there are still scenarios where unexpected callback patterns can cause issues. Programs should implement state checks to ensure operations complete atomically.
pub fn process_with_reentrancy_guard(
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
let state_account = &accounts[0];
let mut state = State::unpack(&state_account.data.borrow())?;
// Check reentrancy flag
if state.is_processing {
return Err(ProgramError::Custom(ErrorCode::ReentrancyDetected as u32));
}
// Set reentrancy guard
state.is_processing = true;
State::pack(state, &mut state_account.data.borrow_mut())?;
// Perform CPI operations
let result = execute_cpi_operations(accounts)?;
// Clear reentrancy guard
let mut state = State::unpack(&state_account.data.borrow())?;
state.is_processing = false;
State::pack(state, &mut state_account.data.borrow_mut())?;
result
}
Handling CPI Failures Gracefully
CPI operations can fail for various reasons—insufficient funds, invalid accounts, or program-specific errors. Designing for graceful failure handling ensures your program maintains consistent state even when external calls fail.
pub fn execute_with_fallback(
primary_dex_accounts: &[AccountInfo],
fallback_dex_accounts: &[AccountInfo],
amount: u64,
minimum_out: u64,
) -> ProgramResult {
// Attempt primary route
match execute_primary_swap(primary_dex_accounts, amount, minimum_out) {
Ok(amount_out) => {
msg!("Primary swap succeeded: {} tokens received", amount_out);
return Ok(());
},
Err(e) => {
msg!("Primary swap failed: {:?}, attempting fallback", e);
}
}
// Attempt fallback route
execute_fallback_swap(fallback_dex_accounts, amount, minimum_out)?;
Ok(())
}
Gas Optimization in CPI-Heavy Programs
Each CPI incurs computational overhead, and optimizing gas usage is crucial for programs that perform multiple cross-program calls. Several strategies can significantly reduce compute unit consumption.
Batching Related Operations
Instead of making separate CPIs for related operations, batch them where possible. The SPL Token program, for example, supports batch transfers through careful instruction construction.
// Instead of multiple individual transfers
// Batch account modifications where possible
pub fn batch_token_operations(
accounts: &[AccountInfo],
operations: &[TokenOperation],
) -> ProgramResult {
// Pre-compute all account indices
let account_indices: Vec<usize> = operations
.iter()
.flat_map(|op| vec![op.source_idx, op.dest_idx])
.collect();
// Single instruction with multiple operations
// reduces CPI overhead significantly
for operation in operations {
execute_single_transfer(
&accounts[operation.source_idx],
&accounts[operation.dest_idx],
&accounts[operation.authority_idx],
operation.amount,
)?;
}
Ok(())
}
Minimizing Account Cloning
Account cloning in CPI calls has a measurable cost. When passing accounts to CPIs, only include accounts that the target program actually needs. Passing unnecessary accounts increases both serialization overhead and compute unit consumption.
Testing CPI Interactions
Testing CPI-heavy programs requires a comprehensive approach that includes unit tests, integration tests, and mainnet fork testing. The Solana Program Test framework provides excellent tooling for simulating CPI scenarios.
#[tokio::test]
async fn test_cpi_escrow_exchange() {
let program_id = Pubkey::new_unique();
let mut program_test = ProgramTest::new(
"escrow_program",
program_id,
processor!(process_instruction),
);
// Add SPL Token program
program_test.add_program(
"spl_token",
spl_token::id(),
None,
);
let (mut banks_client, payer, recent_blockhash) = program_test.start().await;
// Setup accounts
let initializer = Keypair::new();
let taker = Keypair::new();
// Create and fund token accounts
let mint = create_mint(&mut banks_client, &payer, &recent_blockhash).await;
let initializer_token = create_token_account(
&mut banks_client,
&payer,
&mint,
&initializer.pubkey()
).await;
// Execute escrow initialization
let escrow_pda = initialize_escrow(
&mut banks_client,
&payer,
&initializer,
&initializer_token,
1000,
500,
).await;
// Execute exchange and verify CPI results
execute_exchange(
&mut banks_client,
&payer,
&taker,
&escrow_pda,
).await;
// Verify final state
let initializer_balance = get_token_balance(&mut banks_client, &initializer_token).await;
assert_eq!(initializer_balance, 500);
}
Real-World CPI Applications
The patterns discussed here power some of Solana's most successful protocols. Jupiter's aggregation routes leverage multi-hop CPIs to find optimal swap paths. Marinade Finance uses PDA-signed CPIs to manage liquid staking operations. Tensor's NFT marketplace employs escrow patterns for trustless trading. Understanding these patterns opens the door to building the next generation of Solana applications.
As Solana's ecosystem matures, CPI patterns will continue to evolve. The introduction of versioned transactions and address lookup tables has already expanded what's possible in terms of composability. Future runtime improvements will likely increase CPI depth limits and reduce overhead, enabling even more sophisticated cross-program interactions.
Conclusion
Mastering Cross-Program Invocation patterns is essential for any serious Solana developer. From basic token transfers to complex multi-hop DeFi operations, CPI forms the foundation of Solana's composable architecture. By understanding the nuances of invoke vs invoke_signed, implementing proper security validations, and optimizing for gas efficiency, developers can build robust, production-ready programs that leverage the full power of Solana's ecosystem.
The examples and patterns presented here provide a solid foundation, but the best learning comes from hands-on implementation. Start with simple CPI calls, gradually introduce PDA signing, and work your way up to complex multi-program interactions. The Solana developer community offers excellent resources, and studying the source code of established protocols provides invaluable real-world insights.