防止 Solana 交易重复执行

重发安全,重建不安全 —— 这条线决定了所有幂等设计,以及当你不得不重建时守卫该放在哪。

BoltTx Team··13 min read
solana幂等重复交易上链后端可靠性

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: "已被别处认领" };

★先 SELECTINSERT 不是守卫。★ 两个实例都可能在任何一个写入之前读到"未执行"。 必须由唯一性约束、在一条语句里完成这件事。

这也是**多地区机器人部署需要"一个地区决策、多个地区提交同一份已签名字节"**的原因 —— 各自独立构建的交易签名不同,两笔都可能落块

上链应该是什么水平

我们投递节点上的真实交易:中位确认 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 是为延迟提交而存在的,同时也起到一次性守卫的作用。

我的机器人在两个地区跑,会都执行同一笔交易吗? 如果各自构建交易,会。让一个地区决策,把同一份已签名字节扇出到多个提交点 —— 相同字节到哪里发都安全。

发送之后响应丢了怎么办? 那笔交易可能已经成功提交了。在假设失败之前先查签名状态,因为"假设失败然后重建"正是通往重复执行的经典路径。

签名该在发送前还是发送后记录? 之前。如果只在成功之后才记录,"发送完成、记录之前"崩溃会留下一笔已落块却标记为未完成的交易,重启时又发一次。

数据库事务足以防止重复吗? 它能防止你自己的两次写入,防不了链上的两次执行。交易一旦提交,只有签名或链上检查才能决定它会不会跑两次。

延伸阅读

返回博客列表