大多数 Solana 教程把提交讲成一步:调 sendTransaction,交易进网络,完事。
这个模型在你需要"让提交更快、更可靠"之前都够用。一旦有了这个需求,它就什么都解释不了了 —— 因为"网络"其实是好几段不同的路,每一段有自己的失败模式,而且只有一部分归你管。
下面讲清楚从你的进程到区块之间实际发生了什么,以及哪几段你能动手。
逐跳拆解
第一跳:本地构造与签名
这时候还没碰到网络。取 blockhash、组装指令、签名。
签名在这一步结束时就已经存在了 —— 用交易字节和你的密钥在本地算出来。这就是为什么"拿到签名"完全不能说明交易上没上链:它在提交之前就已经确定了。
你能控制什么: 全部。这一跳是纯本地计算。
这里会出什么问题: 用了过期的 blockhash、没给 priority fee、compute unit limit 用默认值。这三件事都在这一跳决定,后面补不回来。
第二跳:你的进程到 RPC
一次网络往返。你的字节发往你配置的那个端点。
你能控制什么: 端点在哪,以及连接复不复用。
连接复用的影响比大多数人以为的大。每笔交易都新建 TLS 连接,意味着在字节真正开始传输之前要先完成一次完整握手。而在复用的连接上这笔开销直接消失 —— 热连接上 HTTPS 的成本和纯 HTTP 基本一样。对持续发送的机器人来说,这是本来白付、其实可以省掉的延迟。
import { Agent } from "undici";
// 让连接在多次发送之间保持热的,而不是每次都重新握手。
const agent = new Agent({
keepAliveTimeout: 60_000,
connections: 8,
});
这里会出什么问题: 每次发送都是冷连接;或者端点在地球另一边。
第三跳:RPC 向出块方转发
交易已经不在你手里了。RPC 节点把它转发给接下来负责出块的验证节点。
你能控制什么: 直接控制不了 —— 但你选哪家服务商,在你发出去之前就已经决定了这一跳的表现。
SWQoS 在这里起作用。验证节点按转发方的质押权重来接收转发过来的交易。质押权重低的服务商,它的流量被接受的比例也低,而这个比例只在区块空间被抢的时候才有意义。
这里会出什么问题: 拥堵时被降级。网络清闲时完全看不出来。
第四跳:被收录
排到的验证节点决定收不收你这笔交易。没有任何队列会替你保留它稍后再试 —— Solana 没有公共 mempool。没被收录、而你又停止重发,它就此消失。
你能控制什么: 下一个区块产出时,你是否还在重发。
如果你已经理解这条路、想换掉第三跳,免费领个 BoltTx key 改一行就行。
"已提交"和"已上链"为什么是两个词
这三个状态值得精确命名,因为把它们混在一起,是这个领域里代价最高的错误:
已提交 —— RPC 收下了你的字节并返回签名。 已上链 —— 交易进了区块,有 slot 号。 已成功 —— 上链了,而且指令执行没报错。
一笔交易可能提交了但从未上链;也可能上链了但失败了。你的监控需要三个计数器,不是一个布尔值:
const { value } = await connection.getSignatureStatuses([signature], {
searchTransactionHistory: true,
});
if (!value[0]) metrics.increment("never_landed");
else if (value[0].err) metrics.increment("landed_failed");
else metrics.increment("landed_ok");
never_landed 在动,是提交侧问题:费用、路径、重试、过期。
landed_failed 在动,是程序侧问题:滑点、余额、账户状态。
这两者没有关系。修好一个对另一个毫无帮助,而一个笼统的"成功率"会把你到底遇到哪一种给盖住。
怎么把提交做对
生产环境能用的写法并不复杂,但和教程版有三处具体的不同。
import { Connection, VersionedTransaction } from "@solana/web3.js";
async function submit(
connection: Connection,
tx: VersionedTransaction,
lastValidBlockHeight: number,
): Promise<string | null> {
const raw = tx.serialize();
const signature = await connection.sendRawTransaction(raw, {
skipPreflight: true,
maxRetries: 0,
});
while (true) {
const height = await connection.getBlockHeight("confirmed");
if (height > lastValidBlockHeight) return null; // 已过期
const { value } = await connection.getSignatureStatuses([signature]);
if (value[0]) return signature; // 已上链,看 .err 判断结果
await connection.sendRawTransaction(raw, {
skipPreflight: true,
maxRetries: 0,
});
await new Promise((r) => setTimeout(r, 400));
}
}
skipPreflight: true。 preflight 拿当前 slot 模拟你这笔交易,要花一次网络往返。但你不会落在当前 slot —— 你会落在之后某个 slot、面对不同的状态。所以 preflight 可能通过了却什么都没告诉你,同时花掉了你正需要的延迟。真正有用的模拟应该放在开发阶段。
maxRetries: 0。 RPC 自带的重试按一套你观察不到的节奏跑。如果你也在重试,就是两个组件按不同时钟重发。挑一个 —— 挑你自己的,因为只有你知道 blockhash 什么时候过期。
重发到过期为止,然后停。 重发相同字节是安全的:签名一样,最多被收录一次。但一旦 lastValidBlockHeight 越过去,那份字节就永久作废了,继续发毫无意义 —— 你需要的是用新 blockhash 构造一笔新的。
给每一跳埋点
如果提交比你想要的慢、或者不够稳,分跳测量能告诉你该去看哪里。大多数团队只测总耗时,然后只能靠猜。
const t0 = performance.now();
const { blockhash, lastValidBlockHeight } =
await connection.getLatestBlockhash("confirmed");
const t1 = performance.now();
const tx = buildAndSign(blockhash);
const t2 = performance.now();
const signature = await connection.sendRawTransaction(tx.serialize(), {
skipPreflight: true,
maxRetries: 0,
});
const t3 = performance.now();
log({
blockhashFetch: t1 - t0, // 第二跳,入站
buildAndSign: t2 - t1, // 第一跳
submitCall: t3 - t2, // 第二跳,出站
blockhashAge: t3 - t1, // 你已经花掉了窗口的多少
});
blockhashAge 是几乎没人跟踪、但最该跟踪的一个:它是交易还没到 RPC 就已经消耗掉的那部分有效期。这个值大的话,你真实的重试窗口比你以为的短很多。
路的另一头是什么样子
我们投递节点上的真实交易:中位确认 336 毫秒 —— 不到一个 slot。
有两点值得带走。
绝大多数交易在两个 slot 内进区块。 如果一个策略假设"提交和收录发生在同一个 slot",它会落空。按两个 slot 规划。
拥堵是把差距拉开,不是整体平移。 大部分交易不受影响,少数会变慢;而提交路径的质量差异,恰好暴露在这少数里 —— 它们又恰好集中在机会出现的时刻。
哪些你能修,哪些不能
按"归你管的程度"排:
| 跳 | 归你管吗 | 可调的东西 |
|---|---|---|
| 构造与签名 | 完全 | blockhash 新鲜度、priority fee、compute limit |
| 进程到 RPC | 大部分 | 连接复用、端点位置 |
| RPC 到验证节点 | 不 | 选哪家服务商 |
| 被收录 | 间接 | 那一刻你还在不在重发 |
这张表真正有用的地方在于排查顺序:从上往下走。上面几行都很便宜,而且能解释掉大部分问题。只有当它们全都干净时,第三行才成为答案 —— 而那一行恰恰是你在代码里改不了的。
BoltTx 在这里的位置
我们做的就是第三行。
BoltTx 接收签好名的交易,把它们送进区块。不做索引,不做解析历史,不做 NFT 元数据。提交走我们自建的四区域投递节点,带 SWQoS 路由,不暴露公共 mempool —— 交易在落块之前没有人能观察到。
私钥始终在你手里。我们不托管资金、不代签、不修改交易内容。定价也是同一套逻辑:你在交易里带一笔 tip,从自己的钱包链上支付;交易 revert 的话 tip 跟着一起 revert —— 这是 Solana 原子交易的机制决定的。只有到达链上的交易才计费。
免费领 API key,没有月费,没有订阅:
// 选离你机器人最近的区域
const connection = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");
然后测你自己在拥堵时段的上链率,拿数字对比。只有那个数字算数。
常见问题
Solana 的交易提交指什么? 把一笔签好名的交易从你的代码送进区块的整个过程。它跨越四跳:本地构造签名、到 RPC 的网络往返、RPC 向出块方转发、验证节点决定收不收。只有前两跳完全归你控制。
调用 sendTransaction 之后发生了什么? 你的字节发往 RPC,RPC 把它转发给接下来负责出块的验证节点。被收录就是上链;没被收录,不会有人替你重试 —— Solana 没有公共 mempool,没进区块的交易就此消失。
为什么交易还没提交就已经有签名了?
签名是用交易字节和你的密钥在本地算出来的,签完就存在,不需要碰网络。所以返回签名不能作为上链的证据,要用 getSignatureStatuses 去确认。
一笔 Solana 交易通常要几个 slot 才能落块? 绝大多数交易在两个 slot 内落块。按这个规划,不要假设下一个区块就能进去。
该用 sendTransaction 还是 sendRawTransaction?
如果你已经序列化并签好名了,用 sendRawTransaction —— 机器人场景基本都是这种。sendTransaction 是帮你签名的便捷封装。写重试循环时,序列化一次然后重发同一份字节,不要每次重新构造。
连接复用对提交延迟真的有影响吗? 有。新建 TLS 连接要先完成完整握手,你的交易字节才开始传。复用连接上这笔开销就没了,HTTPS 和纯 HTTP 成本基本一致。对持续发送的机器人来说,这是每一笔都在白付的延迟。
SWQoS 是什么,它怎么影响提交? stake-weighted quality of service。验证节点按转发方的质押权重来接收转发过来的交易。区块空间充裕时它是隐形的;一旦被抢,从低质押路径过来的交易就会被降级 —— 恰好在你最不希望被降级的时候。
为什么生产环境推荐开 skipPreflight? preflight 是拿当前 slot 做模拟,而你会落在之后的 slot、面对不同状态,所以模拟通过并不能预测你的结果;它还要花一次网络往返。真正有用的模拟应该放在开发阶段,热路径上跳过。
提交延迟该怎么正确测量? 分跳测,不要只测端到端:取 blockhash、构造签名、提交调用,各自单独计时。另外要跟踪提交时的 blockhash 已用时长 —— 也就是有效期里已经被消耗掉的部分。大多数团队只测总耗时,结果不知道该修哪一跳。
能不能把同一笔交易提交给多个端点? 技术上可以,因为同一个签名最多被收录一次。但每一份在某处落块的副本你都要付一次基础费用,而且会让自己的对账变复杂,收益却很有限。把前面四个常见原因修好,通常比多发几份效果更好。
反复重发同一笔交易,会不会付两次钱? 不会。一个签名最多被收录一次,所以重发相同字节是安全的,这正是正确的重试姿势。不安全的是每次重试都重新构造交易 —— 那样每次都会产出一个不同的签名。
提交给多个端点能提高收录概率吗? 同一个签名最多被收录一次,所以技术上是安全的。但落块的那一份要付基础费,而且会让对账变复杂 —— 相比修好费用和路由,收益很小。
怎么知道是哪一跳慢? 分别埋点:取 blockhash、构造签名、提交调用。大多数团队只测端到端,结果不知道该修哪一段。
提交时该用哪个承诺级别?
确认循环用 processed,因为等 confirmed 会花掉一个以上 slot。任何要记成真相的东西用 confirmed 或更高。
为什么我的交易到了 RPC、却没到验证节点? 转发那一跳从你这边是看不见的。拥堵时,验证节点按转发方的质押权重来接收转发过来的交易 —— 所以低质押路径恰好在区块空间紧张时被降级。