Solana 的 CPI 深度上限,以及路由为什么会失败

嵌套调用天花板、为什么嵌套程序碰到的每一个账户都必须从最外层传进来,以及聚合器路由是怎么同时撞上这两条的。

BoltTx Team··14 min read
solanacpi跨程序调用计算交易上链swap

跨程序调用是一个程序在同一笔交易里调用另一个程序。swap 程序转移代币靠它,聚合器穿过多个池子靠它 —— 几乎每一个非平凡的 Solana 动作都靠它完成。

★它同时带着两条限制,而大多数人是以一个令人困惑的 revert、而不是以一条有文档的约束的形式遇到它们的。★

深度天花板

Solana 的指令栈深度是 5 层:你交易里的那条指令占第一层,下面还能再嵌套四层 CPI

你的指令                   ← 第 1 层(它本身不是 CPI)
  └─ 聚合器                ← 嵌套第 1 层
      └─ 池子程序          ← 嵌套第 2 层
          └─ 代币程序      ← 嵌套第 3 层
              └─ hook      ← ★嵌套第 4 层 —— 最后一层★
                  └─ 再往下 ← ★被拒绝:CallDepth★

再往下,运行时返回 CallDepth —— 有时候会以 ProgramFailedToComplete 的形式冒出来,取决于调用方怎么处理。

这个天花板是运行时常量,不是每个程序自己能调的:

// Agave,program-runtime/src/execution_budget.rs
pub const MAX_INSTRUCTION_STACK_DEPTH: usize = 5;

栈把你交易里的那条指令算作第一层,所以 5 层栈意味着 4 次 CPI。★另有一个特性会把嵌套上限抬到 8★ —— 所以今天按四层来设计,但别把它当成链上永恒不变的性质。

★这个天花板很容易撞上,哪怕你自己一行嵌套代码都没写。★ 一条 swap 指令常常在你自己的逻辑贡献任何东西之前,就已经花掉了这四层嵌套里的三层 —— 因为你调用的那些程序自己也在调用程序。

实际会咬人的场景: 穿过好几个池子程序的聚合器路由、为了记账而包裹其它程序的程序,以及任何调用了"自己又通过一个辅助层去调代币程序"的程序。

每一个账户都必须从最外层传进来

这条限制产出的错误更让人困惑。

★程序没法为一次 CPI 凭空造出账户。调用链上任何一个嵌套程序碰到的每一个账户,都必须出现在原始交易的账户列表里。★

// ★这些不只是给你那条指令用的。★
const keys = [
  { pubkey: user,        isSigner: true,  isWritable: true  },
  { pubkey: poolAccount, isSigner: false, isWritable: true  },
  { pubkey: poolVaultA,  isSigner: false, isWritable: true  },  // ★池子程序会用★
  { pubkey: poolVaultB,  isSigner: false, isWritable: true  },  // ★池子程序会用★
  { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false },  // ★在第 3 层被调用★
];

漏掉一个,你会拿到 NotEnoughAccountKeysAccountNotFound —— 这些错误点名的是账户,而不是告诉你某次嵌套调用需要它那个账户属于一个你代码里从没提过的程序,这正是错误信息很少指向有用位置的原因。

这也是 swap 的账户列表那么长、以及它们为什么逼近 1232 字节上限的原因。★你携带的是调用链上每一个程序的账户,不只是你直接调用的那个。★

如果你的指令组装没问题、交易还是会错过,免费领个 BoltTx key 改一行就能测测提交路径。

签名者权限是向下传递的

一个在 CPI 因授权失败时值得知道的细节:

★签名者和可写权限会沿调用链向下传递。★ 如果你的交易为某个账户签了名,你调用的程序可以把该账户当作已签名来操作 —— 而那个程序调用的程序也可以。

// 池子程序用一个 PDA 为自己的金库签名。
invoke_signed(
    &transfer_ix,
    &accounts,
    &[&[b"vault", mint.as_ref(), &[bump]]],   // ★PDA 在这里签名★
)?;

一个程序只能为从它自己的 program ID 推导出来的 PDA 签名。 那就是边界 —— 程序伪造不了你钱包的签名,也没法为另一个程序的 PDA 签名。当一次 CPI 因缺签名而失败时,要问的是本该由哪一层提供它

计算是整条链一起消耗的

每一层都消耗计算单元,而它们全部来自同一份"每笔交易"的预算

// ★模拟真实的那条路由,不是单跳。★
const sim = await connection.simulateTransaction(tx, {
  replaceRecentBlockhash: true,
  sigVerify: false,
});
console.log("单元:", sim.value.unitsConsumed);

★两跳路由的成本不是一跳的两倍 —— 是更多,因为每多一个程序都带着它自己的账户校验和反序列化开销。★ 从单跳外推会低估,而低估上限会让一笔本来能成功的交易失败。

它和 address lookup table 的相互作用会让人意外:解析 lookup table 本身也消耗计算。 一条不用 ALT 时计算很宽裕的路由,用了之后可能需要更高的 setComputeUnitLimit

从日志里读懂一次失败

只要你读的是日志栈而不是只看顶层错误,CPI 失败是可读的:

Program AGGREGATOR invoke [1]
  Program POOL_A invoke [2]
    Program TOKEN invoke [3]
    Program TOKEN success
  Program POOL_A success
  Program POOL_B invoke [2]
    Program TOKEN invoke [3]
    ★Program TOKEN failed: insufficient funds★
  Program POOL_B failed
Program AGGREGATOR failed

★方括号里的数字是深度,而最内层的那次失败才是真正的原因。★ 顶层错误只告诉你聚合器失败了 —— 这句话是对的,也是没用的。

const tx = await connection.getTransaction(sig, {
  maxSupportedTransactionVersion: 0,
});
tx?.meta?.logMessages?.forEach((l) => console.log(l));

从下往上读。 开始层层退栈之前的最后一次失败,才是要修的那个。

减少深度和账户数

当一条路由装不下时,按"多久能奏效"排序的手段:

减少跳数。 直接路由用更少的层、更少的账户。★onlyDirectRoutes: true 是拿一点价格改善,换一笔真能落块的交易。★

拆成两笔交易。 只在这些步骤不需要原子性时可行 —— 而这恰好把套利排除在外。

Address lookup table。 它们帮的是体积上限,对深度上限毫无作用。这一点值得说准确,因为它们经常被同时推荐给两者。

在报价阶段就限制账户数。 聚合器请求里的 maxAccounts,能把路由控制在一笔交易装得下的范围内。

上链应该是什么水平

我们投递节点上的真实交易:中位确认 336 毫秒 —— 不到一个 slot。

★一次 CPI 失败永远拿不到那个数字 —— 它落块然后 revert,为一笔什么都没改变的交易付掉基础手续费。★ 这让它成为值得在模拟阶段就抓住的东西,因为在那里它不花钱。

BoltTx 在这里的位置

我们负责提交。指令结构和路由选择完全归你。

提交走我们自建的四区域投递节点,带 SWQoS 路由,不暴露公共 mempool —— 交易在落块之前、传输途中观察不到。我们不修改交易内容,这包括绝不改动你的指令集或账户列表。

你在本地签名。我们不托管资金、不代签。tip 带在交易里、从你自己的钱包链上支付,交易失败时跟着一起 revert —— 这是 Solana 原子交易的机制决定的。只有到达链上的交易才计费。

免费领 API key,没有月费:

const connection = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");

常见问题

Solana 的 CPI 是什么? 跨程序调用 —— 一个程序在同一笔交易里调用另一个程序。swap 程序转移代币、聚合器穿过池子程序,靠的都是它。

Solana 的 CPI 深度上限是多少? 指令栈一共 5 层:你交易里的那条指令,加上下面四层嵌套调用。再往下运行时会返回 CallDepth

我的聚合器 swap 为什么报 call depth exceeded? 路由嵌套了太多程序。你的指令、聚合器、池子程序、代币程序已经把栈占满,所以再多一个包裹层就超了。

只有嵌套程序用到的账户,我也要传吗? 要。程序没法为 CPI 凭空造账户,所以调用链上任何程序碰到的每一个账户都必须出现在原始交易里。这就是 swap 账户列表那么长的原因。

swap 为什么报 NotEnoughAccountKeys? 某个嵌套程序需要的账户不在你的列表里。错误点名的是账户、而不是想要它的那次嵌套调用,所以它很少指向有用的位置。

一次 CPI 消耗多少计算? 比各程序单独之和更多,因为每一层都增加账户校验和反序列化开销。要模拟真实路由,不要从单跳外推。

程序能在 CPI 里替我的钱包签名吗? 不能。程序只能为从它自己 program ID 推导的 PDA 签名。 你钱包的签名来自你的交易,链上任何程序都伪造不了。

签名者权限在 CPI 各层之间怎么工作? 向下传递。在最外层被签名的账户,对它下面被调用的程序也是已签名状态,嵌套程序就是这样代表你行动的。

怎么调试一次失败的 CPI? 读日志消息,顺着方括号里的深度数字看。最内层的失败才是真正原因,顶层错误只是在报告外层程序失败了。

address lookup table 能帮上 CPI 深度吗? 不能。它压缩账户引用、帮的是 1232 字节的体积上限,对调用深度毫无作用。这是两条独立的限制。

加了 lookup table 为什么计算用量上升? 因为运行时解析这张表本身要消耗计算。一条不用它时很宽裕的路由,用了之后可能需要更高的计算上限。

怎么减少一笔 swap 里的账户数? 减少路由跳数、在报价时限制 maxAccounts,并用 lookup table 压缩剩下的。直接路由携带的账户明显少于拆分路由。

该用 onlyDirectRoutes 来规避这些限制吗? 抢跑时值得考虑。直接路由用更少的层、更少的账户、更少的计算,而一笔能落块的路由胜过一个会 revert 的更优价格。

CPI 失败要花钱吗? 如果交易落块了,要。它 revert、什么都没改变,而基础手续费照付 —— 这正是值得在模拟阶段就抓住它的原因。

能把一条路由拆到两笔交易里吗? 只有当这些步骤不需要原子性时可以。套利需要原子性,所以恰恰在路由最复杂的地方,这个选项用不了。

invoke_signed 是干什么的? 它让程序通过提供种子、用自己的某个 PDA 为一次 CPI 签名。池子程序就是这样授权从它拥有的金库里转出的。

延伸阅读

返回博客列表