fuels-rs 交易依赖估算:让 SDK 自动为合约调用补齐外部契约与可变输出
本篇基于 fuels-rs 官方文档 tx-dependency-estimation.md 展开。当一个 Sway 合约方法在链上调用另一个外部合约、并向指定地址铸造资产时,这笔交易往往需要手工声明外部契约(external contracts)与可变输出(variable outputs),否则调用会直接 revert。本文完整继承官方文档的三个代码示例,并结合 call_handler.rs 与 utils.rs 的源码实现,讲清 determine_missing_contracts() 与 VariableOutputPolicy::EstimateMinimum 背后的 dry run 估算机制,帮助你在不事先知道依赖数量的情况下自动补齐交易依赖。
问题场景:缺少依赖的调用会 revert
官方文档给出的场景是:调用方合约 lib_contract_caller 的方法 mint_then_increment_from_contract 会先调用外部合约,随后向指定地址铸造资产。示例代码来自 examples/contracts/src/lib.rs,其中部署了两个 Sway 合约(位于 e2e/sway/contracts/lib_contract 与 e2e/sway/contracts/lib_contract_caller),并通过 abigen! 宏生成绑定:
abigen!(Contract(
name = "MyContract",
abi = "e2e/sway/contracts/lib_contract_caller/out/release/lib_contract_caller-abi.json"
));
let called_contract_id: ContractId = Contract::load_from(
"../../e2e/sway/contracts/lib_contract/out/release/lib_contract.bin",
LoadConfiguration::default(),
)?
.deploy(&wallet, TxPolicies::default())
.await?
.contract_id;
如果不声明任何依赖直接发起调用,SDK 会在后台构造交易并提交,链上执行因缺少外部契约输入而失败,返回 Err(Error::Transaction(Reason::Failure { .. })):
let address = wallet.address();
let amount = 100;
let response = contract_methods
.mint_then_increment_from_contract(called_contract_id, amount, address.into())
.call()
.await;
assert!(matches!(
response,
Err(Error::Transaction(Reason::Failure { .. }))
));
这个 revert 的根因是:FuelVM 执行 call 指令时要求被调用的外部合约必须已经出现在交易的 Inputs 中,否则触发 ContractNotInInputs panic;而铸造资产又需要为外部合约预留交易输出(output)。SDK 提供了两条解决路径:手工声明,或让 SDK 估算。
方案一:手动声明依赖
如果你已知外部合约的 ContractId 以及所需可变输出数量,可以用两个 builder 方法显式指定,这正是文档中 dependency_estimation_manual 示例的写法:
let response = contract_methods
.mint_then_increment_from_contract(called_contract_id, amount, address.into())
.with_variable_output_policy(VariableOutputPolicy::Exactly(1))
.with_contract_ids(&[called_contract_id])
.call()
.await?;
with_contract_ids(&[called_contract_id]):把外部契约 ID 加入交易输入。CallHandler还提供了with_contracts(&[&contract_instance, ...]),适用于持有abigen!生成的合约实例的场景,其优点是后续可以解码来自该外部合约的日志,参见 other-contracts.md;with_variable_output_policy(VariableOutputPolicy::Exactly(1)):声明该交易需要恰好 1 个可变输出。VariableOutputPolicy的默认值是Exactly(0),即默认不添加任何可变输出。
VariableOutputPolicy 定义于 packages/fuels-core/src/types/transaction_builders.rs:
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum VariableOutputPolicy {
/// Perform a dry run of the transaction estimating the minimum number of variable outputs to
/// add.
EstimateMinimum,
/// Add exactly these many variable outputs to the transaction.
Exactly(usize),
}
手动方式的局限很明显:你必须事先知道外部合约 ID 和输出数量的准确值。对于嵌套调用多层合约、或输出数量依赖运行时逻辑的复杂场景,逐一手工维护既不现实也容易出错。
方案二:依赖自动估算
文档给出的替代方案是链式组合两个调用:
.with_variable_output_policy(VariableOutputPolicy::EstimateMinimum).determine_missing_contracts()
组合后,SDK 会在后台执行多次模拟调用(dry run),把缺失的依赖估算出来并自动设置。完整的 dependency_estimation 示例如下,注意 determine_missing_contracts() 是异步方法,必须在 .call() 之前 await:
let response = contract_methods
.mint_then_increment_from_contract(called_contract_id, amount, address.into())
.with_variable_output_policy(VariableOutputPolicy::EstimateMinimum)
.determine_missing_contracts()
.await?
.call()
.await?;
示例测试随后验证了效果:两次调用成功后,钱包持有的 asset_id 余额从 amount 增长到 2 * amount,证明估算出的依赖确实让 mint 逻辑在链上正确执行(见 examples/contracts/src/lib.rs)。
源码解析:determine_missing_contracts 如何工作
在 packages/fuels-programs/src/calls/call_handler.rs 中,CallHandler 的 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),
}
}
机制可以拆解为三步:
-
先模拟执行一次。
self.simulate(Execution::realistic())会基于当前调用构造一笔完整交易,交给节点做 dry run。从 call_handler.rs 的simulate实现可以看到,它调用provider.dry_run_opt(tx, true, None, at_height),即在本地节点上按真实执行路径预演这笔交易,而不真正上链。 -
从失败收据中定位缺失合约。如果模拟失败且失败原因是
Reason::Failure,SDK 调用 find_ids_of_missing_contracts 扫描收据:pub fn find_ids_of_missing_contracts(receipts: &[Receipt]) -> Vec<ContractId> { receipts .iter() .filter_map(|receipt| match receipt { Receipt::Panic { reason, contract_id, .. } if *reason.reason() == PanicReason::ContractNotInInputs => { let contract_id = contract_id .expect("panic caused by a contract not in inputs must have a contract id"); Some(contract_id) } _ => None, }) .collect() }它专门匹配
PanicReason::ContractNotInInputs的 panic 收据,并从收据中取出缺失合约的ContractId。这正是前面 revert 示例中链上实际产生的错误类型。 -
把缺失合约追加进调用。每个找到的
ContractId都会通过append_external_contract追加到self.call的外部契约列表中,下一次build交易时就会被放入Inputs。
需要强调的一个行为边界是:如果模拟失败但不是「合约缺失」类的 Reason::Failure(例如其他 panic 或网络错误),该方法会把原始错误原样返回(Err(other_error) => Err(other_error)),不会吞掉错误,也不会做无效修复。
可变输出估算的代价与边界
VariableOutputPolicy::EstimateMinimum 的源码注释(packages/fuels-core/src/types/transaction_builders.rs)明确说明其原理与风险:
Estimation of variable outputs is performed by saturating the transaction with variable outputs and counting the number of outputs used.
即 SDK 用一批可变输出把交易「灌满」,执行一次 dry run 后统计实际被用到的输出数量,以此确定最小需求数。这也解释了文档开头所说的「以在后台运行多次模拟调用的为代价」。同一段注释还给出了官方警告:如果脚本会自省可变输出的数量并据此调整逻辑(理论上可以一直铸造输出直到把所有可变输出占满),这种估算将基本失效——此时应改为 Exactly(n) 手动指定。
适用范围:script 调用与 multicall
文档末尾的 Note 指出,with_variable_output_policy 与 determine_missing_contracts 同样适用于 script 调用和多调用(multicall)。这一点在源码中可以得到印证:
VariableOutputPolicy是ScriptTransactionBuilder的字段之一(见 transaction_builders.rs),script 交易构造流程同样消费该策略;MultiCall在 call_handler.rs 中实现了自己的determine_missing_contracts,与单次合约调用的逻辑一致,但使用simulate_without_decode()做模拟(多调用返回Vec值,模拟阶段无需解码),并对每个找到的缺失合约调用append_external_contract。
另一个必须注意的限制:determine_missing_contracts() 只负责把外部契约补进交易输入,不会启用来自外部合约的日志解码。如果你需要读取被调外部合约的日志,应改用 with_contracts(&[&contract_instance, ...]) 传入 abigen! 实例,详见 other-contracts.md。
小结与实践建议
- 依赖已知且稳定(固定外部合约、固定输出数量)时,优先使用
with_contract_ids+with_variable_output_policy(VariableOutputPolicy::Exactly(n)),零额外模拟开销,交易行为完全确定; - 依赖不确定或随链上状态变化时,使用
with_variable_output_policy(VariableOutputPolicy::EstimateMinimum)+determine_missing_contracts().await?,接受一次额外 dry run 的代价换取自动化; - 记住
determine_missing_contracts()返回的是Result<Self>,需要在调用链上await?后再.call(); - 若需要解码外部合约日志,或脚本会依据可变输出数量动态调整逻辑,自动估算不可靠,应回退到手动声明。
相关延伸阅读:variable-outputs.md 讲解可变输出的完整用法,other-contracts.md 讲解外部契约输入与日志解码,multicalls.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