fuels-rs 调用其他合约实战:with_contracts 与 with_contract_ids 的输入输出准备机制
当 Sway 合约的方法内部会调用其他合约时,交易必须包含相应的 Input::Contract / Output::Contract,否则运行时无法为该外部合约分配状态上下文。本文基于 fuels-rs 官方文档 docs/src/calling-contracts/other-contracts.md,完整讲解 CallHandler 提供的两个依赖注入方法 with_contracts 与 with_contract_ids 的用法差异、端到端测试的完整示例,并结合源码剖析其底层实现链路,帮助你在跨合约调用、日志解码和缺失合约修复场景中正确配置交易输入输出。
背景:为什么调用其他合约需要显式声明依赖
在 FuelVM 的模型中,合约本身不是"活的"进程:每次调用合约都需要把合约二进制和状态上下文重新载入交易。因此,如果你的合约方法内部会调用另一个合约(例如链上 A 合约调用 B 合约),你在构造这笔交易时就必须把 B 合约声明为交易的 Input 和 Output,这样 FuelVM 才能在执行过程中为 B 分配可读写内存与状态。
这一点与 EVM 的心智模型不同:在以太坊上你只需知道目标合约地址即可发起调用,而 Fuel 要求在交易层面显式列出所有参与计算的合约。fuels-rs 的 CallHandler(调用链构建器)替你完成这项工作——你只需要把外部合约"喂"给它,它会在构建 ScriptTransaction 时自动生成对应的输入输出配对。
两个方法:with_contracts 与 with_contract_ids
CallHandler 提供了两个设置外部合约依赖的构建器方法(builder method),可按链式方式插在 methods().xxx(...) 之后、.call() 之前:
with_contracts:传入 abigen 生成的合约实例
// 传入通过 abigen! 宏生成的合约实例
let response = contract_caller_instance
.methods()
.increment_from_contract(lib_contract_id, 42)
.with_contracts(&[&lib_contract_instance])
.call()
.await?;
with_contracts(&[&contract_instance, ...]) 要求你传入由 abigen! 宏创建的合约实例(即实现了 ContractDependency trait 的实例)。它的核心优势在于:不仅会登记合约 ID,还会把外部合约的 LogDecoder 合并进当前调用的日志解码器。这意味着外部合约发出的 log 事件以及 require 失败等回滚错误,都能被调用方合约正确传播并解码,而不是以裸字节形式出现在 receipts 中。
with_contract_ids:只传入合约 ID
// 只传入合约 ID,适合不需要解码外部合约日志的场景
let response = contract_caller_instance
.methods()
.increment_from_contract(lib_contract_id, 42)
.with_contract_ids(&[lib_contract_id])
.call()
.await?;
如果你不需要解码外部合约的日志,或者手边根本没有 abigen! 生成的合约实例(比如你只拿得到一个 ContractId),就可以改用 with_contract_ids(&[&contract_id, ...]),直接提供所需的合约 ID 列表。这是更轻量、更通用的方式。
端到端测试:test_contract_calling_contract
上面的两段代码并非凭空构造,它们来自仓库中的 E2E 测试 test_contract_calling_contract。该测试的完整流程值得完整阅读,因为它同时覆盖了两条路径和多种调用形态:
-
测试资产:测试通过
setup_program_test!宏同时部署了两个合约——被调用方 LibContract(提供increment与require方法)和调用方 LibContractCaller,并且把LibContract部署了两份(lib_contract_instance与lib_contract_instance2)以模拟对两个不同合约的并发依赖。 -
Sway 侧的实现:
ContractCaller通过abi(LibContract, contract_id.into())动态构造外部合约代理并发起调用,例如:fn increment_from_contract(contract_id: ContractId, value: u64) -> u64 { let contract_instance = abi(LibContract, contract_id.into()); contract_instance.increment(value) } -
单合约依赖(
with_contracts路径):let response = contract_caller_instance .methods() .increment_from_contract(lib_contract_id, 42) .with_contracts(&[&lib_contract_instance]) .call() .await?; assert_eq!(43, response.value); -
多合约依赖:测试中还演示了同时依赖两个
LibContract实例的写法——注意两个实例类型相同但部署位置不同,SDK 按传入顺序与合约 ID 对应:let response = contract_caller_instance .methods() .increment_from_contracts(lib_contract_id, lib_contract_id2, 42) // Note that the two lib_contract_instances have different types .with_contracts(&[&lib_contract_instance, &lib_contract_instance2]) .call() .await?; assert_eq!(86, response.value); // 43 + 43 -
仅 ID 依赖(
with_contract_ids路径):与第 3 步完全等价的调用,只是用with_contract_ids(&[lib_contract_id])替代合约实例,最终同样得到43。
这个测试验证了一个关键结论:两种写法在"能否成功调用"上没有区别,区别只在于是否携带日志解码能力。
源码剖析:从 CallHandler 到 ContractDependencyConfigurator
方法定义与日志解码器合并
两个方法都定义在 CallHandler 上,源码位于 call_handler.rs:
-
with_contract_ids(L174-L178):把 ID 切片拷贝成Vec后调用self.call.with_external_contracts(...),把外部合约登记到调用对象上。文档注释明确说明其效果是"创建fuel_tx::Input::Contract/fuel_tx::Output::Contract配对并写入交易"。 -
with_contracts(L188-L197):在同样的登记动作之外,多了一个循环——pub fn with_contracts(mut self, contracts: &[&dyn ContractDependency]) -> Self { self.call = self .call .with_external_contracts(contracts.iter().map(|c| c.id()).collect()); for c in contracts { self.log_decoder.merge(c.log_decoder()); } self }这里正是"外部合约日志可解码"的来源:每个传入实例的
LogDecoder被合并进CallHandler持有的解码器,后续CallResponse::logs()与回滚错误解析都会用到它。
ContractDependency trait:合约实例为什么"够用"
with_contracts 的参数类型是 &dyn ContractDependency,这个 trait 定义在 call_handler.rs L33-L36,极其精简:
// Trait implemented by contract instances so that
// they can be passed to the `with_contracts` method
pub trait ContractDependency {
fn id(&self) -> ContractId;
fn log_decoder(&self) -> LogDecoder;
}
也就是说,SDK 只需要合约实例暴露两样东西:合约 ID 和日志解码器。任何由 abigen! 生成的合约实例都实现了该 trait,因此可以无差别地传入 with_contracts。
ContractDependencyConfigurator:依赖如何落到交易上
with_external_contracts 本身来自密封 trait ContractDependencyConfigurator。它同时为 ContractCall 与 ScriptCall 提供实现,把 ID 写入各自结构体的 external_contracts 字段:
impl ContractDependencyConfigurator for ContractCall {
fn with_external_contracts(self, external_contracts: Vec<ContractId>) -> Self {
ContractCall {
external_contracts,
..self
}
}
}
在后续构建交易的环节,external_contracts 与主合约 ID 一起被收集为去重集合(见 utils.rs 中的 extract_unique_contract_ids),再由交易构建器转换为成对的 Input::Contract / Output::Contract。这也解释了为什么同一个合约即使被多次调用也只需登记一次。
进阶:缺失合约的自动探测——determine_missing_contracts
即使你声明了依赖,如果 Sway 侧通过运行时变量(例如配置常量或存储槽)动态确定了被调用合约,静态构建时也可能漏掉某些 ID。SDK 为此提供了一个自愈机制 determine_missing_contracts:
pub async fn determine_missing_contracts(mut self) -> Result<Self> {
match self.simulate(Execution::realistic()).await {
Ok(_) => Ok(self),
Err(Error::Transaction(Reason::Failure { ref receipts, .. })) => {
for contract_id in find_ids_of_missing_contracts(receipts) {
self.call.append_external_contract(contract_id);
}
Ok(self)
}
Err(other_error) => Err(other_error),
}
}
其工作方式:先以"真实执行"方式做一次模拟;若模拟因 PanicReason::ContractNotInInputs 失败,就通过 find_ids_of_missing_contracts 扫描 receipts,从每条 Receipt::Panic 中提取缺失的合约 ID 并自动追加为外部依赖,供下一次真实提交使用。这为"合约 ID 在运行时才确定"的场景提供了兜底手段。
选型建议与小结
| 场景 | 推荐方法 | 理由 |
|---|---|---|
持有 abigen! 生成的合约实例,且需要解码外部合约日志或 require 回滚信息 |
with_contracts(&[&instance, ...]) |
自动合并 LogDecoder,日志与回滚可解码 |
只有 ContractId,不关心外部合约日志 |
with_contract_ids(&[id, ...]) |
轻量、无需实例 |
| 被调用合约 ID 运行时才确定、静态无法枚举 | with_contract_ids 后配合 determine_missing_contracts |
模拟失败自动补齐缺失依赖 |
小结:fuels-rs 中"合约调用合约"的核心约束是交易必须携带外部合约的 Input/Output,而 CallHandler::with_contracts 与 with_contract_ids 分别以"合约实例"和"合约 ID"两种粒度替你完成这项登记,前者额外提供日志解码能力。参考文档 other-contracts.md、E2E 测试 contracts.rs 与源码 call_handler.rs,即可在项目中正确接入跨合约调用。
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