Fuel TypeScript SDK 交易组装实战:assembleTx 全参数解析与 Fuel UTXO 找零机制
【免费下载链接】fuels-tsFuel Network Typescript SDK项目地址: https://gitcode.com/GitHub_Trending/fu/fuels-ts
assembleTx是 Fuel TypeScript SDK(fuels-ts)中负责交易"预组装"的核心方法:它接收一份尚未完整的交易请求,自动补齐输入(inputs)、输出(outputs)与交易策略(policies),并完成 gas 价格估算与费用校验。SDK 中几乎所有高层 API(账户转账、合约部署、Blob 部署、合约调用)最终都经由它落地。读完本文,你将掌握assembleTx的完整参数语义、返回结构,理解 Fuel 基于 UTXO 的找零(change)机制对"谁收到找零"的决定性影响,并能在多账户、多资产场景下正确、安全地组装一笔可签名上链的交易。
assembleTx 能为你做什么
在 Fuel 中,交易不能只携带"想做什么"(例如向某个地址转 100 个 base asset),还必须明确说明资源从哪来(输入 UTXO)、找零给谁(change 输出)、费用由谁承担(fee payer)以及采用何种 gas 价格。手工拼装这些字段极易出错,而assembleTx正是把这一复杂过程收敛为一个方法调用。
根据官方指南(见 assemble-tx.md),它主要处理以下事项:
- 不同资产所需的币数量(coin quantity)汇总;
- 指定费用支付账户(fee payer);
- gas 与费用估算;
- predicate(谓词)的估算;
- 资源排除(忽略特定资源 ID,避免把某笔 UTXO 或消息重复计入)。
其方法签名与入口位于 provider.ts:
async assembleTx<T extends TransactionRequest>( params: AssembleTxParams<T> ): Promise<AssembleTxResponse<T>>注意,方法属于Provider实例,因此调用前需要一个已连接网络的Provider(本地节点可参考各示例中使用的LOCAL_NETWORK_URL)。
AssembleTxParams:参数全解
assembleTx的核心输入类型为AssembleTxParams<T>,其完整定义位于 provider.ts:
export type AssembleTxParams<T extends TransactionRequest = TransactionRequest> = { // The transaction request to assemble request: T; // Coin quantities required for the transaction, optional if transaction only needs funds for the fee accountCoinQuantities?: AccountCoinQuantity[]; // Account that will pay for the transaction fees feePayerAccount: Account; // Block horizon for gas price estimation (default: 10) blockHorizon?: number; // Whether to estimate predicates (default: true) estimatePredicates?: boolean; // Resources to be ignored when funding the transaction (optional) resourcesIdsToIgnore?: ResourcesIdsToIgnore; // Amount of gas to reserve (optional) reserveGas?: BigNumberish; };各参数含义与默认值如下:
| 参数 | 是否必填 | 默认值 | 说明 |
|---|---|---|---|
request | 必填 | 无 | 待组装的交易请求,如ScriptTransactionRequest、CreateTransactionRequest等。 |
feePayerAccount | 必填 | 无 | 负责支付交易费用的账户。 |
accountCoinQuantities | 可选 | [] | 交易需要的各资产币数量数组。若交易只需要覆盖手续费(例如纯转账且资产由 fee payer 提供),可省略。 |
blockHorizon | 可选 | 10 | gas 价格估算时向前预看的区块数量。 |
estimatePredicates | 可选 | true | 是否对 predicate 进行 gas 估算。 |
resourcesIdsToIgnore | 可选 | 无 | 资助交易时需忽略的资源(UTXO 或 message)。 |
reserveGas | 可选 | 无 | 额外预留的 gas 数量。 |
这些默认值并非只在文档层面约定,而是直接体现在实现里。在 provider.ts 中可以看到解构参数时显式写出的默认值:
const { request, reserveGas, resourcesIdsToIgnore, feePayerAccount, blockHorizon = 10, estimatePredicates = true, accountCoinQuantities = [], } = params;accountCoinQuantities 的内部结构
每个AccountCoinQuantity条目包含以下字段:
| 字段 | 必填 | 默认行为 | 说明 |
|---|---|---|---|
amount | 必填 | 无 | 需要的币数量(费用部分无需手动计入,系统会自动叠加 base asset 手续费)。 |
assetId | 必填 | 无 | 资产的 ID(可先通过provider.getBaseAssetId()取得 base asset)。 |
account | 可选 | 默认为根级feePayerAccount | 提供该部分资源的账户。 |
changeOutputAccount | 可选 | 默认为account(若account未提供则回退到feePayerAccount) | 接收该assetId全部已花费资源找零的账户。 |
account / changeOutputAccount 的默认行为
account与changeOutputAccount都允许省略,它们的"缺省回退链"在实现中对应如下代码(provider.ts):
const { amount, assetId, account = feePayerAccount, changeOutputAccount } = quantity; const changeAccountAddress = changeOutputAccount ? changeOutputAccount.address.toB256() : account.address.toB256();即:
- 未指定
account时,直接回退到根级feePayerAccount; - 未指定
changeOutputAccount时,回退到本条目的account(若account也缺省,则最终落到feePayerAccount)。
官方 default-behaviors.ts 示例逐一演示了这三种写法:
const accountCoinQuantities: AccountCoinQuantity[] = [ { amount: 100, assetId: baseAssetId, // account 未指定 => 默认取 feePayerAccount // changeOutputAccount 未指定 => 默认取 feePayerAccount }, { amount: 200, assetId: TestAssetId.A.value, account: accountA, // changeOutputAccount 未指定 => 默认取 accountA }, { amount: 300, assetId: TestAssetId.B.value, account: accountB, changeOutputAccount: accountC, // account 与 changeOutputAccount 均显式指定 }, ];实现中的一个隐含细节:fee payer 自动补位
从源码看,provider.ts 还处理了一种边界情况:如果feePayerAccount没有出现在任何accountCoinQuantities条目中,assembleTx会自动把它作为"金额为 0 的 base asset 需求量"追加进 required balances 末尾,其 change 输出指向baseAssetChange(即已显式声明的 base asset change 地址)或 fee payer 自身地址。这意味着费用虽然由 fee payer 承担,但找零归属会被统一收敛到你声明的 change 策略上——这是理解后续"谁收到找零"问题的关键实现基础。
返回值 AssembleTxResponse
方法返回AssembleTxResponse<T>,定义同样在 provider.ts:
export type AssembleTxResponse<T extends TransactionRequest = TransactionRequest> = { assembledRequest: T; // 已完整组装、带齐 inputs/outputs/policies 的交易请求 gasPrice: BN; // 估算出的 gas 价格 receipts: TransactionResultReceipt[]; // 解析后的 dry run receipts rawReceipts: TransactionReceiptJson[]; // 未解析的原始 receipts };各字段说明:
assembledRequest:组装完成、可直接签名提交的交易请求;gasPrice:按blockHorizon估算的 gas 价格;receipts:dry run 返回、已解析的 receipts;rawReceipts:dry run 返回的未解析 receipts。
需要特别说明的是:assembleTx在返回前会对交易做一次 dry run 以验证其可行性。若 dry run 失败,实现会读取status.type === 'DryRunFailureStatus'的分支并通过extractDryRunError抛出解析后的错误,而不会静默返回一笔注定失败的交易(provider.ts)。
基本用法:组装一笔可签名的转账
basic-usage.ts 给出了完整的入门示例。核心流程如下:
const provider = new Provider(LOCAL_NETWORK_URL); const baseAssetId = await provider.getBaseAssetId(); const accountA = Wallet.fromPrivateKey(WALLET_PVT_KEY, provider); const accountB = Wallet.fromPrivateKey(WALLET_PVT_KEY_2, provider); const transferAmount = 100; // 1. 先声明"想做什么":向 accountB 输出 100 个 base asset const request = new ScriptTransactionRequest(); request.addCoinOutput(accountB.address, transferAmount, baseAssetId); const accountCoinQuantities: AccountCoinQuantity[] = [ { amount: transferAmount, assetId: baseAssetId, account: accountA, changeOutputAccount: accountA, // 可选 }, ]; // 2. 组装:补齐 inputs / outputs / policies const { assembledRequest, gasPrice, receipts } = await provider.assembleTx({ request, accountCoinQuantities, feePayerAccount: accountA, blockHorizon: 10, estimatePredicates: true, }); // 3. 组装完成后即可签名并提交 const submit = await accountA.sendTransaction(assembledRequest); await submit.waitForResult();使用要点:
request只描述业务意图(这里是为accountB增加一笔 coin 输出),不要手工去填输入与 change,那是assembleTx的职责;assembleTx会在需要时自动补入 base asset 手续费所需资源,因此accountCoinQuantities中的amount无需包含费用;- 返回的
assembledRequest是待签名状态的请求,随后用发送方(这里是accountA)的sendTransaction提交并waitForResult等待上链。
为什么必须关注 change:Fuel 的 UTXO 找零模型
account与changeOutputAccount看似只是两个可选字段,但官方文档用大量篇幅专门提醒开发者注意它们——原因在于 Fuel 的UTXO 记账模型与以太坊的账户模型存在本质差异。
整笔消费:UTXO 模型下没有"部分花费"
在 Fuel 中,一笔交易只要把某个 UTXO 列入输入,就会整体消费该 UTXO,即使业务上只需要其中一小部分。官方文档给出过一个直观例子:
假设你有一个价值 10 ETH 的 UTXO。当你创建一笔只想转出 1 Gwei 的交易时,整个 10 ETH 的 UTXO 仍会被消耗。交易随后会:
- 为接收方创建一个 1 Gwei 的 UTXO;
- 为剩余部分(10 ETH - 1 Gwei - 费用)再创建一个 UTXO,并发送到
OutputChange中指定的地址。
这里的"剩余部分"就是找零(change),而OutputChange决定了找零落到谁手里。可以说,OutputChange保证了"你的钱还能回到你手中"。
Fuel 约束:每个 assetId 每笔交易最多一个 change 输出
Fuel 有一条关键规则:一笔交易内,每个assetId只允许存在一个OutputChange。这意味着,如果一笔交易同时花销了多个账户对同一资产(比如都是 ETH)的 UTXO,那么只有一个人能收到这笔资产的全部找零——也就是OutputChange上写明的那位。若处理不当,就会出现"一个账户出了钱、另一个账户拿了找零"的意外结果。
多账户场景下的找零归属:multiple-output-change 示例
把上面两条规则叠加到assembleTx:accountCoinQuantities[].changeOutputAccount就是你在 SDK 层面显式指定"这个 assetId 的找零归谁"的开关。
官方 multiple-output-change.ts 演示了这一典型场景:
let { assembledRequest } = await provider.assembleTx({ request, feePayerAccount: accountA, accountCoinQuantities: [ { amount: transferAmount, assetId: baseAssetId, account: accountB, /** * accountB 将收到找零。虽然这里显式声明, * 但即使不写,它也会默认回退到 account 属性(此处同样是 accountB)。 */ changeOutputAccount: accountB, }, ], });在这个例子中:
accountB的资源被显式请求进accountCoinQuantities(作为本次转账金额的提供方);accountA是feePayerAccount,因此它也会投入资源来覆盖手续费;changeOutputAccount被显式设为accountB。
最终效果是:无论交易中消费了谁的 UTXO(包括accountA为了付手续费投进去的 UTXO),这笔资产的全部找零都会发给accountB。
官方文档给出了一个极具警示性的推演:假设accountA投入了一枚 10 ETH 的 UTXO,由于 UTXO 必须整笔消费,交易会花掉完整的 10 ETH,而剩余找零会流向accountB而非accountA——这正是"只配置了 fee payer,却没仔细想 change 归属"可能踩中的坑。因此代码后续还须由实际出资账户完成见证人签名:
assembledRequest = await accountB.populateTransactionWitnessesSignature(assembledRequest); const submit = await accountA.sendTransaction(assembledRequest); await submit.waitForResult();底层原理:assembleTx 在 provider 内部做了什么
结合仓库源码可以看清assembleTx的完整数据流(provider.ts):
- 归一化余额请求:遍历
accountCoinQuantities,对每条生成{ account, amount, assetId, changePolicy };当assetId是 base asset 时,额外记录该条目的 change 地址作为baseAssetChange; - fee payer 自动补位:若 fee payer 未出现在任何条目中,以金额
0追加一条 base asset 余额请求,保证费用有账户兜底; - 资源排除处理:调用
adjustResourcesToIgnoreForAddresses,基于本次交易涉及的地址集合裁剪resourcesIdsToIgnore——配合 SDK 内部的资源缓存,避免把某地址已占用的资源重复列入输入(同时这也是传入该参数的意义所在); - 调用 GraphQL 组装端点:将
request.toTransactionBytes()、blockHorizon、feeAddressIndex(fee payer 在余额请求数组中的下标)、requiredBalances、estimatePredicates、excludeInput、reserveGas一并提交给 Fuel Core 的operations.assembleTx,由其执行估算与 dry run; - 回填并校验:把节点返回的 witnesses / inputs / outputs 反序列化后写回
request;若 dry run 状态为失败则解析 receipts 并抛错。
账户解析支持 predicate
accountCoinQuantities与feePayerAccount的账户最终会交给 assemble-tx-helpers.ts 中的resolveAccountForAssembleTxParams序列化:
- 普通账户序列化为
{ address }; - predicate 账户(实现上通过
'bytes' in account判定)则序列化为{ predicate, predicateAddress, predicateData },将谓词字节码、地址与数据一并上报节点用于估算。
这解释了为什么assembleTx需要estimatePredicates参数:当交易涉及 predicate 资源时,gas 估算必须把 predicate 执行的开销计算在内。
与已废弃旧 API 的关系
assembleTx是getTransactionCost+fund、以及estimateAndFund等旧流程的替代方案,后者已标记为 deprecated,并将在未来版本移除。如果你正在迁移旧代码,可参考仓库中的官方迁移指南 assemble-tx-migration-guide.md。相比旧 API,新方法的优势可概括为三点:对"谁付手续费、谁出资源"的控制更显式;可精确指定每个账户、每种资产各自提供多少数量;在声明每个账户的 coin quantity 时就能直接控制该 assetId 的找零归属。
测试验证
仓库的燃料 gauge 集成测试 assemble-tx.test.ts 覆盖了assembleTx在真实 Fuel 节点上的行为,包含多账户、多资产与找零归属等场景,可作为理解本文内容后进一步对照源码学习的入口。
Best Practices:官方建议清单
- 始终提供余额充足的
feePayerAccount:手续费不足会让 dry run 失败并在组装阶段即抛错; - 多账户共享同一 assetId 时,务必谨慎对待 change 输出:
- Fuel 每个 assetId 每笔交易只允许一个 change 输出;
- 若同一 assetId 的资源来自多个账户,则只有一个账户能收到全部已花费资源的找零;
- 请与交易涉及的各方事先协调,明确由谁接收该 assetId 的找零,再通过
changeOutputAccount显式声明。
Notes:行为特性小结
assembleTx会自动处理 base asset 的手续费需求,accountCoinQuantities的amount无需叠加费用;- 返回前会对交易执行 dry run 校验,失败即抛错,避免把无效交易放行到签名环节;
- 组装后的交易会依据
accountCoinQuantities带齐全部必要的 inputs 与 outputs; - gas 与费用估算基于指定的
blockHorizon完成; - 若要彻底掌控找零去向,最稳妥的做法是:永远显式写出
account与changeOutputAccount,不依赖默认回退逻辑。
【免费下载链接】fuels-tsFuel Network Typescript SDK项目地址: https://gitcode.com/GitHub_Trending/fu/fuels-ts
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考