Solana 白送你一层防重放,而它保护的范围比大多数人以为的小。
★一个签名最多被收录一次。这就是全部的保证 —— 而它保证的是字节,不是意图。★
决定一切的那条线
// ★安全。相同字节、相同签名,最多被收录一次。★
await connection.sendRawTransaction(raw);
await connection.sendRawTransaction(raw);
await connection.sendRawTransaction(raw);
// ★不安全。新 blockhash → 新签名 → 第二次执行。★
const rebuilt = buildTransaction(intent, await freshBlockhash());
await connection.sendRawTransaction(rebuilt.serialize());
两个看起来都是"重试"。只有一个真的是。
★Solana 上每一个重复执行的 bug,都活在这条线的错误一侧。★ 重发序列化字节的重试循环不可能双花。而重新构建交易的重试循环可以,并且一定会 —— 就在某次提交落了块却没被观察到的第一时刻。
危险的时序 —— 注意 sendRawTransaction 抛错,并不代表字节没有到达:
发出 交易A → 网络抖动,响应丢了
你假设它失败了 → 重建成 交易B
交易A 落块 ← ★你从没看见它★
交易B 落块 ← ★执行了两次★
重建之前先把状态确定下来
让重建变安全的规则:在弄清上一个签名的结局之前,绝不重建。
async function safeRetry(intent, lastSig, lastValidBlockHeight) {
if (lastSig) {
const { value } = await connection.getSignatureStatuses([lastSig]);
const st = value[0];
if (st && !st.err) return { status: "already_done", sig: lastSig };
if (st && st.err) return { status: "reverted", err: st.err };
// ★状态为 null:只有窗口关闭之后才能安全重建。★
const height = await connection.getBlockHeight();
if (height <= lastValidBlockHeight) {
return { status: "still_pending" }; // ★继续重发,不要重建★
}
}
return { status: "rebuild_ok" };
}
★中间那个分支是最常被跳过的。★ 有效窗口内的 null 状态意味着"还没",不是"永远不会"。 在那里重建,正是你最终手握两笔表达同一意图的活交易的方式。
如果你的重试逻辑没问题、交易还是在过期,免费领个 BoltTx key 改一行就能测测提交路径。
按意图去重,不是按签名
当同一个决策被做了两次时,签名层面的保护帮不上忙,因为每一次决策都产出不同的字节。
// ★用户连点两下。两个意图、两个签名、两次执行。★
const key = hashIntent({ userId, action, mint, amount, windowMinute });
if (await recentlyExecuted(key)) {
return { skipped: "重复意图" };
}
await markExecuting(key);
★时间窗口是这个键的一部分。★ 没有它,一个确实想买同一个代币两次的用户会被挡住;窗口太粗,一次快速重复又会被放行。按分钟分桶是常见的折中,而正确的值取决于"一次真实的重复"多快就合理。
最要紧的场景: Telegram 机器人(连点)、AI agent(重试循环和重新提示)、webhook(至少一次投递),以及任何带自动重试的队列。
动到资金时,把守卫放到链上
客户端去重要和你自己的重试竞争、和一次重启竞争、还要和你服务的另一个实例竞争。
★链上检查不存在竞争。★
// 程序拒绝第二次执行。
require!(!position.settled, ErrorCode::AlreadySettled);
position.settled = true;
三种表达方式:
一个状态标志。 简单,对"结算一个仓位"这类一次性操作足够。
一个序号。 指令携带期待的 nonce,程序拒绝任何对不上的。
一个按 epoch 或按周期的标记。 给那些"每个周期该发生一次"而不是"永远只发生一次"的动作。
★凡是动用户资金的,多花那一个账户都值。★ 每一种客户端守卫都存在"两个进程都通过了检查"的失败模式;链上守卫没有。
天生幂等的构造
有些操作不需要守卫,因为重复做它们什么都不改变:
// ★账户存不存在都成功。★
createAssociatedTokenAccountIdempotentInstruction(payer, ata, owner, mint)
★只要存在幂等版本,就优先用它。★ 非幂等版本在账户已存在时会失败,而这会让整个原子交易 revert —— 于是在重试时,一个此前成功的步骤,变成了整件事失败的原因。
值得留意的通用形状:一条断言"期望的终态"而不是执行"一个增量"的指令。 "确保这个账户存在"可以安全重试,"创建这个账户"不行。
多实例部署
两个进程跑同样的逻辑,是精心设计的单进程幂等失效的地方。
// ★原子地认领,而不是先查再写。★
const claimed = await db.query(
`INSERT INTO executions (key, status)
VALUES ($1, 'running')
ON CONFLICT (key) DO NOTHING
RETURNING key`,
[intentKey],
);
if (!claimed.rowCount) return { skipped: "已被别处认领" };
★先 SELECT 再 INSERT 不是守卫。★ 两个实例都可能在任何一个写入之前读到"未执行"。 必须由唯一性约束、在一条语句里完成这件事。
这也是**多地区机器人部署需要"一个地区决策、多个地区提交同一份已签名字节"**的原因 —— 各自独立构建的交易签名不同,两笔都可能落块。
上链应该是什么水平
我们投递节点上的真实交易:中位确认 336 毫秒 —— 不到一个 slot。
★可预测的落块,减少的是你面对那个含糊情况的次数。★ 大多数重复执行事故,都始于一笔命运不明的交易 —— 不明得足够久,久到有人决定去重建它。
BoltTx 在这里的位置
我们负责提交。幂等设计留在你的代码和你的程序里。
提交走我们自建的四区域投递节点,带 SWQoS 路由,不暴露公共 mempool —— 交易在落块之前、传输途中观察不到。
你在本地签名。★我们不修改交易内容,所以通过我们重发的字节产出同一个签名,不可能执行两次。★ 我们不托管资金、不代签。tip 带在交易里、从你自己的钱包链上支付,交易失败时跟着一起 revert —— 这是 Solana 原子交易的机制决定的。只有到达链上的交易才计费。
免费领 API key,没有月费:
const connection = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");
常见问题
一笔 Solana 交易会执行两次吗? 同一笔已签名的交易不会 —— 一个签名最多被收录一次。但重建出来的交易签名不同,即便原来那笔已经落块,它照样能执行。
重发同一笔 Solana 交易安全吗? 安全。相同字节产出相同签名,所以持续重发到 blockhash 过期为止是标准做法,不可能重复执行。
重建交易为什么危险? 新的 blockhash 会产出新签名,Solana 把它当成一笔完全独立的交易。如果原来那笔落块了而你没看见,两笔都会执行。
什么时候重建才安全? 只有在弄清上一个签名的结局之后。如果状态是 null 但 blockhash 仍然有效,就继续重发 —— 在那里重建正是重复产生的方式。
怎么让重试幂等? 重发序列化字节,而不是重新构建。 当重建不可避免时,先把上一个签名的状态确定下来;而凡是动资金的,把真正的守卫放到链上。
怎么防止用户连点两下买了两次? 在构建任何东西之前,按意图哈希在一个时间窗口内去重。签名层面的保护帮不上忙,因为两次点击产出两笔不同的交易。
意图去重的键该包含什么? 用户、动作、参数,以及一个时间桶。没有时间桶,一次合理的重复会被挡住;桶太粗,一次快速重复会溜过去。
多实例之间怎么防重复? 在共享存储里做一次原子认领,比如带唯一性约束的插入。先读后写不是守卫,因为两个实例可能都先读到同样的状态。
怎么在链上强制幂等? 在程序里检查一个状态标志、一个序号,或者一个按周期的标记。不同于客户端检查,链上守卫不会被两个进程抢跑。
什么是幂等指令?
一条断言期望终态、而不是执行增量的指令。createAssociatedTokenAccountIdempotentInstruction 账户存不存在都成功,所以重试是安全的。
我的重试为什么报"账户已存在"? 你用了非幂等的创建指令。在重试时,那个此前成功的创建,变成了整个原子交易 revert 的原因。
Solana 有像以太坊那样的 nonce 吗? 普通交易没有 —— 防重放来自 blockhash 和签名。durable nonce 是为延迟提交而存在的,同时也起到一次性守卫的作用。
我的机器人在两个地区跑,会都执行同一笔交易吗? 如果各自构建交易,会。让一个地区决策,把同一份已签名字节扇出到多个提交点 —— 相同字节到哪里发都安全。
发送之后响应丢了怎么办? 那笔交易可能已经成功提交了。在假设失败之前先查签名状态,因为"假设失败然后重建"正是通往重复执行的经典路径。
签名该在发送前还是发送后记录? 之前。如果只在成功之后才记录,"发送完成、记录之前"崩溃会留下一笔已落块却标记为未完成的交易,重启时又发一次。
数据库事务足以防止重复吗? 它能防止你自己的两次写入,防不了链上的两次执行。交易一旦提交,只有签名或链上检查才能决定它会不会跑两次。
延伸阅读
- Solana 交易一直 pending 到底意味着什么
- Solana 交易重试模式
- Solana 空投分发
- Solana 交易机器人该部署在哪
- Solana 交易上链完全指南