首页
/ fuels-ts 自定义交易实战:用 ScriptTransactionRequest 与 assembleTx 完成多资产转账到合约

fuels-ts 自定义交易实战:用 ScriptTransactionRequest 与 assembleTx 完成多资产转账到合约

2026-09-05 14:20:36作者:裴麒琰

本篇基于 fuels-ts 官方文档《Custom Transactions》,讲解当一笔交易涉及多种程序类型与多种资产时,如何手动构建自定义交易:从 Sway 脚本编写,到用 ScriptTransactionRequest 逐步填充脚本字节码、main 函数入参、合约输入/输出,再到通过 provider.assembleTx 完成资源估算与注资、最终发送并验证合约余额变化的完整链路。读完本篇,你将掌握在 fuels-ts 中脱离高层封装、自主控制交易请求每一步的实战能力,并理解 assembleTx 底层参数(如 feePayerAccountaccountCoinQuantitieschangeOutputAccount)的源码级语义。

什么场景需要“自定义交易”

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);

逐步说明:

  1. 创建请求new ScriptTransactionRequest({ script, gasLimit, ... })。传入 script(脚本字节码)与 gasLimit(示例中用 3,000,000 覆盖默认值 defaultTxParams.gasLimit = 10000)。gasLimit 只是初始值,后续 assembleTx 会依据 dry-run 的 gas 实际消耗回填;
  2. 实例化 main 入参:参数顺序必须与 Sway 脚本 main 签名一致——合约地址、ASSET_AAssetId、数量 1000、ASSET_BAssetId、数量 500。注意 AssetId 需用 { bits: ASSET_A } 包装(对应 ABI 中 AssetId 类型的编码约定),u64 数量用 BN 表达;
  3. 填充脚本数据并挂上合约输入/输出setData(abi, args) 把参数按 ABI 编码进 scriptDataaddContractInputAndOutput(contract.id) 同时 push 一条 InputContract 与一条 OutputContract,使合约成为交易可访问的资源;
  4. 估算与注资provider.assembleTx 接收 accountCoinQuantities 数组——这里声明了两个条目ASSET_A 需 1000、ASSET_B 需 500),这是“多资产”交易的核心表达方式;
  5. 发送并验证wallet.sendTransaction 提交组装好的请求,waitForResult 等待执行完成后,用 contract.getBalance(assetId) 分别校验合约侧两种资产的最终余额。

源码解读:ScriptTransactionRequest 的关键方法

以下实现事实来自 script-transaction-request.ts 对应的实际文件 packages/account/src/providers/transaction-request/script-transaction-request.ts

  • 构造器(L65-L71):若未提供 scriptscriptData,会回退到内置的 returnZeroScript(空转脚本),即 new ScriptTransactionRequest({}) 得到一个合法但什么都不做的 Script 交易请求。gasLimitbn() 转为 BN 类型;
  • setData(L275-L279):内部通过 new Interface(abi).functions.main.encodeArguments(args)main 参数编码,因此传入的 ABI 必须是脚本的 JSON ABI(typegen 产物 ScriptTransferToContract.abi),参数数量与类型错误会在编码阶段暴露;
  • addContractInputAndOutput(L235-L255):先做去重——若已存在同一 contractIdInputContract 则直接返回,避免重复挂接;否则 push 一条 InputType.Contract(带占位 txPointer)和一条指向该输入索引的 OutputType.Contract,并返回 this 以支持链式调用;
  • 可变输出(L171-L198):如果脚本需要从交易中“取出”资源,还可调用 addVariableOutputs(number)addVariableOutput(to, amount, assetId) push OutputType.Variable,由节点在执行时填充具体地址与数量——这是自定义交易中常见的“多输出”场景;
  • toTransaction(L106-L119):将请求序列化为 TransactionScript,自动计算 scriptLengthscriptDataLength 并初始化 receiptsRoot,是 assembleTx 将请求转为字节(toTransactionBytes)提交 dry-run 的基础;
  • 类上还带有 getContractInputs / getContractOutputs / getVariableOutputs 等过滤方法,便于在自定义逻辑中读取已挂接的资源。

从源码结构看,ScriptTransactionRequest 继承自 BaseTransactionRequestinputs/outputs/witnesses 的管理都复用基类,开发者只需关注“脚本特有”的部分(script、scriptData、gasLimit)与资源的追加。

assembleTx:估算与注资的底层参数

上一步的 provider.assembleTx 是 SDK 中所有高层 API 共用的交易组装入口(账户转账、合约/ blob 部署、合约调用均由它驱动),其实现位于 provider.ts,参数定义见 AssembleTxParams。结合 assemble-tx.md 的说明,关键参数如下:

参数 必填 说明
request 要组装的交易请求(本例为 ScriptTransactionRequest
feePayerAccount 支付交易费的账户;若 accountCoinQuantities 中未单独指定 account,默认由它出资源
accountCoinQuantities 否* 资源需求数组,每项含 amount(不含费用)、assetIdaccount(默认 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)。本文示例中单一钱包既付费又出资,accountchangeOutputAccount 都显式设为 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-utilslaunchTestNode 本地测试节点,以及 typegen 生成的 EchoValuesFactory(合约工厂)与 ScriptTransferToContract(脚本封装),脚本字节码与 ABI 均来自 Sway 侧编译产物;
  • EchoValuesFactory 在本例中仅用于启动节点并部署一个合约实例作为转账目标;ASSET_A / ASSET_Btest-utils 提供的两个测试资产 ID;
  • 断言逻辑为:转账完成后合约在 ASSET_AASSET_B 上的余额应分别增加 1000 与 500(以脚本执行 true 且余额校验通过为准)。

相关文档与测试

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