Files
2025-11-30 09:01:25 +08:00

20 KiB

Cross-Program Invocation (CPI)

This reference provides comprehensive coverage of Cross-Program Invocation (CPI) for native Rust Solana program development, including invoke patterns, account privilege propagation, and security considerations.

Table of Contents

  1. What is CPI
  2. CPI Fundamentals
  3. invoke vs invoke_signed
  4. Account Privilege Propagation
  5. Common CPI Patterns
  6. CPI Limits and Constraints
  7. Security Considerations
  8. Best Practices

What is CPI

Cross-Program Invocation (CPI) is when one Solana program directly calls instructions on another program.

Conceptual Model

If you think of a Solana instruction as an API endpoint, a CPI is like one API endpoint internally calling another.

User Transaction
     │
     ▼
┌────────────────────┐
│   Your Program     │
│                    │
│   ┌──────────────┐ │
│   │ Instruction  │ │
│   │   Handler    │ │
│   └──────┬───────┘ │
│          │ CPI     │
└──────────┼─────────┘
           │
           ▼
┌────────────────────┐
│ System Program     │
│  create_account    │
└────────────────────┘

Why CPI is Essential

Composability: Programs can leverage functionality from other programs without reimplementing it.

Common Use Cases:

  • Create accounts (System Program CPI)
  • Transfer tokens (Token Program CPI)
  • Interact with DeFi protocols
  • Call custom program logic
  • Complex multi-step operations

CPI vs Direct Instruction

Aspect Direct Instruction CPI
Who initiates User wallet Another program
Signer source User's private key Program or PDA
Call depth 1 (top-level) 2-5 (nested)
Use case Entry point Program-to-program

CPI Fundamentals

The Two CPI Functions

Solana provides two functions for making CPIs:

use solana_program::program::{invoke, invoke_signed};

// 1. invoke: For regular account signers
pub fn invoke(
    instruction: &Instruction,
    account_infos: &[AccountInfo],
) -> ProgramResult

// 2. invoke_signed: For PDA signers
pub fn invoke_signed(
    instruction: &Instruction,
    account_infos: &[AccountInfo],
    signers_seeds: &[&[&[u8]]],
) -> ProgramResult

Required Imports

use solana_program::{
    account_info::AccountInfo,
    entrypoint::ProgramResult,
    instruction::{AccountMeta, Instruction},
    program::{invoke, invoke_signed},
    pubkey::Pubkey,
};

Instruction Structure

Before making a CPI, you must construct an Instruction:

pub struct Instruction {
    /// Program ID of the program being invoked
    pub program_id: Pubkey,

    /// Accounts required by the instruction
    pub accounts: Vec<AccountMeta>,

    /// Serialized instruction data
    pub data: Vec<u8>,
}

pub struct AccountMeta {
    /// Account public key
    pub pubkey: Pubkey,

    /// Is this account a signer?
    pub is_signer: bool,

    /// Is this account writable?
    pub is_writable: bool,
}

invoke vs invoke_signed

invoke: Regular Signers

Use invoke when all required signers are regular accounts (not PDAs).

Example: User transfers SOL

use solana_program::{
    account_info::{next_account_info, AccountInfo},
    entrypoint::ProgramResult,
    program::invoke,
    program_error::ProgramError,
    pubkey::Pubkey,
    system_instruction,
};

pub fn user_transfer_sol(
    _program_id: &Pubkey,
    accounts: &[AccountInfo],
    amount: u64,
) -> ProgramResult {
    let account_info_iter = &mut accounts.iter();

    let sender = next_account_info(account_info_iter)?;
    let recipient = next_account_info(account_info_iter)?;
    let system_program = next_account_info(account_info_iter)?;

    // Verify sender signed the transaction
    if !sender.is_signer {
        return Err(ProgramError::MissingRequiredSignature);
    }

    // Create transfer instruction
    let transfer_ix = system_instruction::transfer(
        sender.key,
        recipient.key,
        amount,
    );

    // Execute CPI (sender already signed the transaction)
    invoke(
        &transfer_ix,
        &[
            sender.clone(),
            recipient.clone(),
            system_program.clone(),
        ],
    )?;

    Ok(())
}

Key Points:

  • sender.is_signer must be true (verified at transaction level)
  • No signers_seeds needed
  • invoke internally calls invoke_signed with empty seeds

invoke_signed: PDA Signers

Use invoke_signed when a PDA needs to sign the instruction.

Example: PDA transfers SOL

use solana_program::{
    account_info::{next_account_info, AccountInfo},
    entrypoint::ProgramResult,
    program::invoke_signed,
    program_error::ProgramError,
    pubkey::Pubkey,
    system_instruction,
};

pub fn pda_transfer_sol(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    amount: u64,
) -> ProgramResult {
    let account_info_iter = &mut accounts.iter();

    let pda_account = next_account_info(account_info_iter)?;
    let recipient = next_account_info(account_info_iter)?;
    let system_program = next_account_info(account_info_iter)?;

    // Derive PDA and verify
    let (pda, bump_seed) = Pubkey::find_program_address(
        &[b"vault", recipient.key.as_ref()],
        program_id,
    );

    if pda != *pda_account.key {
        return Err(ProgramError::InvalidSeeds);
    }

    // Create transfer instruction
    let transfer_ix = system_instruction::transfer(
        pda_account.key,  // From PDA (needs signing!)
        recipient.key,
        amount,
    );

    // PDA signing seeds (must match derivation)
    let signer_seeds: &[&[&[u8]]] = &[&[
        b"vault",
        recipient.key.as_ref(),
        &[bump_seed],  // Critical: bump must be included
    ]];

    // Execute CPI with PDA signature
    invoke_signed(
        &transfer_ix,
        &[
            pda_account.clone(),
            recipient.clone(),
            system_program.clone(),
        ],
        signer_seeds,  // Runtime verifies and grants signing authority
    )?;

    Ok(())
}

How Runtime Handles PDA Signing:

  1. Runtime receives signers_seeds
  2. Calls create_program_address(signers_seeds, calling_program_id)
  3. Verifies derived PDA matches an account in the instruction
  4. Grants signing authority for that account
  5. Executes the CPI

Critical: Seeds must exactly match the PDA derivation, including the bump.


Account Privilege Propagation

Privilege Extension

When making a CPI, account privileges extend from the caller to the callee.

User Transaction
     │ (provides: signer=true, writable=true)
     ▼
┌─────────────────────┐
│  Program A          │
│  Receives accounts: │
│  - user (signer)    │──┐ Privileges
│  - vault (writable) │  │ propagate
└─────────────────────┘  │
                         ▼
                    ┌─────────────────────┐
                    │  Program B (via CPI)│
                    │  Can use:           │
                    │  - user (signer)    │
                    │  - vault (writable) │
                    └─────────────────────┘

Propagation Rules

Rule 1: If an account is a signer in Program A, it remains a signer in Program B (via CPI)

Rule 2: If an account is writable in Program A, it remains writable in Program B (via CPI)

Rule 3: Programs can add PDA signers via invoke_signed

Rule 4: Programs cannot escalate privileges (can't make non-signer a signer without PDA derivation)

Example: Privilege Propagation Chain

// User calls Program A
// Accounts: [user (signer, writable), vault (writable), data_account]

// Program A → CPI to Program B
invoke(
    &instruction_for_program_b,
    &[user.clone(), vault.clone()],  // Both retain privileges
)?;

// Program B → CPI to Program C
invoke(
    &instruction_for_program_c,
    &[user.clone()],  // user still a signer!
)?;

Depth: Up to 4 levels of CPI (5 total stack height including initial transaction)


Common CPI Patterns

1. System Program: Create Account

Most common CPI: Creating new accounts.

use solana_program::{
    program::invoke_signed,
    rent::Rent,
    system_instruction,
    sysvar::Sysvar,
};

pub fn create_pda_account(
    program_id: &Pubkey,
    payer: &AccountInfo,
    pda_account: &AccountInfo,
    system_program: &AccountInfo,
    space: usize,
    seeds: &[&[u8]],
    bump: u8,
) -> ProgramResult {
    // Calculate rent
    let rent = Rent::get()?;
    let rent_lamports = rent.minimum_balance(space);

    // Create account instruction
    let create_account_ix = system_instruction::create_account(
        payer.key,
        pda_account.key,
        rent_lamports,
        space as u64,
        program_id,
    );

    // Prepare signer seeds
    let mut full_seeds = seeds.to_vec();
    full_seeds.push(&[bump]);
    let signer_seeds: &[&[&[u8]]] = &[&full_seeds];

    // Execute CPI
    invoke_signed(
        &create_account_ix,
        &[payer.clone(), pda_account.clone(), system_program.clone()],
        signer_seeds,
    )?;

    Ok(())
}

2. System Program: Transfer SOL

use solana_program::system_instruction;

// From regular account
let transfer_ix = system_instruction::transfer(from_key, to_key, lamports);
invoke(&transfer_ix, &[from_account, to_account, system_program])?;

// From PDA
let transfer_ix = system_instruction::transfer(pda_key, to_key, lamports);
let signer_seeds: &[&[&[u8]]] = &[&[seeds, &[bump]]];
invoke_signed(&transfer_ix, &[pda_account, to_account, system_program], signer_seeds)?;

3. Custom Program CPI

Calling another custom program:

use borsh::BorshSerialize;

#[derive(BorshSerialize)]
struct CustomInstructionData {
    amount: u64,
    memo: String,
}

pub fn call_custom_program(
    custom_program_id: &Pubkey,
    accounts: &[AccountInfo],
    amount: u64,
) -> ProgramResult {
    let account_info_iter = &mut accounts.iter();
    let user = next_account_info(account_info_iter)?;
    let target_account = next_account_info(account_info_iter)?;
    let custom_program = next_account_info(account_info_iter)?;

    // Serialize instruction data
    let instruction_data = CustomInstructionData {
        amount,
        memo: "Hello from CPI".to_string(),
    };
    let data = instruction_data.try_to_vec()?;

    // Build instruction
    let instruction = Instruction {
        program_id: *custom_program_id,
        accounts: vec![
            AccountMeta::new(*user.key, true),           // signer, writable
            AccountMeta::new(*target_account.key, false), // writable
        ],
        data,
    };

    // Execute CPI
    invoke(
        &instruction,
        &[user.clone(), target_account.clone(), custom_program.clone()],
    )?;

    Ok(())
}

4. Multiple PDAs Signing

pub fn multi_pda_cpi(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
) -> ProgramResult {
    let pda1_seeds = &[b"pda1", &[bump1]];
    let pda2_seeds = &[b"pda2", &[bump2]];

    // Multiple PDA signers
    let signer_seeds: &[&[&[u8]]] = &[
        pda1_seeds,  // First PDA
        pda2_seeds,  // Second PDA
    ];

    invoke_signed(&instruction, &accounts, signer_seeds)?;

    Ok(())
}

5. Chained CPIs

Program A calls Program B, which calls Program C:

// In Program A
pub fn program_a_handler(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
) -> ProgramResult {
    // Call Program B
    let instruction_for_b = build_program_b_instruction();
    invoke(&instruction_for_b, accounts)?;

    Ok(())
}

// In Program B
pub fn program_b_handler(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
) -> ProgramResult {
    // Call Program C
    let instruction_for_c = build_program_c_instruction();
    invoke(&instruction_for_c, accounts)?;

    Ok(())
}

Depth tracking: User→A→B→C = stack depth 4 (within limit)


CPI Limits and Constraints

Stack Depth Limit

Maximum call depth: 5 (including initial transaction)

Depth 1: User Transaction
Depth 2: Program A (first CPI)
Depth 3: Program B (second CPI)
Depth 4: Program C (third CPI)
Depth 5: Program D (fourth CPI)
Depth 6: ❌ ERROR - MAX_INSTRUCTION_STACK_DEPTH exceeded

Constant:

// From agave source
pub const MAX_INSTRUCTION_STACK_DEPTH: usize = 5;

Error when exceeded:

Error: CallDepth(5)

Account Limits

  • Max accounts per instruction: 256 (practical limit ~64 without ALTs)
  • Max writable accounts: Limited by transaction size
  • Duplicate accounts: Allowed but share state (mutations visible to all references)

Compute Unit Costs

CPI operations consume compute units:

Operation Approximate CU Cost
invoke base cost ~1,000 CU
invoke_signed base cost ~1,000 CU
Per account passed ~50-100 CU
PDA derivation in runtime ~1,500 CU
Actual callee logic Variable

Tip: Pre-derive PDAs and store bumps to save CU.

Data Size Limits

  • Instruction data: No hard limit, but affects transaction size (1232 bytes max for non-ALT transactions)
  • Account data modification: Accounts can be resized via realloc (up to 10 MiB)

Security Considerations

1. Validate PDA Derivation Before CPI

Vulnerable:

pub fn vulnerable_cpi(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
) -> ProgramResult {
    let pda_account = &accounts[0];

    // No validation!
    let signer_seeds: &[&[&[u8]]] = &[&[b"vault", &[bump]]];

    invoke_signed(&instruction, &[pda_account.clone()], signer_seeds)?;
    Ok(())
}

Secure:

pub fn secure_cpi(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    bump: u8,
) -> ProgramResult {
    let pda_account = &accounts[0];

    // Validate PDA before CPI
    let (expected_pda, _) = Pubkey::find_program_address(&[b"vault"], program_id);
    if expected_pda != *pda_account.key {
        return Err(ProgramError::InvalidSeeds);
    }

    let signer_seeds: &[&[&[u8]]] = &[&[b"vault", &[bump]]];
    invoke_signed(&instruction, &[pda_account.clone()], signer_seeds)?;
    Ok(())
}

2. Verify Signer Requirements

Always check is_signer before making CPIs that transfer value:

if !user.is_signer {
    return Err(ProgramError::MissingRequiredSignature);
}

let transfer_ix = system_instruction::transfer(user.key, vault.key, amount);
invoke(&transfer_ix, &[user.clone(), vault.clone(), system_program.clone()])?;

3. Program ID Verification

Verify the program being called is the expected program:

const EXPECTED_PROGRAM: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";

if token_program.key.to_string() != EXPECTED_PROGRAM {
    return Err(ProgramError::IncorrectProgramId);
}

4. Privilege Leakage

Be careful about which accounts you pass in CPIs:

// ❌ Dangerous - passes admin with signer privilege
invoke(
    &untrusted_program_instruction,
    &[admin.clone(), user_data.clone()],  // Admin is a signer!
)?;

// ✅ Safe - only pass necessary accounts
invoke(
    &untrusted_program_instruction,
    &[user_data.clone()],  // Admin not included
)?;

5. Reent rancy Considerations

Solana programs are generally safe from reentrancy because:

  • Accounts are locked during instruction execution
  • Runtime prevents concurrent modifications

However, be cautious with:

  • State assumptions across CPI boundaries
  • Read-modify-write patterns split across CPIs

6. Error Handling

CPI errors propagate to the caller:

// If CPI fails, entire transaction reverts
match invoke(&instruction, &accounts) {
    Ok(()) => msg!("CPI succeeded"),
    Err(e) => {
        msg!("CPI failed: {:?}", e);
        return Err(e);  // Propagate error
    }
}

All state changes are atomic - if CPI fails, all changes rollback.


Best Practices

1. Derive PDAs Once

// ❌ Wasteful - derives multiple times
pub fn wasteful(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult {
    let (pda, bump) = Pubkey::find_program_address(&[b"data"], program_id);
    // ... use pda

    let (pda_again, bump_again) = Pubkey::find_program_address(&[b"data"], program_id);
    // ... use pda_again (same as pda!)
}

// ✅ Efficient - derive once, reuse
pub fn efficient(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult {
    let (pda, bump) = Pubkey::find_program_address(&[b"data"], program_id);
    // Reuse pda and bump
}

2. Store and Reuse Bumps

#[derive(BorshSerialize, BorshDeserialize)]
pub struct VaultData {
    pub bump: u8,  // Store on creation
    // ... other fields
}

// On CPI: use stored bump
let vault_data = VaultData::try_from_slice(&vault_pda.data.borrow())?;
let signer_seeds: &[&[&[u8]]] = &[&[b"vault", &[vault_data.bump]]];

Benefit: Saves ~2,700 CU per operation.

3. Helper Functions for Common CPIs

pub mod cpi_helpers {
    use super::*;

    pub fn transfer_sol(
        from: &AccountInfo,
        to: &AccountInfo,
        system_program: &AccountInfo,
        amount: u64,
    ) -> ProgramResult {
        let ix = system_instruction::transfer(from.key, to.key, amount);
        invoke(&ix, &[from.clone(), to.clone(), system_program.clone()])
    }

    pub fn transfer_sol_from_pda(
        from_pda: &AccountInfo,
        to: &AccountInfo,
        system_program: &AccountInfo,
        amount: u64,
        signer_seeds: &[&[&[u8]]],
    ) -> ProgramResult {
        let ix = system_instruction::transfer(from_pda.key, to.key, amount);
        invoke_signed(&ix, &[from_pda.clone(), to.clone(), system_program.clone()], signer_seeds)
    }
}

4. Validate All CPI Inputs

Checklist before CPI:

  • Verify signer requirements (is_signer)
  • Validate PDA derivation
  • Check program IDs match expectations
  • Verify account ownership
  • Validate data integrity

5. Document CPI Dependencies

/// Transfers SOL from program vault to recipient.
///
/// # Accounts
/// 0. `[writable]` vault_pda - Program vault (PDA, signer)
/// 1. `[writable]` recipient - Receives SOL
/// 2. `[]` system_program - System Program (11111...)
///
/// # CPIs Made
/// - System Program: transfer (from vault to recipient)
pub fn withdraw_from_vault(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    amount: u64,
) -> ProgramResult {
    // ...
}

6. Error Context

invoke(&instruction, &accounts).map_err(|e| {
    msg!("CPI to System Program failed");
    e
})?;

7. Minimize CPI Depth

Keep call chains shallow:

  • Reduces compute units
  • Easier to debug
  • Lower risk of hitting stack limit
  • Better user experience (simpler transactions)

Summary

Key Takeaways:

  1. CPI enables composability - programs call other programs
  2. Use invoke for regular signers, invoke_signed for PDAs
  3. Privileges propagate - signers and writable flags extend through CPIs
  4. Maximum depth is 5 - including initial transaction
  5. Always validate PDAs before using in invoke_signed
  6. Verify signer requirements to prevent unauthorized operations
  7. Store bumps in account data to save compute units
  8. CPIs are atomic - failures rollback all changes

Security Checklist:

  • Validate PDA derivation with canonical bump
  • Verify is_signer for value transfers
  • Check program IDs match expectations
  • Only pass necessary accounts (avoid privilege leakage)
  • Handle CPI errors appropriately

Common Pattern:

// 1. Validate inputs
if !user.is_signer {
    return Err(ProgramError::MissingRequiredSignature);
}

// 2. Derive and validate PDA if needed
let (pda, bump) = Pubkey::find_program_address(&seeds, program_id);
if pda != *pda_account.key {
    return Err(ProgramError::InvalidSeeds);
}

// 3. Build instruction
let ix = build_instruction();

// 4. Execute CPI
invoke_signed(&ix, &accounts, &[&[seeds, &[bump]]])?;

CPI is the foundation of program composability on Solana. Master it to build powerful, modular programs that leverage the entire ecosystem.