CPI 执行与权限

摘要

CPI 会经过 11 个运行时步骤,包括权限检查、账户转换和数据同步。最大调用深度为 5(使用 SIMD-0268 时为 9)。权限规则防止被调用方获得超出调用方授予的权限。

权限规则

CPI 会将调用方的账户权限扩展给被调用方,并严格执行。运行时会在 prepare_next_instruction 检查这些规则:

场景是否允许执行检查点错误代码
调用方将账户作为可写传递,被调用方标记为可写----
调用方将账户作为只读传递,被调用方标记为可写prepare_next_instructionPrivilegeEscalation
调用方将账户作为可写传递,被调用方标记为只读----
调用方将账户作为签名者传递,被调用方标记为签名者----
调用方将账户作为非签名者传递,被调用方标记为签名者,且账户是由调用方种子派生的 PDAprepare_next_instruction--
调用方将账户作为非签名者传递,被调用方标记为签名者,且账户不是由调用方派生的 PDAprepare_next_instructionPrivilegeEscalation
调用方将账户作为签名者传递,被调用方标记为非签名者----
程序 A 直接调用自身(A -> A)push()--
程序 A 调用 B,B 再调用 A(间接重入)push()ReentrancyNotAllowed
CPI 到 native loader、bpf_loader、bpf_loader_deprecated 或 precompilecheck_authorized_programProgramNotSupported
交易中未找到账户prepare_next_instructionMissingAccount

权限规则可总结如下:

  1. 可写权限不可提升。 如果调用方将账户标记为只读,被调用方不能将其标记为可写。
  2. 签名者权限需要授权。 只有在以下情况下,被调用方中的账户才能成为签名者:(a) 该账户在调用方中已是签名者,或 (b) 该账户是通过调用程序的种子派生出的 PDA(程序派生地址),方式为 invoke_signed
  3. 权限降低始终允许。 被调用方可以使用比调用方授予的更少的权限。

CPI 执行流程

CPI 会经过多个运行时层。本节将记录从程序 SDK 调用到越过 syscall 边界进入运行时再返回的完整流程。每一步都引用了实现该步骤的源文件。

程序指令调用的最大高度称为 max_instruction_stack_depth ,其值由 MAX_INSTRUCTION_STACK_DEPTH 常量设定为 5。当 MAX_INSTRUCTION_STACK_DEPTH_SIMD_0268 激活时,该值提升至 9。

堆栈高度为 1 时表示初始交易指令。每次 CPI 调用高度加 1。最大为 5 意味着程序最多可进行 4 层嵌套 CPI 调用(启用 SIMD-0268 时可达 8 层)。

步骤 1:程序调用 invokeinvoke_signed

程序会调用 invokeinvoke_signedinvoke 是一个简单封装器,会以空的签名者种子数组调用 invoke_signed。SDK 函数会将 InstructionAccountInfo 切片和签名者种子序列化到 VM 内存中,然后触发 syscall。

步骤 2:Syscall 入口

SBF VM 会分发到 sol_invoke_signed_rust syscall 处理器,并调用共享入口点: cpi_common

步骤 3:消耗调用费用

cpi_common 内部的第一个操作是 收取固定调用费用 ,费用从共享计算计量器中扣除: invoke_units = 1,000 CU(或使用 SIMD-0339 时为 946 CU)。

步骤 4:从 VM 内存中转换指令

系统调用处理器会将指令从程序的 VM 地址空间转换为主机端 Rust 类型,通过 translate_instruction_rust 实现。它会读取一个 StableInstruction 结构体,并根据 MAX_INSTRUCTION_DATA_LEN (10,240 字节)校验数据长度,然后收取 数据序列化费用

步骤 5:转换签名者 seed 并派生 PDA

处理器会调用 translate_signers_rust。对于每组签名者 seed,运行时会:

  1. 检查签名者 seed 集合的数量是否超过 MAX_SIGNERS (16)。
  2. 检查每组 seed 的长度是否超过 MAX_SEEDS (每组最多 16 个 seed)。
  3. 使用 seed 和调用方的程序 ID 调用 Pubkey::create_program_address。如果 seed 无法生成有效的 PDA,则 CPI 会因 BadSeeds 失败。
  4. 将生成的 PDA 公钥收集到一个 signers vec 中。

这些派生出的 PDA 会被视为被调用指令的有效签名者。

步骤 6:检查授权程序

在继续之前,运行时会调用 check_authorized_program 以验证目标程序是否允许 CPI。以下程序会被阻止:

  • 原生加载器
  • bpf_loaderbpf_loader_deprecated
  • bpf_loader_upgradeable(仅允许特定管理指令:upgradeset_authorityset_authority_checked(需特性开关)、 extend_program_checked(需特性开关)、close
  • 预编译程序(如 ed25519、secp256k1 等)

如有违规,将返回 ProgramNotSupported

步骤 7:权限验证(prepare_next_instruction

运行时会调用 prepare_next_instruction ,构建被调用方的 InstructionAccount 列表并强制执行权限规则。完整决策表请参见下方的权限规则

步骤 8:翻译账户信息

处理程序会调用 translate_accounts,其功能如下:

  1. 验证账户信息数量 是否符合 MAX_CPI_ACCOUNT_INFOS(128,或在 SIMD-0339 下为 255)。
  2. 收取账户信息翻译费用 (仅限 SIMD-0339):(num_account_infos * 80) / 250 CU。
  3. 对于每个非可执行且非重复的账户,通过将指针从 VM 内存翻译到主机内存,构建一个 CallerAccount。这包括 按账户收取数据序列化费用account_data_len / cpi_bytes_per_unit CU。

步骤 9:CPI 前账户同步(调用方到被调用方)

在执行被调用方之前,运行时会同步调用方账户的修改,以便被调用方能够看到这些更改。对于每个已翻译的账户,会调用函数 update_callee_account,复制 lamport、数据和 owner。详细字段映射请参见 账户数据同步

步骤 10:推送指令上下文、执行被调用方并弹出

运行时会调用 process_instruction,其功能如下:

  1. 调用 push(),向指令栈添加一个新帧。push() 会强制执行 可重入性规则:程序只能在直接调用者的情况下调用自身(即程序 A 可以调用 A,但 A 不能调用 B 再调用 A)。如违反则返回 ReentrancyNotAllowed
  2. 调用 process_executable_chain,解析被调用方的程序入口并执行。被调用方与调用方共享同一个计算预算,所有被调用方消耗的 CU 都会减少调用方剩余预算。
  3. 调用 pop(),移除被调用方的帧,并验证 lamport 余额是否未变(如有变则返回 UnbalancedInstruction)。

步骤 11:CPI 后账户同步(被调用方到调用方)

process_instruction 返回后(包括弹出操作),运行时会通过 update_caller_account 对每个可写账户同步更改。此外, update_caller_account_region 会更新数据区域发生变化账户的 VM 内存区域映射。详细字段映射请参见 账户数据同步

CPI syscall 会向调用程序返回 0(成功)。

Is this page helpful?

管理者

©️ 2026 Solana 基金会版权所有
取得联系