fuels-rs 自定义输入与输出:用 with_inputs / with_outputs / add_signer 定制合约调用交易
在 Fuel 链上发起合约调用时,fuels-rs 默认会自动根据调用所需的资产与合约推导交易输入(Input)和输出(Output)。当调用场景特殊——例如需要在同一笔交易中消费指定来源的币、引入自定义的 change output,或让第二方钱包对自定义 coin 输入进行签名时——SDK 提供了 with_inputs 和 with_outputs 两个构建方法,以及配套的 add_signer 方法来接管这一推导过程。本文以仓库文档 custom-inputs-outputs.md 为主体,结合 fuels-rs 源码,完整讲清这套 API 的用法、底层数据流与签名机制。
读完本文,你将能够:
- 在合约调用链上正确挂载自定义
Vec<Input>/Vec<Output>; - 理解自定义输入输出在 SDK 内部从
ContractCall到最终ScriptTransaction的完整流转路径; - 掌握
add_signer如何让外部签名者对自定义 coin 输入完成 witness 签名。
何时需要自定义 Input / Output
一笔 Fuel 交易由输入(Input:coin、contract、message 等)、输出(Output:coin、contract、change 等)和脚本组成。fuels-rs 在 packages/fuels-programs/src/calls/utils.rs 的 transaction_builder_from_contract_calls 中会自动完成推导:根据 ContractCall 需要的资产向账户取 coin 作为资产输入(asset_inputs),再由 get_transaction_inputs_outputs 生成合约输入、change 输出等。
对于绝大多数简单调用,这条默认路径足够。但存在 SDK 无法替你做决策的场景:
- 你想指定交易里具体使用哪些输入(例如消费某个特定来源的 UTXO、带特定
predicate的输入),而不是让 SDK 自动选取; - 你想追加自定义的输出(例如自定义 change 流向、额外转账输出);
- 自定义输入中包含需要签名的 coin,此时必须提供对应的签名者,SDK 才能为这些 coin 输入生成签名 witness。
官方文档对后一种情况给出了明确提示:若自定义输入包含需要签名的 coin,应使用 add_signer 方法添加相应签名者。
基本用法:with_inputs 与 with_outputs
文档给出的核心示例来自 examples/contracts/src/lib.rs 中 custom_assets_example 测试(该测试通过 setup_program_test! 宏准备了两个钱包 wallet / wallet_2,并部署了 MyContract 合约):
let custom_inputs = vec![];
let custom_outputs = vec![];
let _ = contract_instance
.methods()
.initialize_counter(42)
.with_inputs(custom_inputs) // 附加自定义交易输入 Vec<Input>
.with_outputs(custom_outputs) // 附加自定义交易输出 Vec<Output>
.add_signer(wallet_2.signer().clone()) // 为需要签名的自定义输入提供签名者
.call()
.await?;
几个要点:
with_inputs/with_outputs是构建方法(builder),接收Vec<Input>与Vec<Output>,可任意穿插在methods()...call()调用链中;- 示例传入了空
vec![],是因为测试目的为验证 API 链路本身;实际使用时你应当构造好需要注入的Input/Output值; add_signer(wallet_2.signer().clone())传入第二个钱包的签名者,使该钱包能够为自定义 coin 输入签名。
Input / Output 类型由 fuels-core 对外导出,定义在 packages/fuels-core/src/types/input.rs 与 packages/fuels-core/src/types/output.rs(通过 types.rs 聚合导出),它们是 fuel_tx 中交易输入/输出枚举的 SDK 封装。
源码解析:自定义输入输出的流转路径
第一站:ContractCall 结构体
调用链上第一个承载自定义输入输出的位置是 ContractCall 结构体,见 packages/fuels-programs/src/calls/contract_call.rs:
pub struct ContractCall {
pub contract_id: ContractId,
pub encoded_args: Result<Vec<u8>>,
pub encoded_selector: Selector,
pub call_parameters: CallParameters,
pub external_contracts: Vec<ContractId>,
pub output_param: ParamType,
pub is_payable: bool,
pub custom_assets: HashMap<(AssetId, Option<Address>), u64>,
pub inputs: Vec<Input>, // with_inputs 写入这里
pub outputs: Vec<Output>, // with_outputs 写入这里
}
对应的两个方法实现非常直接(contract_call.rs#L65-L75):
/// Add custom outputs to the `ContractCall`.
pub fn with_outputs(mut self, outputs: Vec<Output>) -> Self {
self.outputs = outputs;
self
}
/// Add custom inputs to the `ContractCall`.
pub fn with_inputs(mut self, inputs: Vec<Input>) -> Self {
self.inputs = inputs;
self
}
可以推断,with_inputs / with_outputs 是整体替换语义:传入的向量直接覆盖 self.inputs / self.outputs,而不是追加。
第二站:CallHandler 的转发与签名者收集
contract_instance.methods().xxx() 返回的调用句柄最终由 CallHandler(packages/fuels-programs/src/calls/call_handler.rs)统一管理,它同时持有账户(account)、底层调用(call)、交易策略、日志解码器,以及一个关键的字段:
pub struct CallHandler<A, C, T> {
pub account: A,
pub call: C,
pub tx_policies: TxPolicies,
pub log_decoder: LogDecoder,
pub datatype: PhantomData<T>,
decoder_config: DecoderConfig,
cached_tx_id: Option<Bytes32>,
variable_output_policy: VariableOutputPolicy,
unresolved_signers: Vec<Arc<dyn Signer + Send + Sync>>,
}
在 CallHandler 上再次调用 with_inputs / with_outputs 时,只是把值转发给底层调用(call_handler.rs#L366-L374):
pub fn with_outputs(mut self, outputs: Vec<Output>) -> Self {
self.call = self.call.with_outputs(outputs);
self
}
pub fn with_inputs(mut self, inputs: Vec<Input>) -> Self {
self.call = self.call.with_inputs(inputs);
self
}
而 add_signer 并不触碰 call,而是把签名者暂存到 unresolved_signers(call_handler.rs#L84-L87):
pub fn add_signer(mut self, signer: impl Signer + Send + Sync + 'static) -> Self {
self.unresolved_signers.push(Arc::new(signer));
self
}
注意“unresolved”这个命名:此刻只是登记签名者身份,真正把它绑定到某个 coin 输入的 witness 索引,发生在后面构建交易的时候。
第三站:TransactionBuilder 与最终交易组装
当执行 .call() 时,CallHandler::transaction_builder(call_handler.rs#L96-L131)完成三件事:
- 通过 provider 获取共识参数,并调用
required_assets计算各资产需求; - 用账户的可花费资源凑出
asset_inputs(默认自动选取的 coin 输入); - 把调用描述转成
ScriptTransactionBuilder,并把unresolved_signers一次性交给 builder:tb.add_signers(&self.unresolved_signers)?。
ScriptTransactionBuilder 实现了 TransactionBuilder trait(packages/fuels-core/src/types/transaction_builders.rs#L161-L182),其中与本文主题直接相关的接口为:
fn add_signer(&mut self, signer: impl Signer + Send + Sync + 'static) -> Result<&mut Self>;
fn add_signers<'a>(&mut self, signers: ...) -> Result<&mut Self>;
fn with_inputs(self, inputs: Vec<Input>) -> Self;
fn with_outputs(self, outputs: Vec<Output>) -> Self;
fn with_witnesses(self, witnesses: Vec<Witness>) -> Self;
fn inputs(&self) -> &Vec<Input>;
fn outputs(&self) -> &Vec<Output>;
其宏实现(transaction_builders.rs#L268-L276)中,with_inputs / with_outputs 同样是直接覆盖 builder 内部的 inputs / outputs 字段。
最终,ContractCall.inputs / ContractCall.outputs 会汇入交易级输入输出。关键逻辑在 packages/fuels-programs/src/calls/utils.rs 的 get_transaction_inputs_outputs:
// Custom `Inputs` and `Outputs` should be placed before other inputs and outputs.
let custom_inputs = calls.iter().flat_map(|c| c.inputs.clone()).collect_vec();
let custom_inputs_len = custom_inputs.len();
let custom_outputs = calls.iter().flat_map(|c| c.outputs.clone()).collect_vec();
let inputs = chain!(
custom_inputs,
generate_contract_inputs(contract_ids, custom_outputs.len()),
asset_inputs
).collect();
let outputs = chain!(
custom_outputs,
generate_contract_outputs(num_of_contracts, custom_inputs_len),
generate_asset_change_outputs(address, asset_ids),
generate_custom_outputs(calls),
).collect();
由此可以读出两条明确的编排规则:
- 自定义输入输出永远排在最前面。输入序列为:
custom_inputs→ 合约输入(generate_contract_inputs)→ 自动选取的资产输入;输出序列为:custom_outputs→ 合约输出 → 资产 change 输出 →custom_assets产生的 coin 输出。 - 索引一致性被显式维护。合约输入通过
output_index字段引用输出数组中的位置,而generate_contract_inputs(contract_ids, custom_outputs.len())与generate_contract_outputs(num_of_contracts, custom_inputs_len)都传入了自定义部分的长度作为偏移,从而保证节点侧按output_index索引时不错位。源码注释也明确写道:节点收到请求后,会用output_index来索引我们发送的inputs/outputs数组。
因此,使用 with_inputs / with_outputs 时应当意识到:你注入的元素会整体前移后续自动生成元素的索引位置,SDK 已替你处理了这部分对齐,你不需要(也不应该)手动计算偏移。
add_signer:为自定义 coin 输入提供签名
回到文档的那条 Note。为什么自定义输入需要签名时要 add_signer?
在 TransactionBuilder 的宏实现里,add_signer(transaction_builders.rs#L191-L202)做了两件事:
fn add_signer(&mut self, signer: impl Signer + Send + Sync + 'static) -> Result<&mut Self> {
self.validate_no_signer_available(&signer.address())?; // 防止重复注册
let index_offset = self.unresolved_signers.len() as u64;
self.unresolved_witness_indexes
.owner_to_idx_offset
.insert(signer.address().clone(), index_offset); // 记录签名者 -> witness 偏移
self.unresolved_signers.push(std::sync::Arc::new(signer));
Ok(self)
}
从源码结构看,SDK 通过 unresolved_witness_indexes 维护“签名者地址 → witness 索引偏移”的映射:当构建脚本交易时,凡是 coin 输入的拥有者(owner)地址与某个已注册签名者匹配,builder 就会在 witness 列表预留位置,并在签名阶段调用对应 Signer 生成 witness。这也解释了示例中 add_signer(wallet_2.signer().clone()) 的必要性——如果 custom_inputs 里包含属于 wallet_2 且带 owner 的 coin 输入,不注册 wallet_2 的签名者,交易就无法为该输入生成签名。
另外两个值得了解的细节:
- 重复为同一地址注册签名者会直接报错(
validate_no_signer_available),例如默认账户已是主签名者时不能再add_signer同一个地址; CallHandler上可以链式多次add_signer(多个签名者依次入栈),而 trait 层的add_signers支持批量注册。
在脚本调用与交易构建器上的同款 API
with_inputs / with_outputs 并不局限于合约调用。从源码看,同一套 API 也覆盖:
- 脚本调用:
ScriptCall同样实现了with_inputs/with_outputs(packages/fuels-programs/src/calls/script_call.rs),并在构建时将其与generate_contract_inputs/generate_contract_outputs组合,索引对齐逻辑与合约调用一致; - 底层交易构建器:如果你不经过
methods()调用链,而是直接操作ScriptTransactionBuilder等 builder,也可以调用 trait 上的with_inputs/with_outputs/with_witnesses以及只读访问器inputs()/outputs()来检查当前输入输出列表(transaction_builders.rs#L172-L178)。
多合约调用(multicall)场景下,CallHandler 的另一组 with_inputs / with_outputs 实现(call_handler.rs#L413-L421)会把输入输出应用到多个 ContractCall 上,随后在 get_transaction_inputs_outputs 中通过 flat_map 汇总所有调用的自定义部分,即每个调用贡献的 inputs / outputs 会被整体拼接。
小结与参考
with_inputs(Vec<Input>)/with_outputs(Vec<Output>):以整体替换语义为当前调用挂载自定义交易输入/输出;自动生成的合约输入、change 输出等会排在其后,索引对齐由 SDK 维护(packages/fuels-programs/src/calls/utils.rs#L178-L211)。add_signer:登记签名者,用于为自定义 coin 输入生成 witness;重复注册同一地址会报错(packages/fuels-core/src/types/transaction_builders.rs#L191-L202)。- 调用链形态:
contract_instance.methods().method(args).with_inputs(...).with_outputs(...).add_signer(...).call().await,示例代码见 examples/contracts/src/lib.rs#L754-L763。
延伸阅读(均为仓库内相对路径):
- 官方文档原文:docs/src/calling-contracts/custom-inputs-outputs.md
- 合约调用总览:docs/src/calling-contracts/index.md
- 自定义资产转账(
add_custom_asset,与本文示例同处一个测试):docs/src/calling-contracts/custom-asset-transfer.md - 调用参数配置:docs/src/calling-contracts/call-params.md
- 变量输出策略:docs/src/calling-contracts/variable-outputs.md
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