首页
/ fuels-ts 失败交易安全重提交指南:厘清“提交”与“处理”两个阶段,规避 UTXO 重放错误

fuels-ts 失败交易安全重提交指南:厘清“提交”与“处理”两个阶段,规避 UTXO 重放错误

2026-09-08 22:15:30作者:柯茵沙

本文依据 apps/docs/src/guide/cookbook/resubmitting-failed-transactions.md 编写,并结合 fuels-ts 仓库源码对其中的机制与代码进行纵深展开。

在构建链上应用时,交易并不总是“发出去就成功”。有些场景下,你需要为发送失败的交易实现一套重提交(resubmit)方案:比如批量转账工具因网络抖动提交失败后需要重试,或 DApp 中用户发起的合约调用因 OutOfGas 被回滚后需要友好地补偿与重放。这篇指南将帮助你彻底理解 fuels-ts 中“交易被网络接受”和“交易被成功处理”的区别,掌握何时可以安全重试、何时必须重建交易请求,并最终写出可靠的失败重提交逻辑。

一、一切从两个阶段开始:提交(Submission)与处理(Processing)

文档的核心观点是:Fuel 网络上的一笔交易要经历两个彼此独立的阶段,绝不能把“提交成功”误认为“执行成功”

  • 提交(Submission):把签名后的交易请求发送给节点,让网络把它放进待处理队列;
  • 处理(Processing):交易被区块打包后由 Fuel VM 真正执行并产生结果。

在 fuels-ts 中,Wallet.sendTransactionTransactionResponse.waitForResult 分别对应这两个阶段。原文档与配套示例位于 apps/docs/src/guide/cookbook/snippets/resubmitting-failed-transactions/submitting.ts,核心代码如下:

import { Provider, Wallet } from 'fuels';

import { LOCAL_NETWORK_URL, WALLET_PVT_KEY } from '../../../../env';

const provider = new Provider(LOCAL_NETWORK_URL);
const wallet = Wallet.fromPrivateKey(WALLET_PVT_KEY, provider);

const baseAssetId = await provider.getBaseAssetId();

const transferAmount = 1000;

const transactionRequest = await wallet.createTransfer(
  wallet.address,
  transferAmount,
  baseAssetId
);

// 阶段一:提交 —— 返回 TransactionResponse
const response = await wallet.sendTransaction(transactionRequest);

// 阶段二:等待处理结果
const result = await response.waitForResult();

console.log('success', result.isStatusSuccess);

说明:示例中的 LOCAL_NETWORK_URLWALLET_PVT_KEY 来自本仓库文档工程的环境模块 apps/docs/src/env.ts。在自己项目中使用时,请替换为你的 Provider 地址与私钥(推荐通过环境变量注入,切勿硬编码)。

1. sendTransaction 返回什么

sendTransaction 位于账户层,例如 packages/account/src/account.ts#L1117-L1120

async sendTransaction(
  transactionRequestLike: TransactionRequestLike,
  { estimateTxDependencies = true, ...connectorOptions }: AccountSendTxParams = {}
): Promise<TransactionResponse>

它返回的是 TransactionResponse,而非执行结果。只要该方法成功 resolve,就说明交易已被网络接受并进入处理队列——仅此而已,并不保证这笔交易最终执行成功。

2. waitForResult 才是“定音锤”

TransactionResponse.waitForResult 的实现位于 packages/account/src/providers/transaction-response/transaction-response.ts#L607-L613

async waitForResult<TTransactionType = void>(
  contractsAbiMap?: AbiMap
): Promise<TransactionResult<TTransactionType>> {
  await this.waitForConfirmationStatuses();
  this.unsetResourceCache();
  return this.assembleResult<TTransactionType>(contractsAbiMap);
}

从源码可见,它会先等待交易进入确认状态(waitForConfirmationStatuses),随后清除与该交易关联的资源缓存unsetResourceCache),最后把链上状态、收据(receipts)等组装成 TransactionResult 返回。TransactionResult 携带 isStatusSuccess 等字段,仓库的测试中也大量使用该字段断言结果,例如 packages/account/src/account.test.ts#L454-L456

const response1 = await sender.batchTransfer(transferConfig);
const { isStatusSuccess } = await response1.waitForResult();
expect(isStatusSuccess).toBeTruthy();

3. 失败语义对照

结合文档,两个阶段“谁报错”决定了交易的真实处境:

失败发生点 含义 是否能对同一请求重试
sendTransaction reject 交易未被网络接受,也不会被处理 可以;属于网络/提交层问题,请求本身通常没有“占用”链上资源
sendTransaction resolve,但 waitForResult reject 交易已被接受并进入处理,但执行期间被回滚(reverted) 不能直接重发同一请求(原因见下一节)

二、处理失败时资源去了哪里:为什么不能“原样重发”

原文档特别提醒了一个容易被忽视的经济学事实:如果交易在处理阶段被回滚,Fuel VM 依然会消耗该交易已注资(funded)的资源,用以支付失败点之前产生的 gas 费用。在扣除 gas 成本后,剩余的资金会被放入一笔新的、归属同一所有者(owner)的 UTXO(未花费交易输出)

这带来一个关键推论:原交易请求中引用的那批“输入”UTXO 已经被花费——即便交易失败,这笔钱的“新形态”也已经变化(以找零形式回到一个全新 UTXO 中)。因此,试图原样重发同一笔交易请求,几乎必然失败,因为请求里指向的初始资源已不存在。

错误示范位于 apps/docs/src/guide/cookbook/snippets/resubmitting-failed-transactions/wrong-resubmission.ts。该示例故意把 gasLimit 设为 0,以触发 OutOfGas 回滚,然后试图“把 gas 调大后原样重发”:

import type { FuelError } from 'fuels';
import { bn, Provider, Wallet } from 'fuels';

const transactionRequest = await wallet.createTransfer(
  wallet.address,
  transferAmount,
  baseAssetId
);

// Set the gasLimit to 0 to force revert with OutOfGas error
transactionRequest.gasLimit = bn(0);

// 交易会被成功提交(注意:提交成功 ≠ 处理成功)
const response = await wallet.sendTransaction(transactionRequest);

try {
  await response.waitForResult();
} catch (error) {
  if (/OutOfGas/.test((<FuelError>error).message)) {
    transactionRequest.gasLimit = bn(1000);

    // 直接重发"同一请求"会失败
    await wallet.sendTransaction(transactionRequest).catch((error2) => {
      console.log('error2', error2);
    });
  }
}

正如原文档所述,上面这段重发会得到如下来自网络的错误:

FuelError: Transaction is not inserted. UTXO does not exist: {{utxoId}}

这条报错清晰说明:该请求引用的 UTXO 已经不存在。也就是说,燃料(coin)资源在首次处理时已被消耗殆尽,剩余资金以找零形式回到了别的新 UTXO 上,因此节点拒绝插入这比“重复引用已花费资源”的交易。

三、正确姿势:从零重组请求后再重试

安全的重试方式是:完全丢弃旧的 TransactionRequest 对象,从“零”重新组装一笔请求,再提交新请求。因为每次调用 wallet.createTransfer(或其它构建方法)时,钱包都会基于链上当前可用的 UTXO 集合重新选取输入、重新计算找零——新的请求自然指向那笔新的“剩余资金 UTXO”。

正确示例位于 apps/docs/src/guide/cookbook/snippets/resubmitting-failed-transactions/right-resubmission.ts

import type { FuelError } from 'fuels';
import { bn, Provider, Wallet } from 'fuels';

const transactionRequest = await wallet.createTransfer(
  wallet.address,
  transferAmount,
  baseAssetId
);

// 仍然先用 gasLimit = 0 人为制造一次 OutOfGas 回滚
transactionRequest.gasLimit = bn(0);
const response = await wallet.sendTransaction(transactionRequest);

try {
  await response.waitForResult();
} catch (error) {
  if (/OutOfGas/.test((<FuelError>error).message)) {
    // 关键点:重新 createTransfer,得到一笔全新的请求
    const transactionRequest2 = await wallet.createTransfer(
      wallet.address,
      transferAmount,
      baseAssetId
    );

    // 新的请求会引用"回滚后剩余资金生成的新 UTXO",因此能够成功
    await wallet.sendTransaction(transactionRequest2);
  }
}

对比两段代码可以看出唯一的本质区别:错误做法修改的是同一对象的字段(transactionRequest.gasLimit = bn(1000)),它的输入集合仍指向已被消耗的 UTXO;正确做法是调用 createTransfer 重新构建对象,让 SDK 从最新可用资源中重新装配交易。

四、源码级印证:资源缓存与重试的底层机制

为什么 SDK 能保证重新 createTransfer 会选取到新 UTXO?结合源码可以从两个方面佐证:

  1. 资源选取发生在请求构建期createTransfer 等接口在构建 TransactionRequest 时会向 Provider 查询并选取可花费的 coin 资源。被回滚的交易已经把旧 UTXO 花费掉,链上余额的最新形态只能通过再次查询获得——这正是“重建请求”有效的根本原因。
  2. 处理完成后 SDK 会主动释放资源缓存:回到 transaction-response.tswaitForResult,其中调用的 unsetResourceCache 实现为:
private unsetResourceCache() {
  this.provider.cache?.unset(this.id);
}

即交易确认完成后,SDK 会清除 Provider 资源缓存中与该交易 id 相关的条目,避免后续构建新交易时误用已被花费的资源。这也从侧面说明:一旦交易进入处理流程,SDK 自身就会把相关资源视为“已消耗”,需要重新选取。

五、实践建议与注意事项

基于原文档与上述机制,在实现重提交逻辑时可遵循以下原则:

  1. 对失败点做二分处理:捕获错误后,先判断错误来自提交(sendTransaction reject)还是处理(waitForResult reject)。前者属于“尚未入队”,可评估重试提交;后者属于“已回滚”,必须重建请求。
  2. 绝不在原地修改请求后重发:回滚交易引用的 UTXO 已不存在,任何“调大 gasLimit / 提高 tip 再发同一个对象”的做法都会触发 UTXO does not exist 错误。
  3. 把“重建请求”抽象成可复用函数:将 createTransfer(或合约调用的 functions.xxx 链式构建)封装为纯函数,失败后直接再次调用即可拿到携带最新资源的新请求。
  4. 保留资金语义:回滚后剩余资金会以新 UTXO 形式回到原所有者地址,余额并未凭空蒸发(只是扣除了已消耗的 gas),因此重试在资金层面是安全的。
  5. waitForResult 的结果而非提交返回值判断成败:只有 result.isStatusSuccess 等结果字段才能代表最终执行状态。仓库测试大量使用这一约定,例如 account.test.tsconst { isStatusSuccess } = await response1.waitForResult() 的写法。

总结

在 fuels-ts 中处理失败交易,核心心智模型是提交成功 ≠ 处理成功sendTransaction 只保证交易进入队列,waitForResult 才返回最终结果。一旦 waitForResult 报错,说明交易已被 Fuel VM 处理但被回滚,且原请求引用的 UTXO 已被花费,剩余资金以新 UTXO 形式回到所有者地址。因此,正确做法是丢弃旧请求、调用构建接口从零重组一笔新请求再提交,而不是修改同一请求对象后盲目重发。理解这一机制,是写出可靠的重试、补单与用户补偿逻辑的前提。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
898
5.82 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
921
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.8 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
596
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
519
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
391