fuels-ts 失败交易安全重提交指南:厘清“提交”与“处理”两个阶段,规避 UTXO 重放错误
本文依据 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.sendTransaction 与 TransactionResponse.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_URL、WALLET_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?结合源码可以从两个方面佐证:
- 资源选取发生在请求构建期:
createTransfer等接口在构建TransactionRequest时会向 Provider 查询并选取可花费的 coin 资源。被回滚的交易已经把旧 UTXO 花费掉,链上余额的最新形态只能通过再次查询获得——这正是“重建请求”有效的根本原因。 - 处理完成后 SDK 会主动释放资源缓存:回到 transaction-response.ts 的
waitForResult,其中调用的unsetResourceCache实现为:
private unsetResourceCache() {
this.provider.cache?.unset(this.id);
}
即交易确认完成后,SDK 会清除 Provider 资源缓存中与该交易 id 相关的条目,避免后续构建新交易时误用已被花费的资源。这也从侧面说明:一旦交易进入处理流程,SDK 自身就会把相关资源视为“已消耗”,需要重新选取。
五、实践建议与注意事项
基于原文档与上述机制,在实现重提交逻辑时可遵循以下原则:
- 对失败点做二分处理:捕获错误后,先判断错误来自提交(
sendTransactionreject)还是处理(waitForResultreject)。前者属于“尚未入队”,可评估重试提交;后者属于“已回滚”,必须重建请求。 - 绝不在原地修改请求后重发:回滚交易引用的 UTXO 已不存在,任何“调大 gasLimit / 提高 tip 再发同一个对象”的做法都会触发
UTXO does not exist错误。 - 把“重建请求”抽象成可复用函数:将
createTransfer(或合约调用的functions.xxx链式构建)封装为纯函数,失败后直接再次调用即可拿到携带最新资源的新请求。 - 保留资金语义:回滚后剩余资金会以新 UTXO 形式回到原所有者地址,余额并未凭空蒸发(只是扣除了已消耗的 gas),因此重试在资金层面是安全的。
- 用
waitForResult的结果而非提交返回值判断成败:只有result.isStatusSuccess等结果字段才能代表最终执行状态。仓库测试大量使用这一约定,例如 account.test.ts 中const { isStatusSuccess } = await response1.waitForResult()的写法。
总结
在 fuels-ts 中处理失败交易,核心心智模型是提交成功 ≠ 处理成功:sendTransaction 只保证交易进入队列,waitForResult 才返回最终结果。一旦 waitForResult 报错,说明交易已被 Fuel VM 处理但被回滚,且原请求引用的 UTXO 已被花费,剩余资金以新 UTXO 形式回到所有者地址。因此,正确做法是丢弃旧请求、调用构建接口从零重组一笔新请求再提交,而不是修改同一请求对象后盲目重发。理解这一机制,是写出可靠的重试、补单与用户补偿逻辑的前提。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00