Appearance
Cross-Program Invocation
Cross-Program Invocation (CPI) allows one program to call another program's instructions within the same transaction. CPI is the primary mechanism for composability between on-chain programs — it is how your program transfers SOL, mints tokens, creates accounts, and interacts with any other deployed program.
Upstream-compatible
CPI mechanics on Zink are identical to Solana mainnet. The same invoke and invoke_signed syscalls, the same privilege model, and the same depth limits apply.
How CPI works
When program A executes a CPI to program B, the runtime:
- Suspends program A's execution.
- Loads program B and begins executing its entry point with the provided accounts and instruction data.
- When program B completes (or itself makes a deeper CPI), resumes program A.
- Account state modifications made by program B are visible to program A after the CPI returns.
From program B's perspective, the call looks exactly like a top-level instruction invocation — it receives program_id, accounts, and instruction_data through the same entry point.
invoke vs. invoke_signed
The Solana SDK provides two syscalls for making CPIs:
invoke
Calls another program, passing through the signer privileges that the current instruction already has:
rust
use solana_program::program::invoke;
invoke(
&system_instruction::transfer(from.key, to.key, amount),
&[from.clone(), to.clone(), system_program.clone()],
)?;Use invoke when the required signers are external accounts that already signed the transaction. The runtime extends their signer privilege to the CPI — if from signed the outer transaction, it is considered a signer in the inner CPI as well.
invoke_signed
Calls another program and additionally grants signer privilege to one or more PDAs:
rust
use solana_program::program::invoke_signed;
invoke_signed(
&token_instruction::transfer(
token_program.key,
source_ata.key,
destination_ata.key,
vault_authority.key, // a PDA
&[],
amount,
)?,
&[source_ata.clone(), destination_ata.clone(), vault_authority.clone()],
&[&[b"vault_authority", vault.key.as_ref(), &[bump]]], // signer seeds
)?;The third argument is a slice of signer seed slices — one entry per PDA that needs to sign. The runtime verifies that each set of seeds, combined with the calling program's ID, derives the corresponding PDA address. If verification passes, the PDA is treated as a signer for this CPI.
Privilege extension
CPI has a specific set of rules for how privileges (signer, writable) propagate:
| Privilege | Rule |
|---|---|
| Signer | A signer in the outer instruction remains a signer in the CPI. Additionally, PDAs signed via invoke_signed become signers. |
| Writable | An account marked writable in the outer instruction can be passed as writable in the CPI. You cannot escalate a read-only account to writable via CPI. |
This means you do not need users to sign again for inner CPIs — their signature on the outer transaction carries through.
Depth limit
CPI calls can be nested — program A calls B, which calls C, and so on. The maximum nesting depth is 4 levels (the top-level instruction + 4 CPI levels). Exceeding this limit causes the transaction to fail.
Transaction instruction (depth 0)
└─ CPI to program B (depth 1)
└─ CPI to program C (depth 2)
└─ CPI to program D (depth 3)
└─ CPI to program E (depth 4) ← maximum
└─ CPI to program F ← FAILSCompute budget considerations
CPI calls consume compute units from the outer transaction's budget — there is no separate budget per program. A CPI invocation has overhead (~1,000 CU) plus whatever the target program consumes. Plan your compute budget accordingly for transactions with deep or wide CPI trees.
Common CPI patterns
Transfer SOL
rust
invoke(
&system_instruction::transfer(from.key, to.key, lamports),
&[from.clone(), to.clone(), system_program.clone()],
)?;Create an account
rust
invoke(
&system_instruction::create_account(
payer.key,
new_account.key,
rent_lamports,
space as u64,
owner_program_id,
),
&[payer.clone(), new_account.clone(), system_program.clone()],
)?;Mint tokens (PDA authority)
rust
invoke_signed(
&spl_token::instruction::mint_to(
token_program.key,
mint.key,
destination.key,
mint_authority_pda.key, // PDA is the mint authority
&[],
amount,
)?,
&[mint.clone(), destination.clone(), mint_authority_pda.clone()],
&[&[b"mint_authority", &[bump]]],
)?;Transfer tokens (PDA authority)
rust
invoke_signed(
&spl_token::instruction::transfer(
token_program.key,
source.key,
destination.key,
authority_pda.key,
&[],
amount,
)?,
&[source.clone(), destination.clone(), authority_pda.clone()],
&[&[b"authority", source.key.as_ref(), &[bump]]],
)?;CPI in Starframe
Starframe keeps CPI calls explicit while giving you typed helper crates for common programs. For SPL Token transfers, add the SPL helper crate:
toml
[dependencies]
star_frame_spl = { version = "0.29.0", features = ["token"] }Then call the typed CPI builder:
rust
use star_frame_spl::token::{
instructions::{Transfer, TransferCpiAccounts},
Token,
};
Token::cpi(
Transfer { amount },
TransferCpiAccounts {
source: *accounts.source.account_info(),
destination: *accounts.destination.account_info(),
owner: *accounts.owner.account_info(),
},
None,
).invoke()?;For PDA-signed CPIs, reconstruct the exact seed tuple and pass signer seeds to invoke_signed or the framework helper you are using.
Return data
Programs can return data from a CPI using sol_set_return_data. The calling program can then read it with sol_get_return_data. This is useful for programs that need to return computed values (e.g., a price oracle returning a price). Return data is limited to 1024 bytes.