fuels-ts 自定义交易实战:用 ScriptTransactionRequest 与 assembleTx 完成多资产转账到合约
本篇基于 fuels-ts 官方文档《Custom Transactions》,讲解当一笔交易涉及多种程序类型与多种资产时,如何手动构建自定义交易:从 Sway 脚本编写,到用 ScriptTransactionRequest 逐步填充脚本字节码、main 函数入参、合约输入/输出,再到通过 provider.assembleTx 完成资源估算与注资、最终发送并验证合约余额变化的完整链路。读完本篇,你将掌握在 fuels-ts 中脱离高层封装、自主控制交易请求每一步的实战能力,并理解 assembleTx 底层参数(如 feePayerAccount、accountCoinQuantities、changeOutputAccount)的源码级语义。
什么场景需要“自定义交易”
fuels-ts 中的高层 API(如 contract.functions.xxx())已经为常见的合约调用封装好了交易组装流程,但存在一类场景:一笔交易需要同时涉及多种程序类型(script、contract、predicate 等)与多种资产,或者交易结构无法用“一次合约调用”表达,例如把两种不同的资产分别转给同一个合约。
这类场景可以通过实例化 ScriptTransactionRequest 来完成:该类允许你在同一个交易中追加多种程序类型的输入/输出,并逐个资产地声明资源需求。核心文档见 custom-transactions.md。
第一步:编写 Sway 侧的多资产转账脚本
以“向合约转账两种资产”为例,对应 Sway 源码位于 script-transfer-to-contract:
script;
use std::asset::transfer;
fn main(
contract_address: b256,
asset_a: AssetId,
amount_asset_a: u64,
asset_b: AssetId,
amount_asset_b: u64,
) -> bool {
let wrapped_contract = ContractId::from(contract_address);
let contract_id = Identity::ContractId(wrapped_contract);
transfer(contract_id, asset_a, amount_asset_a);
transfer(contract_id, asset_b, amount_asset_b);
true
}
几个关键点:
- 脚本的
main接收 5 个参数:合约地址(b256)、两个AssetId以及对应的转账数量(u64)。脚本本身不“拥有”资源,它只发出transfer调用;实际的输入/输出资源必须由 TypeScript 侧在交易中准备好; Identity::ContractId是 Fuel 中对transfer目标身份的统一抽象(资产可以转给账户、合约等身份);- 该脚本在 apps/docs/sway/Forc.toml 中注册,编译产物(字节码与 ABI)会通过 typegen 生成
ScriptTransferToContract类供 TypeScript 引用。
第二步:TypeScript 侧构建 ScriptTransactionRequest
文档给出的执行片段(完整可运行代码见 script-custom-transaction.ts)分为 5 步:
import { BN, ScriptTransactionRequest, coinQuantityfy } from 'fuels';
import { ASSET_A, ASSET_B, launchTestNode } from 'fuels/test-utils';
// 1. Create a script transaction using the script binary
const request = new ScriptTransactionRequest({
...defaultTxParams,
gasLimit: 3_000_000,
script: ScriptTransferToContract.bytecode,
});
// 2. Instantiate the script main arguments
const scriptArguments = [
contract.id.toB256(),
{ bits: ASSET_A },
new BN(1000),
{ bits: ASSET_B },
new BN(500),
];
// 3. Populate the script data and add the contract input and output
request
.setData(ScriptTransferToContract.abi, scriptArguments)
.addContractInputAndOutput(contract.id);
// 4. Estimate and fund the transaction
const { assembledRequest } = await provider.assembleTx({
request,
feePayerAccount: wallet,
accountCoinQuantities: [
{
amount: 1000,
assetId: ASSET_A,
account: wallet,
changeOutputAccount: wallet,
},
{
amount: 500,
assetId: ASSET_B,
account: wallet,
changeOutputAccount: wallet,
},
],
});
// 5. Send the transaction
const tx = await wallet.sendTransaction(assembledRequest);
await tx.waitForResult();
const contractFinalBalanceAssetA = await contract.getBalance(ASSET_A);
const contractFinalBalanceAssetB = await contract.getBalance(ASSET_B);
逐步说明:
- 创建请求:
new ScriptTransactionRequest({ script, gasLimit, ... })。传入script(脚本字节码)与gasLimit(示例中用 3,000,000 覆盖默认值defaultTxParams.gasLimit = 10000)。gasLimit只是初始值,后续assembleTx会依据 dry-run 的 gas 实际消耗回填; - 实例化
main入参:参数顺序必须与 Sway 脚本main签名一致——合约地址、ASSET_A的AssetId、数量 1000、ASSET_B的AssetId、数量 500。注意AssetId需用{ bits: ASSET_A }包装(对应 ABI 中AssetId类型的编码约定),u64数量用BN表达; - 填充脚本数据并挂上合约输入/输出:
setData(abi, args)把参数按 ABI 编码进scriptData;addContractInputAndOutput(contract.id)同时 push 一条InputContract与一条OutputContract,使合约成为交易可访问的资源; - 估算与注资:
provider.assembleTx接收accountCoinQuantities数组——这里声明了两个条目(ASSET_A需 1000、ASSET_B需 500),这是“多资产”交易的核心表达方式; - 发送并验证:
wallet.sendTransaction提交组装好的请求,waitForResult等待执行完成后,用contract.getBalance(assetId)分别校验合约侧两种资产的最终余额。
源码解读:ScriptTransactionRequest 的关键方法
以下实现事实来自 script-transaction-request.ts 对应的实际文件 packages/account/src/providers/transaction-request/script-transaction-request.ts:
- 构造器(L65-L71):若未提供
script或scriptData,会回退到内置的returnZeroScript(空转脚本),即new ScriptTransactionRequest({})得到一个合法但什么都不做的 Script 交易请求。gasLimit经bn()转为BN类型; setData(L275-L279):内部通过new Interface(abi).functions.main.encodeArguments(args)对main参数编码,因此传入的 ABI 必须是脚本的 JSON ABI(typegen 产物ScriptTransferToContract.abi),参数数量与类型错误会在编码阶段暴露;addContractInputAndOutput(L235-L255):先做去重——若已存在同一contractId的InputContract则直接返回,避免重复挂接;否则 push 一条InputType.Contract(带占位txPointer)和一条指向该输入索引的OutputType.Contract,并返回this以支持链式调用;- 可变输出(L171-L198):如果脚本需要从交易中“取出”资源,还可调用
addVariableOutputs(number)或addVariableOutput(to, amount, assetId)pushOutputType.Variable,由节点在执行时填充具体地址与数量——这是自定义交易中常见的“多输出”场景; toTransaction(L106-L119):将请求序列化为TransactionScript,自动计算scriptLength、scriptDataLength并初始化receiptsRoot,是assembleTx将请求转为字节(toTransactionBytes)提交 dry-run 的基础;- 类上还带有
getContractInputs / getContractOutputs / getVariableOutputs等过滤方法,便于在自定义逻辑中读取已挂接的资源。
从源码结构看,ScriptTransactionRequest 继承自 BaseTransactionRequest,inputs/outputs/witnesses 的管理都复用基类,开发者只需关注“脚本特有”的部分(script、scriptData、gasLimit)与资源的追加。
assembleTx:估算与注资的底层参数
上一步的 provider.assembleTx 是 SDK 中所有高层 API 共用的交易组装入口(账户转账、合约/ blob 部署、合约调用均由它驱动),其实现位于 provider.ts,参数定义见 AssembleTxParams。结合 assemble-tx.md 的说明,关键参数如下:
| 参数 | 必填 | 说明 |
|---|---|---|
request |
是 | 要组装的交易请求(本例为 ScriptTransactionRequest) |
feePayerAccount |
是 | 支付交易费的账户;若 accountCoinQuantities 中未单独指定 account,默认由它出资源 |
accountCoinQuantities |
否* | 资源需求数组,每项含 amount(不含费用)、assetId、account(默认 feePayerAccount)、changeOutputAccount(默认 account);多资产交易就是靠多个条目表达 |
blockHorizon |
否 | gas 价格估算向前看的区块数,默认 10 |
estimatePredicates |
否 | 是否为 predicate 估算 gas,默认 true |
resourcesIdsToIgnore |
否 | 注资时要忽略的资源(UTXO 或 message)ID |
reserveGas |
否 | 额外预留的 gas 量 |
* 若交易只需支付费用(不涉及额外资产),可省略。
每个 assetId 只能有一个 change 输出,这是理解 changeOutputAccount 的关键。Fuel 采用 UTXO 模型,交易会整体花费被选中的 UTXO,即使实际只用其中一小部分;花费后剩余的部分(change)会发往 OutputChange 指定的地址。由于同一交易内每个 assetId 只允许一条 OutputChange,当多个账户提供同一 assetId 的资源时,只能由一个账户收回找零——changeOutputAccount 就是用来显式指定这个“找零接收方”的。源码中可以看到对应逻辑:provider.assembleTx 会为每个 accountCoinQuantities 条目生成 changePolicy: { change: <address> },且当 feePayerAccount 未出现在任何条目中时,会为其追加一条金额为 0 的基础资产条目以保证费用来源(provider.ts L1772-L1823)。本文示例中单一钱包既付费又出资,account 与 changeOutputAccount 都显式设为 wallet,是最直观也最不易出错的写法。
AssembleTxResponse 返回 assembledRequest(已填充全部输入/输出/policies 的最终请求)、gasPrice(估算的气价)以及 dry-run 产生的 receipts / rawReceipts,可直接用于后续发送或断言。
完整示例与验证
将 Sway 脚本与 TypeScript 步骤组合起来的完整可运行示例如下(对应文档中 #full 区域,源文件为 script-custom-transaction.ts):
import { BN, ScriptTransactionRequest, coinQuantityfy } from 'fuels';
import { ASSET_A, ASSET_B, launchTestNode } from 'fuels/test-utils';
import { EchoValuesFactory } from '../../../typegend/contracts/EchoValuesFactory';
import { ScriptTransferToContract } from '../../../typegend/scripts/ScriptTransferToContract';
using launched = await launchTestNode({
contractsConfigs: [{ factory: EchoValuesFactory }],
});
const {
contracts: [contract],
wallets: [wallet],
provider,
} = launched;
const defaultTxParams = {
gasLimit: 10000,
};
// 1. Create a script transaction using the script binary
const request = new ScriptTransactionRequest({
...defaultTxParams,
gasLimit: 3_000_000,
script: ScriptTransferToContract.bytecode,
});
// 2. Instantiate the script main arguments
const scriptArguments = [
contract.id.toB256(),
{ bits: ASSET_A },
new BN(1000),
{ bits: ASSET_B },
new BN(500),
];
// 3. Populate the script data and add the contract input and output
request
.setData(ScriptTransferToContract.abi, scriptArguments)
.addContractInputAndOutput(contract.id);
// 4. Estimate and fund the transaction
const { assembledRequest } = await provider.assembleTx({
request,
feePayerAccount: wallet,
accountCoinQuantities: [
{
amount: 1000,
assetId: ASSET_A,
account: wallet,
changeOutputAccount: wallet,
},
{
amount: 500,
assetId: ASSET_B,
account: wallet,
changeOutputAccount: wallet,
},
],
});
// 5. Send the transaction
const tx = await wallet.sendTransaction(assembledRequest);
await tx.waitForResult();
const contractFinalBalanceAssetA = await contract.getBalance(ASSET_A);
const contractFinalBalanceAssetB = await contract.getBalance(ASSET_B);
运行前提说明:
- 该示例依赖
fuels/test-utils的launchTestNode本地测试节点,以及 typegen 生成的EchoValuesFactory(合约工厂)与ScriptTransferToContract(脚本封装),脚本字节码与 ABI 均来自 Sway 侧编译产物; EchoValuesFactory在本例中仅用于启动节点并部署一个合约实例作为转账目标;ASSET_A/ASSET_B是test-utils提供的两个测试资产 ID;- 断言逻辑为:转账完成后合约在
ASSET_A与ASSET_B上的余额应分别增加 1000 与 500(以脚本执行true且余额校验通过为准)。
相关文档与测试
- 自定义交易核心文档:custom-transactions.md
assembleTx完整参数与 change 语义详解:assemble-tx.md;多账户同一资产 change 冲突的示例片段见 multiple-output-change.tsassembleTx的迁移指南(旧版estimateAndFund用法对照):assemble-tx-migration-guide.md;源码中estimateAndFund已标注@deprecated并指向该迁移文档(script-transaction-request.ts L83-L99)- 端到端行为验证:
assembleTx的集成测试位于 assemble-tx.test.ts,覆盖了注资、dry-run 失败与资源处理等路径,可作为自定义交易行为断言的参考
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 StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00