Summary
CPIs pass through 11 runtime steps including privilege checking, account translation, and data sync. Max call depth: 5 (9 with SIMD-0268). Privilege rules prevent callee from escalating beyond what the caller granted.
Privilege rules
CPIs extend the caller's account privileges to the callee with strict
enforcement. The runtime checks these rules in
prepare_next_instruction:
| Scenario | Allowed? | Enforcement point | Error |
|---|---|---|---|
| Caller passes account as writable, callee marks writable | Yes | -- | -- |
| Caller passes account as read-only, callee marks writable | No | prepare_next_instruction | PrivilegeEscalation |
| Caller passes account as writable, callee marks read-only | Yes | -- | -- |
| Caller passes account as signer, callee marks signer | Yes | -- | -- |
| Caller passes account as non-signer, callee marks signer, account is a PDA derived from caller's seeds | Yes | prepare_next_instruction | -- |
| Caller passes account as non-signer, callee marks signer, account is NOT a PDA from caller | No | prepare_next_instruction | PrivilegeEscalation |
| Caller passes account as signer, callee marks non-signer | Yes | -- | -- |
| Program A calls itself directly (A -> A) | Yes | push() | -- |
| Program A calls B which calls A (indirect reentrancy) | No | push() | ReentrancyNotAllowed |
| CPI to native loader, bpf_loader, bpf_loader_deprecated, or precompile | No | check_authorized_program | ProgramNotSupported |
| Account not found in transaction | No | prepare_next_instruction | MissingAccount |
The privilege rules can be summarized as:
- Writable privilege cannot escalate. If the caller marks an account as read-only, the callee cannot mark it writable.
- Signer privilege requires authorization. An account can be a signer in
the callee only if (a) it was already a signer in the caller, OR (b) it is a
PDA derived from the calling program's seeds via
invoke_signed. - Privilege reduction is always allowed. The callee may use fewer privileges than the caller granted.
CPI execution flow
A CPI passes through several runtime layers. This section documents the full pipeline from the program SDK call through the syscall boundary into the runtime and back. Each step references the source file that implements it.
The maximum height of the program instruction invocation is called the
max_instruction_stack_depth
and is set to the
MAX_INSTRUCTION_STACK_DEPTH
constant of 5. With MAX_INSTRUCTION_STACK_DEPTH_SIMD_0268 active, this increases to 9.
Stack height 1 is the initial transaction instruction. Each CPI increments the height by 1. A maximum of 5 means a program can make CPIs up to 4 levels deep (8 levels deep with SIMD-0268).
Step 1: Program calls invoke or invoke_signed
The program calls
invoke
or
invoke_signed.
invoke is a thin wrapper that calls invoke_signed with an empty signer seeds
array. The SDK function serializes the Instruction, AccountInfo slice, and
signer seeds into VM memory, then triggers the syscall.
Step 2: Syscall entry
The SBF VM dispatches to the
sol_invoke_signed_rust
syscall handler, which calls into the shared entry point:
cpi_common.
Step 3: Consume invocation cost
The first action inside cpi_common is to
charge the fixed invocation cost
from the shared compute meter:
invoke_units
= 1,000 CUs (or 946 CUs with SIMD-0339).
Step 4: Translate instruction from VM memory
The syscall handler translates the instruction from the program's VM address
space to host-side Rust types via
translate_instruction_rust,
which reads a StableInstruction struct, validates data length against
MAX_INSTRUCTION_DATA_LEN
(10,240 bytes), then charges the
data serialization cost.
Step 5: Translate signer seeds and derive PDAs
The handler calls
translate_signers_rust.
For each set of signer seeds, the runtime:
- Checks the number of signer seed sets against
MAX_SIGNERS(16). - Checks each seed set's length against
MAX_SEEDS(16 seeds per set). - Calls
Pubkey::create_program_addresswith the seeds and the caller's program ID. If the seeds do not produce a valid PDA, the CPI fails withBadSeeds. - Collects the resulting PDA pubkeys into a
signersvec.
These derived PDAs are treated as valid signers for the callee instruction.
Step 6: Check authorized program
Before proceeding, the runtime calls
check_authorized_program
to verify the target program is allowed for CPI. The following programs are
blocked:
- The native loader
bpf_loaderandbpf_loader_deprecatedbpf_loader_upgradeable(except specific management instructions:upgrade,set_authority,set_authority_checked(feature-gated),extend_program_checked(feature-gated),close)- Precompile programs (ed25519, secp256k1, etc.)
Violation returns
ProgramNotSupported.
Step 7: Privilege verification (prepare_next_instruction)
The runtime calls
prepare_next_instruction
which builds the callee's InstructionAccount list and enforces privilege
rules. See Privilege rules below for the full decision
table.
Step 8: Translate account infos
The handler calls translate_accounts which:
- Validates the account info count
against
MAX_CPI_ACCOUNT_INFOS(128, or 255 with SIMD-0339). - Charges account info translation cost
(SIMD-0339 only):
(num_account_infos * 80) / 250CUs. - For each non-executable, non-duplicate account, builds a
CallerAccountby translating pointers from VM memory to host memory. This includes charging per-account data serialization cost:account_data_len / cpi_bytes_per_unitCUs.
Step 9: Pre-CPI account sync (caller to callee)
Before executing the callee, the runtime syncs the caller's account
modifications so the callee can see them. The function
update_callee_account
is called for each translated account, copying lamports, data, and owner. See
Account data synchronization
for the detailed field mapping.
Step 10: Push instruction context, execute callee, and pop
The runtime calls
process_instruction,
which:
- Calls
push()to add a new frame to the instruction stack.push()enforces the reentrancy rule: a program may only call itself if it is the direct caller (i.e., program A can call A, but A cannot call B which calls A). Violation returnsReentrancyNotAllowed. - Calls
process_executable_chainwhich resolves the callee's program entry point and invokes it. The callee runs with the same shared compute meter. All CU consumption by the callee reduces the caller's remaining budget. - Calls
pop()to remove the callee's frame and verify that lamport balances are unchanged (UnbalancedInstructionif not).
Step 11: Post-CPI account sync (callee to caller)
After process_instruction returns (which includes the pop), the runtime syncs
changes back to the caller via
update_caller_account
for each writable account. Additionally,
update_caller_account_region
updates VM memory region mappings for accounts whose data regions changed. See
Account data synchronization
for the detailed field mapping.
The CPI syscall returns 0 (success) to the caller program.
Is this page helpful?