一个直接调用时完美工作的程序,放进聚合器路由里可能根本用不了。它本身没有任何毛病 —— 它只是花光了调用方需要的预算。
★深度、账户槽位、计算,都是整笔交易共享的。一个花钱大手大脚的程序,是一个无法被组合的程序。★
你花的是哪几份预算
调用方的指令 ← 第 1 层(调用方的入口)
└─ ★你的程序★ ← 第 2 层
└─ ★你的 CPI★ ← 第 3 层
└─ ★你的 CPI★ ← 嵌套第 3 层
└─ 代币程序 ← ★嵌套第 4 层 —— 天花板★
★如果你的程序用了两层嵌套 CPI,那么一个经聚合器调用你的人,在你的逻辑跑起来之前就已经超限了。★
另外两份预算同理:
| 预算 | 上限 | 你的程序花掉什么 |
|---|---|---|
| ★CPI 深度★ | ★4 层嵌套★ | ★每一次嵌套调用★ |
| ★账户★ | ★共 1232 字节★ | ★你要求的每个账户 32 字节★ |
| ★计算★ | ★按交易计★ | ★你的用量加上 CPI 开销★ |
这些都不是"每个程序的份额"。 它们属于这笔交易,而你的调用方在和你分着用。
能压平就压平
最常见的、可以避免的深度成本,是一个"为了代码组织"而存在的辅助程序:
// ★本来两层就够的事,用了三层。★
your_program
└─ your_helper_program
└─ token_program
// ★两层。★
your_program
└─ token_program
★一个不提供任何链上保证的包装层,是你的调用方要付的深度。★ 如果那段逻辑能待在你的程序里、而不是躲在另一次调用后面,可组合性的收益通常大于代码组织上的损失。
当 CPI 确实必要时 —— 你需要另一个程序的状态转换 —— 那层深度是这个功能的价格,而那是公平的交换。
如果你的程序组合性很好、交易还是会错过,免费领个 BoltTx key 你的用户改一行就能测测提交路径。
只要最小的账户集合
你指令 context 里的每一个账户,都在调用方的交易里占 32 字节:
#[derive(Accounts)]
pub struct Swap<'info> {
pub pool: Account<'info, Pool>,
pub user_source: Account<'info, TokenAccount>,
pub user_dest: Account<'info, TokenAccount>,
pub token_program: Program<'info, Token>,
// ★这些你真的需要吗?★
pub rent: Sysvar<'info, Rent>, // ★现在通常不需要了★
pub system_program: Program<'info, System>, // ★只有创建账户时才要★
}
★rent、clock 这类 sysvar 在大多数情况下可以直接读取,不必作为账户传进来。★ 要求它们是一个遗留写法,而它在每一个调用方的交易里都还在占 32 字节。
派生账户比看起来更微妙。 如果你的程序自己能推导一个 PDA,要求调用方传进来会为一个你反正要算的东西加字节 —— 不过它确实省掉了链上推导的计算,所以这是一个真实的取舍,而不是哪边明显更好。
在昂贵的活儿之前做校验
指令内部的顺序,决定了一次失败要花多少:
pub fn swap(ctx: Context<Swap>, amount: u64, min_out: u64) -> Result<()> {
// ★便宜的检查放前面 —— 在花计算之前失败。★
require!(amount > 0, SwapError::AmountZero);
require!(!ctx.accounts.pool.is_paused, SwapError::PoolPaused);
// ★然后才是昂贵的部分。★
let out = compute_output(&ctx.accounts.pool, amount)?;
require!(out >= min_out, SwapError::SlippageExceeded);
token::transfer(ctx.accounts.transfer_ctx(), amount)?; // ★CPI 放最后★
Ok(())
}
★在 CPI 之后 revert,浪费掉了那次 CPI 的计算;在它之前 revert,则没有。★ 交易两种情况都要付基础手续费,但调用方的计算上限是一份共享资源 —— 便宜地失败,给他们留下了在同一份预算内重试的空间。
只为调用方算不出来的东西发事件
#[event]
pub struct SwapExecuted {
pub amount_in: u64,
pub amount_out: u64, // ★经过所有内部调整之后★
pub fee_paid: u64,
}
★交易 meta 已经报告了余额变化,所以重复那些信息的事件是在白花计算。★
值得发的是调用方从余额推导不出来的内部状态 —— 哪个费率档位生效了、内部走了哪条路由、执行时池子状态如何。那些信息只存在于你的程序里,没有它,调用方只能靠猜来重建。
用 PDA 签名
当你的程序需要为它拥有的账户授权一次转账时:
let seeds = &[b"vault", mint.as_ref(), &[bump]];
let signer = &[&seeds[..]];
token::transfer(
CpiContext::new_with_signer(token_program, accounts, signer),
amount,
)?;
★把 bump 存下来,不要在链上调 find_program_address。★ 那个搜索循环每次调用都跑,消耗你的调用方要付的计算,去重算一个你在初始化时本可以存下来的值。
一个程序只能为从它自己 program ID 推导的 PDA 签名 —— 这是安全边界,你伪造不了调用方钱包或另一个程序 PDA 的签名。
按"会被机器人调用"来设计
★最要紧的可组合性问题是:你的指令能不能成为一笔原子多步交易里的一条腿?★
让它成为可能的:
深度节制,好让上面还有给路由器的空间。
账户集合小,好让交易还装得下。
计算可预测,并且公布出来,好让调用方设对上限。
★错误能区分"可重试"和"永久"★,好让机器人知道下一步做什么。
让它不可能的,通常不是一个大问题,而是累积 —— 这里一个包装层、那里一个 sysvar、再加一个没公布的计算数字 —— 直到一条本该装得下的路由装不下了。
上链应该是什么水平
我们投递节点上的真实交易:中位确认 336 毫秒 —— 不到一个 slot。
★可组合性决定一笔交易能不能被构建;路由决定它什么时候落块。★ 一个给调用方留出空间的程序,解决的是第一个问题 —— 而那是任何提交路径都修不好的那个。
BoltTx 在这里的位置
我们为调用你程序的那些人处理提交。CPI 结构和账户要求都在你的程序里决定。
提交走我们自建的四区域投递节点,带 SWQoS 路由,不暴露公共 mempool —— 交易在落块之前、传输途中观察不到。我们不修改交易内容,这包括绝不改动指令集或账户列表。
你的调用方在本地签名。我们不托管资金、不代签、不修改交易内容。tip 带在交易里、从他们自己的钱包链上支付,交易失败时跟着一起 revert —— 这是 Solana 原子交易的机制决定的。只有到达链上的交易才计费。
免费领 API key,没有月费:
const connection = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");
常见问题
我的程序用多少 CPI,怎么影响调用方? 深度、账户、计算是整笔交易共享的,不是按程序分配的。你每嵌套一层,经聚合器路由过来的调用方就少一层。
Solana 的 CPI 深度上限是多少? 指令栈 5 层:调用方的指令算第一层,下面还能嵌套四层。一个自己就用掉两层嵌套 CPI 的程序,几乎不给间接调用它的人留任何空间。
该避免辅助程序吗? 在它不提供任何链上保证的地方,该避免。 一个为了代码组织而存在的包装层,在每一笔交易上都让调用方付一层深度。
还需要传 rent sysvar 吗? 通常不需要。大多数情况下 sysvar 可以直接读,而要求它们在每个调用方的交易里白占 32 字节。
我的程序该自己推导 PDA 还是要求传进来?
这是一个真实的取舍。要求传会花调用方的字节;自己推导会花计算。 存下 bump 再用 create_program_address,两边都能避开那个昂贵的搜索。
为什么要存 PDA 的 bump?
因为 find_program_address 每次调用都跑一个搜索循环,消耗你的调用方要付的计算,去重算一个你在初始化时本可以存下来的值。
指令里校验该放在哪? 便宜的检查放前面,昂贵的工作和 CPI 放最后。 在 CPI 之前 revert,比在它之后 revert 浪费掉的调用方计算预算更少。
什么样的事件值得发? 调用方从余额变化推导不出来的内部状态,比如哪个费率档位生效了。重复交易 meta 已有信息的事件,是在白花计算。
一条指令该要求多少账户? 只要真正需要的。 每个占 1232 字节上限里的 32 字节 —— 而那通常正是让一条多跳路由装不下的原因。
我的程序能为任何账户签名吗? 只能为从它自己 program ID 推导的 PDA 签名。 它伪造不了调用方钱包或另一个程序 PDA 的签名 —— 这是安全边界。
我的程序直接调用没问题,放进聚合器路由为什么失败? 几乎肯定是共享预算的问题。那条路由在到达你之前已经消耗了深度、账户或计算,而你的要求超出了剩下的部分。
怎么让我的程序可组合? 深度节制、账户集合小、公布计算数字、错误区分可重试与永久。 可组合性通常是被累积消耗掉的,不是毁于一个大失误。
合并 CPI 能降低成本吗? 在目标程序接口允许的地方能。每次 CPI 都带着被调程序开销之外的建立成本,所以调用次数越少,开销越小。
该公布程序的计算用量吗? 该。知道这个数字的调用方会设一个准确的上限,而不知道的会默认设高、多付钱,然后把成本归咎于网络状况。
CPI 的账户是怎么工作的? 任何嵌套程序碰到的每一个账户,都必须出现在原始交易里。 你的程序造不出它们 —— 这就是账户要求会一路向上传导到调用方的原因。
最常见的可组合性失误是什么? 要求了程序并不需要的账户(通常是遗留的 sysvar),再加上一个白占一层深度的多余包装程序。