Solana 交易模拟:它到底能告诉你什么

simulateTransaction 预测的是当前状态下的执行,不是你会落进去的那个状态。它真正有用的地方、会误导的地方,以及生产环境为什么跳过 preflight。

BoltTx Team··14 min read
solana模拟skippreflightcompute-unit调试rpc

simulateTransaction 拿当前链上状态跑一遍你的交易,告诉你会发生什么。听起来正是你花钱之前想知道的。

问题出在"当前"两个字。你不会落在当前 slot —— 你会落在之后某个 slot,面对的是已经被其它交易改过的状态。模拟回答的是一个和你的问题相邻、但不相同的问题。

模拟到底返回什么

三样有用的东西,而且分清哪个可靠很重要:

const sim = await connection.simulateTransaction(tx, {
  sigVerify: false,
  replaceRecentBlockhash: true,
});

sim.value.err;             // 按当前状态,会不会失败
sim.value.logs;            // 程序日志输出
sim.value.unitsConsumed;   // ★实际消耗的计算单元★
sim.value.accounts;        // 执行后的账户状态(需要请求)

unitsConsumed 是最可靠的输出★ —— 因为计算消耗主要由代码路径决定,而不是行情。这就是你该拿来设 compute unit limit 的那个数字。

err 最不可靠,因为交易会不会失败,往往取决于那些"等你落块时早已变了"的状态。

两个最容易踩的选项

sigVerify: false 跳过签名验证。适合模拟一笔你还没签名的交易 —— 开发阶段、指令还在组装的时候。

replaceRecentBlockhash: true 在服务端替换成新的 blockhash。不加这个,模拟一笔 blockhash 已过期的交易会因为错误的原因失败,而那和你的指令毫无关系。

// 开发阶段:量计算消耗、检查指令逻辑。
await connection.simulateTransaction(tx, {
  sigVerify: false,
  replaceRecentBlockhash: true,
});

// 验证一笔已完整签名的交易格式是否正确。
await connection.simulateTransaction(tx, {
  sigVerify: true,
  replaceRecentBlockhash: false,
});

如果你的模拟已经写对、问题在交易不上链,免费领个 BoltTx key 改一行就能测测路由那一侧。

测量计算单元

这是模拟最有价值的用途,也是最多人跳过的。

const sim = await connection.simulateTransaction(tx, {
  sigVerify: false,
  replaceRecentBlockhash: true,
});

const measured = sim.value.unitsConsumed ?? 200_000;

// 余量用来覆盖更深的 CPI 链、更大的账户状态,
// 以及这次模拟没走到的分支。
const limit = Math.ceil(measured * 1.2);

★要对真实状态模拟,不要用空夹具。★ 穿过一个 tick 数据很多的池子做 swap,消耗明显高于穿过一个空池子。在 devnet 拿空池子模拟出来的数字,到生产环境会失败。

不设实测的 limit,你就会按一个远高于真实用量的默认值计费 —— 而这恰好浪费在费用最贵的时候。

读日志

sim.value.logs 是失败交易自己解释原因的地方,前提是你知道该看什么。

if (sim.value.err) {
  // 最后几行通常包含真正的原因。
  console.log(sim.value.logs?.slice(-8).join("\n"));
}

常见模式及其含义:

日志片段 原因
exceeded CUs meter ★compute limit 太低★
insufficient funds 余额不足,常常包含租金
custom program error: 0x1771 ★滑点超限(Jupiter 一类)★
AccountNotFound 账户还不存在 —— 往往是缺 ATA
Cross-program invocation ... privilege escalation 账户没标成可写或签名者

★错误码是程序自定义的。★ 0x1771 在一个 swap 程序里是一个意思,在别的程序里是另一个意思。去查那个程序自己的错误枚举,别光搜这个十六进制值。

生产环境为什么跳过 preflight

preflight 是 RPC 在转发之前自动跑的模拟。它默认开启,而对交易路径来说,它的代价大于收益。

它要花一次往返。 RPC 先模拟、再转发。这是加在每次发送上的延迟。

它模拟的是错误的 slot。 你会在之后一个或多个 slot 落块,面对已被其它交易改过的状态。preflight 通过,预测不了你的结果。

它可能因为"本来没问题"的状态而失败。 拥堵时,preflight 那一刻和收录那一刻的池子状态不同 —— preflight 会拒掉一笔本来能落块的交易。

// 生产发送路径。
await connection.sendRawTransaction(raw, {
  skipPreflight: true,   // ★在开发阶段模拟,不在热路径上★
  maxRetries: 0,
});

★分工很清楚:★ 开发阶段模拟,用来量计算消耗、抓指令 bug;生产环境跳过 preflight,结果用 getSignatureStatuses 拿。

模拟告诉不了你什么

值得明说,因为有团队会在错误的层面上调试几周。

它会不会上链。 模拟跑的是执行,不是收录。一笔模拟完美的交易,可能因为费用、路由或 blockhash 过期而根本进不了区块 —— 这些模拟一个都碰不到。

成交价会是多少。 模拟一笔 swap,给的是按当前储备算的价格。等你落块时,别人的交易已经把储备推走了。

你会不会赢下这场竞争。 模拟没有"竞争"这个概念。两个机器人模拟同一笔清算,都会得到成功的结果 —— 但只有一个能落块。

★如果模拟通过、交易却始终没出现在链上,那问题在提交,不在执行。★

调试循环里的模拟

交易失败时,这个顺序最省时间:

① 它到底上链了吗?              → getSignatureStatuses
     null → 提交侧问题,模拟帮不上忙
② 上链了但报错                  → 用模拟复现
③ 读最后几行日志                → 找到真正的原因
④ 修复、再模拟、重新提交

第 1 步是最多人跳过的。★对一笔从未上链的交易做模拟,你研究的是一个你并不存在的问题。★

const { value } = await connection.getSignatureStatuses([sig], {
  searchTransactionHistory: true,
});

if (!value[0]) {
  // 从未上链。费用、路由、重试或过期 —— 不是执行问题。
} else if (value[0].err) {
  // 上链了但失败。现在模拟才是对的工具。
}

上链应该是什么水平

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

★"落在比模拟时更晚的 slot"是常态,不是边缘情况。★ 正是这个差距,让"模拟通过"成为对指令的检查,而不是对结果的预测。

BoltTx 在这里的位置

我们做提交,而那正是模拟验证不了的那部分。

提交走我们自建的四区域投递节点,带 SWQoS 路由,不暴露公共 mempool —— 交易在落块之前,传输途中观察不到。你在本地签名,我们不托管资金、不代签、不修改交易内容。

tip 带在交易里、从你自己的钱包链上支付,交易失败时跟着一起 revert —— 这是 Solana 原子交易的机制决定的。只有到达链上的交易才计费。

免费领 API key,没有月费:

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

常见问题

Solana 的 simulateTransaction 是做什么的? 它拿当前链上状态执行你的交易但不提交,返回是否会失败、程序日志、以及消耗的计算单元。它预测的是执行,不是收录。

模拟结果准吗? 量计算消耗准,因为那主要由代码路径决定。判断成功失败只对"模拟那一刻的状态"准 —— 你会落在之后的 slot,而滑点这类依赖行情的结果早就变了。

模拟和 preflight 有什么区别? preflight 是 RPC 在转发前自动跑的模拟。是同一个操作,只是发生在一个"会让你付延迟、而且检查的是你不会落进去的那个 slot"的时点上。

该关掉 preflight 吗? 生产发送通常该关。它花一次往返,而且模拟的是错误的 slot。开发阶段模拟才有意义,真实结果用 getSignatureStatuses 拿。

怎么从模拟里拿到计算单元?sim.value.unitsConsumed。把 compute unit limit 设在它之上、留约 20% 余量,用来覆盖更深的 CPI 链和比模拟时更大的账户状态。

replaceRecentBlockhash 有什么用? 它在模拟时于服务端替换一个新的 blockhash。不加的话,blockhash 已过期的交易会因为这个原因失败,而那和你的指令毫无关系。

什么时候该用 sigVerify: false? 模拟一笔还没签名的交易时 —— 开发阶段的常态。只有当你明确想验证"一笔完整签名的交易格式对不对"时,才设成 true。

为什么我的交易模拟没问题、链上却失败了? 因为模拟和落块之间状态变了。别人的交易推动了池子、消耗了供应、或者改了某个账户。在一条"你总是晚于模拟若干 slot 才落块"的链上,这是预期之内的。

为什么我的交易模拟没问题、却根本没出现在链上? 那是提交问题,不是执行问题。模拟对费用、路由、重试行为、blockhash 过期一个字都不说。先查 getSignatureStatuses

Solana 的模拟日志怎么读? 最后几行通常是真正的原因。找 compute meter 相关的信息、insufficient fundsAccountNotFound、或者 custom program error 的十六进制码,然后去查那个程序的错误枚举。

custom program error 0x1771 是什么意思? 这是程序自定义的。 在好几个 swap 程序里它表示滑点超限,但同一个十六进制值在别的程序里是另一回事。永远去查具体那个程序的错误定义。

能在签名之前模拟交易吗? 能,用 sigVerify: false。这就是正常的开发流程 —— 组装指令、模拟检查逻辑并量计算消耗,满意之后再签名。

模拟要花钱吗? 不花手续费,因为没有东西上链。但它会消耗一次 RPC 调用额度,而且在热发送路径上要付延迟 —— 这正是那里该跳过 preflight 的理由。

生产环境该给每笔交易都做模拟吗? 发送路径上不该。开发阶段模拟,以及调试"上链后失败"的问题时模拟。 每次发送前都模拟,买到的是一个关于错误 slot 的预测,代价是一次往返。

怎么对真实状态测计算消耗? 拿主网账户模拟,别用空的 devnet 夹具。一个状态丰富的池子消耗的计算量明显更多,而这个差别正是让 limit 在生产环境失败的原因。

模拟能告诉我会不会赢下竞争吗? 不能。模拟没有"竞争"的概念。两个机器人模拟同一个机会都会得到成功结果,而只有一个落块。竞争的结果取决于费用、路由和时机。

延伸阅读

返回博客列表