Solana 的 sendTransaction 采用两阶段路径:节点首先根据 preflightCommitment 选定的 bank 对交易进行模拟,然后将交易转发到集群。预检失败会返回 JSON-RPC 错误码,例如 -32002(模拟失败)、-32003(签名验证失败)和 -32005(节点落后),这些错误必须与传输层故障分开处理。由于交易的签名由其消息派生而来,重新发送字节完全相同的已签名交易是幂等的:集群按签名去重,而不是执行两次。正确的提交例程会在每次重发前重新查询 getSignatureStatuses,遇到终态状态时停止,并在原始 blockhash 过期后使用新的 blockhash 重新签名。本文提供参数约定、错误分类、可运行的 Node.js 重试循环,以及用于针对你自己的端点进行测量的结果表。
两阶段 sendTransaction 路径与 preflightCommitment
Solana JSON-RPC 文档中关于 sendTransaction 的描述定义了一条两阶段路径。在第一阶段,接收节点针对某个 bank 对交易进行预检模拟;在第二阶段,只有预检成功,节点才会将交易转发到集群,交由 leader 处理。preflightCommitment 参数选择模拟所针对的 bank,因此在 processed 级别模拟通过的交易,在集群实际使用的 confirmed 或 finalized bank 上仍可能失败。
这一区别是常见生产环境 bug 的根源:调用方为了降低延迟设置 preflightCommitment: "processed",看到预检成功,随后却观察到链上失败。模拟对于它所针对的 bank 是准确的,但该 bank 并不是交易最终落地的那个。对于正确性比几毫秒更重要的提交路径,应将 preflightCommitment 与你打算确认的 commitment 对齐,并在选择前阅读 Solana 承诺级别:processed vs confirmed vs finalized。
预检是节点侧的便利功能,而非共识保证。它能在交易消耗集群资源之前捕获明显失败(缺少签名、lamports 不足、程序错误),但无法预测模拟与执行之间发生的状态变化。应将通过的预检视为过滤器,而非承诺。
- 阶段 1:节点针对 preflightCommitment 选定的 bank 进行模拟。
- 阶段 2:仅当阶段 1 成功时,节点才将交易转发到集群。
- 较低的 preflightCommitment 可能在本地通过,但在真实集群上仍然失败。
- 预检是过滤器,不是共识保证。
使用 curl 检查原始 sendTransaction 错误信封
在编写重试逻辑之前,先查看端点返回的确切 JSON-RPC 错误信封会很有帮助。以下 curl 命令在启用预检的情况下提交一个 base64 编码的已签名交易,并打印原始响应。将 RPC_URL 替换为你的端点,将 BASE64_TX 替换为序列化后的交易。
针对一个故意失败的交易(例如 lamports 不足)运行它,以观察 -32002 的结构,包括携带模拟日志的 data 字段。使用有效交易运行同一命令会显示成功结构:一个包含签名字符串的 result 字段。这就是你的客户端代码必须解析的信封。
- 在编写重试代码之前,使用 curl 捕获原始错误信封。
- 失败的交易会揭示带有日志的 -32002 data 载荷。
- 有效的交易会返回包含签名的 result 字符串。
- 通过 jq 管道检查 data 和 InstructionError 等嵌套字段。
curl -s -X POST "$RPC_URL" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "sendTransaction",
"params": [
"BASE64_TX",
{
"encoding": "base64",
"skipPreflight": false,
"preflightCommitment": "confirmed",
"maxRetries": 0
}
]
}' | jq .生产参数约定:encoding、skipPreflight、preflightCommitment、maxRetries
sendTransaction 参数约定定义了生产环境中重要的四个字段。encoding 控制序列化交易的传输方式(base64 是二进制安全传输的常见选择)。skipPreflight 完全绕过第一阶段。preflightCommitment 选择模拟 bank。maxRetries 是节点侧尽力而为的转发计数器,用于将交易转发给 leaders。
关键的操作要点是,maxRetries 被文档描述为节点侧重试,而非投递保证。节点可能因你无法控制的原因(leader 调度、内部队列限制、节点重启)停止重试,并且它放弃时不会向你报告。你绝不能将 maxRetries 视为自己确认循环的替代品。将其设置为较小值或零,并在客户端中自行掌控重试逻辑,这样你才能观察状态。
skipPreflight: true 仅在你已经自行模拟过交易(例如通过 simulateTransaction)并希望最小化延迟,或你故意让已知良好的交易参与竞速时才适用。对未经验证的交易跳过预检意味着集群会在消耗资源后拒绝它,并且你会收到结构化程度较低的错误。姊妹文章 解码 Solana simulateTransaction 错误 介绍了当你选择带外模拟时的诊断路径。
- encoding:base64 是序列化交易的二进制安全默认值。
- skipPreflight:绕过模拟;仅在你自行模拟后使用。
- preflightCommitment:选择第一阶段模拟的 bank。
- maxRetries:节点侧尽力而为,不是投递保证。
提交时错误分类与分支逻辑
solana.com/docs/rpc 上的 Solana RPC 错误码参考记录了你将遇到的提交时错误码。-32002(交易模拟失败)携带一个 data 载荷,其中包含模拟日志和嵌套的 InstructionError;使用 解码 Solana simulateTransaction 错误 中描述的方法解码该嵌套错误。-32003(交易签名验证失败)意味着交易的签名与消息不匹配,通常表明存在签名 bug 或消息被篡改。-32005(节点落后)意味着节点对集群的视图已过时,你应针对另一个节点重试。
参数错误使用标准 JSON-RPC -32602(Invalid params)错误码,由 JSON-RPC 2.0 规范 定义。这些错误表明请求格式错误(编码错误、缺少字段、base64 无效),不应在不修复请求的情况下重试。JSON-RPC 2.0 信封对所有这些错误都是相同的:一个包含 code、message 和可选 data 的 error 对象。
应根据错误码而非消息字符串进行分支。message 字段是人类可读的,可能因节点实现而异,但数字 code 是稳定的。当 data 存在时,它可能是字符串(模拟日志)或对象(结构化错误);应防御性地处理这两种形式。
- -32002:交易模拟失败;从 data 中解码嵌套的 InstructionError。
- -32003:签名验证失败;修复签名,不要盲目重试。
- -32005:节点落后;针对另一个节点重试。
- -32602:无效参数;修复请求,不要重试。
- 根据数字错误码分支;仅将 message 视为人类可读信息。
幂等重试:以签名作为去重键
Solana 交易的签名由其消息派生而来。因此,重新发送字节完全相同的已签名交易是安全的:集群按签名去重,并将重复项作为已知交易拒绝,而不是执行两次。这与 JSON-RPC 幂等性与重复请求安全 中描述的传输层属性相同,只是应用于 Solana 提交路径。
实际结果是,你的重试循环应在每次重发前重新查询 getSignatureStatuses。如果签名已具有终态状态(confirmed 或 finalized),则停止。如果它仍为 processed 或未知,则重新发送相同字节是安全的。切勿用不同的 blockhash 重新签署同一消息并同时重发两个版本;那会产生两个不同的签名,并带来双重执行的风险。
这就是为什么重试循环必须在多次重发之间保持序列化交易字节不变。对消息的任何修改(包括新的 blockhash)都会改变签名并破坏幂等性。
- 签名由消息派生;相同字节产生相同签名。
- 重复提交会作为已知交易被拒绝,而不会执行两次。
- 每次重发前重新查询 getSignatureStatuses;遇到终态状态即停止。
- 切勿为同一笔逻辑转账重发两个不同的签名。
Blockhash 生命周期作为重试边界
交易的 recent blockhash 仅在有限窗口内有效。Solana 关于交易确认和 blockhash 生命周期的文档(参见 solana.com/docs)说明,一旦 blockhash 过期,交易便无法再被包含,集群将拒绝它。这为你的重试循环设定了边界:你不能永远重发一笔过期的交易。
当 blockhash 过期时,你必须使用新的 blockhash 重新签名。这会产生新的签名,因此幂等性保证重置:在提交新交易之前,你必须先确认旧签名没有落地。安全顺序是查询旧签名的 getSignatureStatuses,仅当它不存在或已过期时,才使用新的 blockhash 构建并签署新交易。
对于无法容忍这一重新签名窗口的工作流,durable nonces 提供了不会过期的 blockhash。相关权衡在 Solana blockhash 过期与 durable nonces 中介绍。
- Blockhash 仅在有限窗口内有效;过期会限制重试。
- 过期时,使用新的 blockhash 重新签名;签名会改变。
- 在提交新交易之前,确认旧签名没有落地。
- Durable nonces 以额外设置为代价消除了过期边界。
可运行的 Node.js 提交示例:预检、错误解析与幂等重试
以下示例在启用预检的情况下提交,解析 JSON-RPC 错误信封,在 -32002 时回退到诊断性的 simulateTransaction,并以退避方式幂等重试,直到获得终态签名状态。它使用 @solana/web3.js 和普通的 fetch 进行原始 RPC 调用,以便错误信封可见。
将 RPC_URL 替换为你的端点。循环在多次重发之间保持序列化交易不变,仅在 blockhash 过期时重新签名。
import { Connection, Keypair, Transaction, SystemProgram, sendAndConfirmTransaction } from '@solana/web3.js';
const RPC_URL = process.env.RPC_URL; // e.g. your OnFinality Solana endpoint
const connection = new Connection(RPC_URL, 'confirmed');
async function rpc(method, params) {
const res = await fetch(RPC_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
});
const json = await res.json();
if (json.error) {
const err = new Error(json.error.message);
err.code = json.error.code;
err.data = json.error.data;
throw err;
}
return json.result;
}
async function submitWithRetry(signedTx, maxAttempts = 8) {
const raw = signedTx.serialize().toString('base64');
const signature = signedTx.signatures[0].signature.toString('base64');
let attempt = 0;
let backoff = 500;
while (attempt < maxAttempts) {
attempt++;
// 1. Check terminal status before resending.
const statuses = await rpc('getSignatureStatuses', [[signature], { searchTransactionHistory: true }]);
const status = statuses.value[0];
if (status && (status.confirmationStatus === 'confirmed' || status.confirmationStatus === 'finalized')) {
return { signature, status: status.confirmationStatus };
}
try {
// 2. Submit with preflight on.
const sig = await rpc('sendTransaction', [raw, { encoding: 'base64', skipPreflight: false, preflightCommitment: 'confirmed', maxRetries: 0 }]);
console.log('submitted', sig, 'attempt', attempt);
} catch (e) {
if (e.code === -32002) {
// 3. Diagnostic simulateTransaction fallback.
const sim = await rpc('simulateTransaction', [raw, { encoding: 'base64', commitment: 'confirmed' }]);
console.error('preflight failed; simulation logs:', sim.value.logs);
throw new Error('simulation failed: ' + JSON.stringify(sim.value.err));
}
if (e.code === -32003) throw new Error('signature verification failed; fix signing');
if (e.code === -32005) { /* node behind: fall through to backoff */ }
if (e.code === -32602) throw new Error('invalid params: ' + e.message);
// transport or unknown error: back off and retry
}
await new Promise(r => setTimeout(r, backoff));
backoff = Math.min(backoff * 2, 8000);
}
throw new Error('exhausted retries without terminal status');
}
// Usage: build, sign, then submit.
const payer = Keypair.generate();
const tx = new Transaction().add(SystemProgram.transfer({ fromPubkey: payer.publicKey, toPubkey: payer.publicKey, lamports: 1 }));
tx.recentBlockhash = (await connection.getLatestBlockhash('confirmed')).blockhash;
tx.feePayer = payer.publicKey;
tx.sign(payer);
submitWithRetry(tx).then(console.log).catch(console.error);结果表:针对你的端点测量预检与重试行为
提供商行为各不相同,因此应针对你自己的端点进行测量,而不是相信通用数字。针对一笔已知良好的交易运行上述提交循环,并记录每次尝试的以下字段。用你自己的观察结果填写表格;不要将任何已发布的数字视为你自己测量的替代品。
目标是刻画你端点的预检延迟、错误分布和重试收敛情况,以便设置符合实际的退避和尝试次数上限。
- 尝试次数
- sendTransaction 墙钟延迟(毫秒)
- 返回的错误码(如有)
- 检查时 getSignatureStatuses 的 confirmationStatus
- 下次尝试前应用的退避(毫秒)
- 是否达到终态状态(confirmed/finalized)及总尝试次数
故障模式与排查清单
大多数提交失败都属于少数几种模式。按顺序逐项检查清单;每一项都隔离两阶段路径的不同层面。
如果预检通过但交易始终未确认,很可能是 blockhash 在 leader 将其包含之前已过期。如果预检以 -32002 失败,嵌套的 InstructionError 会告诉你哪条指令、哪个程序失败;在更改其他任何内容之前先解码它。如果你看到 -32005,说明节点落后,应故障转移到另一个端点,而不是对同一节点重试。
- 预检通过但未确认:检查 blockhash 过期并重新签名。
- -32002:解码嵌套的 InstructionError;不要原样重试。
- -32003:验证消息在签名后未被篡改。
- -32005:节点落后;故障转移到健康端点。
- -32602:修复编码或参数结构;不要重试。
- 重复签名被拒绝:符合预期;查询状态而非重发。
- 重试循环永不终止:确认终态状态检查是否正确。
激进重试与 skipPreflight 的权衡与局限
激进重试会放大节点和集群的负载。由于重复提交按签名去重,它们不会双重执行,但仍会消耗 RPC 容量,并可能挤占其他调用方。使用带上限的指数退避,并在观察到终态状态后立即停止。
skipPreflight: true 降低延迟,但移除了节点侧过滤器,因此格式错误或失败的交易会到达集群并返回结构化程度较低的错误。仅将其保留给你已经模拟过的交易。提供商侧行为也各不相同:某些端点会施加自己的速率限制、排队或预检默认值,因此相同参数在不同提供商处的行为可能不同。文档描述的行为是基线;请针对你的端点进行验证。
最后,maxRetries 是节点侧尽力而为,不应依赖其完成投递。在客户端中自行掌控重试循环,这样你才能观察状态并应用退避。有关更广泛的超时与重试策略,请参阅 Solana RPC 超时与重试策略。
- 重试会放大负载;使用带上限的指数退避。
- skipPreflight 移除节点侧过滤器;仅在模拟后使用。
- 提供商侧默认值和速率限制各不相同;请针对你的端点验证。
- maxRetries 是尽力而为;在客户端中自行掌控循环。
后续步骤:端点、定价与相关指南
要将其投入生产,请选择符合你确认和重试要求的端点。Solana 网络页面 介绍了可用的 Solana RPC 端点,RPC 定价 涵盖了影响吞吐量和速率限制的套餐级别差异。如果你在 API 层进行集成,API 服务 概览解释了托管访问的结构。
如需方法级参考,Solana RPC API 指南(RPC Assistant) 记录了完整的方法面。要深入了解诊断路径,请阅读 解码 Solana simulateTransaction 错误;有关过期处理,请阅读 Solana blockhash 过期与 durable nonces;有关传输层幂等性,请阅读 JSON-RPC 幂等性与重复请求安全。浏览 OnFinality Learn 中心 获取完整的故障排查系列。
- 选择符合你确认和重试需求的端点。
- 查看定价以了解吞吐量和速率限制差异。
- 使用 RPC Assistant 指南进行方法级参考。
- 继续阅读 simulateTransaction、blockhash 和幂等性指南。