fuels-rs Predicate(谓词)深度实战:在 Fuel 网络上实现脚本化条件托管与转账
本文以 fuels-rs(Fuel Network Rust SDK)为背景,系统讲解 Sway Predicate 的原理与完整落地路径:如何编写一个返回布尔值的纯函数式 Predicate,通过 forc build 编译生成字节码,用 SDK 的 abigen! 宏生成编码器与可配置常量结构体,最终实现"向谓词地址锁定资产、凭谓词数据条件化花费资产、用可配置常量在字节码层动态改写条件"的完整闭环。读完本文,你将掌握 Predicate::load_from、encode_data、with_data、with_configurables 以及 Account trait 转账等一整套可复制的实战技能。
Predicate 是什么:Sway 的"纯函数"式资产托管
在 Sway 中,Predicate 是一类只返回布尔值、且没有任何副作用(纯函数)的程序。它的核心作用是以"可编程校验"的方式托管资产:
- 一个 Predicate 的地址可以拥有资产,该地址由编译后的字节码(byte code)推导生成,与比特币中 P2SH(Pay-to-Script-Hash)地址的构造思路一致——地址本身就是"脚本哈希",而不是某个公钥或私钥的直接承诺。
- 任何人都可以像向普通地址转账一样,无缝地把资产发送到 Predicate 地址,无需任何特殊操作。
- 要想花费 Predicate 名下的资金,使用者必须同时提供两样东西:Predicate 的原始字节码(byte code),以及谓词数据(predicate data)。字节码在链上执行时会用到这些谓词数据,只有校验通过(即谓词返回真值),资金才能被转移。
这一模型的含义是:Predicate 天然适合做"锁定条件满足后才允许动用"的资金管理,例如托管、条件支付、多签风格的校验、时间/高度约束、跨层提款等场景,且不需要部署合约——条件逻辑被"烘焙"在字节码本身中。
在 fuels-rs 中,Predicate 的一切交互都由 packages/fuels-accounts/src/predicate.rs 中定义的 Predicate 类型承载,它同时实现了 SDK 的 ViewOnlyAccount 与 Account trait,因此既能查询余额,也能像钱包一样发起转账(详见下文)。
从一个最小 Predicate 开始:源码、编译与实例化
我们以仓库中最简单的谓词 e2e/sway/predicates/basic_predicate/src/main.sw 为例。它的逻辑非常直观:main 接收两个参数,只有当二者数值相等时谓词才返回 true(即允许花费):
predicate;
fn main(a: u32, b: u64) -> bool {
b == a.as_u64()
}
用 forc build 编译该 Sway 工程后,会得到 SDK 侧所需的两个产物(示例代码中反复引用的就是它们):
e2e/sway/predicates/basic_predicate/out/release/basic_predicate.bin—— 谓词字节码;e2e/sway/predicates/basic_predicate/out/release/basic_predicate-abi.json—— 谓词 ABI,供abigen!使用。
在 SDK 中创建一个可用的 Predicate 实例,最常用的是 Predicate::load_from(file_path)。从源码结构看,它的内部实现是:先用 fs::read 把字节码读入内存,再交给 from_code,由 calculate_address 通过 fuel_tx::Input::predicate_owner(code) 计算出谓词地址,最后把 code、空 data、空 provider 组装成 Predicate(参见 packages/fuels-accounts/src/predicate.rs)。
也就是说,Predicate 地址完全由字节码决定,字节码不同则地址不同;这一点也直接影响了后续"可配置常量"的工作方式(见下文)。
Predicate 结构体本身封装了四个字段:address、code、data 和(std 特性下的)provider,并提供了一组链式构造方法,with_provider 用于绑定节点 Provider,with_data 用于写入谓词数据。真实的实例化流程,先看下一节如何用 abigen! 生成数据编码器。
用 abigen! 生成 Encoder 与 Configurables
abigen! 宏会根据谓词的 JSON ABI,为我们在 Rust 侧生成该谓词涉及的全部类型,并额外生成两个自定义结构体:
- 一个 Encoder(编码器),提供
encode_data方法,能按顺序把谓词main函数的全部参数自动编码成predicate data; - 一个 Configurables(可配置常量)结构体,为谓词中声明的每个
configurable提供对应的with_xxx设置方法。
注意:
abigen!宏会在谓词name字段后面拼接Encoder与Configurables两个后缀。例如name = "MyPredicate"会生成名为MyPredicateEncoder与MyPredicateConfigurables的两个结构体。
下面是完整示例的第一步——配置两个钱包资产、启动本地节点,并用 abigen! 绑定上面的 basic_predicate(对应 examples/predicates/src/lib.rs 中的 predicate_data_setup 片段):
let asset_id = AssetId::zeroed();
let wallets_config = WalletsConfig::new_multiple_assets(
2,
vec![AssetConfig {
id: asset_id,
num_coins: 1,
coin_amount: 1_000,
}],
);
let wallets = &launch_custom_provider_and_get_wallets(wallets_config, None, None).await?;
let first_wallet = &wallets[0];
let second_wallet = &wallets[1];
abigen!(Predicate(
name = "MyPredicate",
abi = "e2e/sway/predicates/basic_predicate/out/release/basic_predicate-abi.json"
));
其中 AssetId::zeroed() 指基础资产(base asset)。WalletsConfig::new_multiple_assets(2, ...) 创建了两个钱包,每个钱包持有 1 枚面额 1_000 的 coin;launch_custom_provider_and_get_wallets 会连带启动一个节点实例并返回配置好的钱包。abigen!(Predicate(...)) 的 abi 指向前面 forc build 产物中的 ABI 文件。
从实现上看,Configurables 生成代码位于 packages/fuels-code-gen/src/program_bindings/abigen/configurables.rs:每个 with_xxx 方法最终会把"常量在字节码中的偏移量 + 新值数据"收集为一个 Configurable,并汇总成 fuels_core::Configurables 结构交给调用方。
编码谓词数据并把它加载进 Predicate
拿到 Encoder 后,就可以调用 MyPredicateEncoder::default().encode_data(...) 编码 main 函数的入参了。对于 basic_predicate,main(a: u32, b: u64) 只有在 a == b 时返回 true,因此这里传入两个相同的值 4096:
let predicate_data = MyPredicateEncoder::default().encode_data(4096, 4096)?;
let code_path = "../../e2e/sway/predicates/basic_predicate/out/release/basic_predicate.bin";
let predicate: Predicate = Predicate::load_from(code_path)?
.with_provider(first_wallet.provider().clone())
.with_data(predicate_data);
这段代码完成了三件事(对应 examples/predicates/src/lib.rs 的 with_predicate_data 片段):
- 用
Predicate::load_from(code_path)从字节码文件加载谓词(地址自动由字节码推导); - 用
.with_provider(...)绑定第一个钱包所连接的 Provider; - 用
.with_data(predicate_data)把编码好的predicate data写进谓词。
with_data 在 packages/fuels-accounts/src/predicate.rs 中仅仅是写入 self.data。这份数据在后面真正发起花费交易时,会被塞进交易输入中随字节码一起交给链上执行。
需要留意的是,Encoder 底层使用 SDK 的 ABI 编码器(ABIEncoder)并受 EncoderConfig 约束(如 max_tokens 上限)。仓库中 e2e/tests/predicates.rs 的 predicate_encoder_config_is_applied 测试就演示了用 MyPredicateEncoder::new(encoder_config) 传入自定义配置后,编码超限会报 token limit ... reached while encoding 错误;默认的 EncoderConfig 则足够覆盖常规参数。如果你在自定义 EncoderConfig,请保证它与数据规模匹配。
向 Predicate 地址锁定资产
谓词地址就是一个普通地址,任何账户都可以直接把资产转给它。下面用第一个钱包向该谓词转入 500 枚基础资产,并通过 get_asset_balance 验证到账(examples/predicates/src/lib.rs 的 predicate_data_lock_amount 片段):
// First wallet transfers amount to predicate.
first_wallet
.transfer(predicate.address(), 500, asset_id, TxPolicies::default())
.await?;
// Check predicate balance.
let balance = predicate.get_asset_balance(&AssetId::zeroed()).await?;
assert_eq!(balance, 500);
这一步之所以"像普通地址转账"一样简单,是因为 Predicate 实现了 ViewOnlyAccount trait——get_asset_balance 正是该 trait 提供的查询能力。至此,500 个资产被"锁"在由谓词字节码决定的地址上:任何人知道字节码但拿不出能让谓词返回 true 的 predicate data,就无法动用它们。
通过 Account trait 花费 Predicate 资产
花费环节是谓词最有价值的部分:SDK 的 Account trait 提供了统一的资产转移接口,Wallet 与 Predicate 都实现了它(详见 docs/src/accounts.md)。因此下面这段代码在 API 层面与"钱包转账"几乎没有差别:
let amount_to_unlock = 300;
predicate
.transfer(
second_wallet.address(),
amount_to_unlock,
asset_id,
TxPolicies::default(),
)
.await?;
// Second wallet balance is updated.
let balance = second_wallet.get_asset_balance(&AssetId::zeroed()).await?;
assert_eq!(balance, 1300);
在 SDK 内部,谓词花费与钱包花费的关键差异是输入构造方式。当 Predicate 需要为转账提供资产输入时,其 ViewOnlyAccount 实现会调用 get_asset_inputs_for_amount,并把选中的 coin/message 资源包装成带谓词证明的输入:
Input::resource_predicate(resource, self.code.clone(), self.data.clone())
也就是说,交易输入里会原样携带谓词字节码与之前设置的 predicate data(见 packages/fuels-accounts/src/predicate.rs)。链上执行时,VM 会运行这份字节码并结合 data 进行校验;校验通过则交易成立。
这种"调用方只需对普通 Address 转账,花费方需出示字节码 + data"的机制,正是 P2SH 式脚本托管在 Fuel 上的体现。仓库 e2e/tests/predicates.rs 中的 spend_predicate_coins_messages_basic、transfer_coins_and_messages_to_predicate 等测试完整验证了"转入 → 校验 data → 转出并正确扣除手续费"的整条链路。反之,如果 data 不满足谓词条件,交易会被链上拒绝,报错形如:
PredicateVerificationFailed(Panic { index: 0, reason: PredicateReturnedNonOne })
这一点可在 predicate_with_invalid_data_fails 测试中看到:encode_data(0, 100) 传入不相等参数后,transfer 返回该错误且接收方余额不变。
结合 docs/src/accounts.md:
Accounttrait 除transfer外还提供force_transfer_to_contract(转给合约)与withdraw_to_base_layer(跨层提款到底层链),这些方法对Predicate同样可用,只是动用谓词资产前必须先设置好满足条件的predicate data。
Configurable 常量:在字节码层动态改写花费条件
Predicate 与合约、脚本一样,可以声明 configurable 常量(可配置常量),在执行期间被改写。仓库中的谓词 e2e/sway/predicates/predicate_configurables/src/main.sw 给出了一个覆盖多种数据类型的完整示例:
configurable {
BOOL: bool = true,
U8: u8 = 8,
TUPLE: (u8, bool) = (8, true),
ARRAY: [u32; 3] = [253, 254, 255],
STRUCT: StructWithGeneric<u8> = StructWithGeneric {
field_1: 8,
field_2: 16,
},
ENUM: EnumWithGeneric<bool> = EnumWithGeneric::VariantOne(true),
}
fn main(
switch: bool,
u_8: u8,
some_tuple: (u8, bool),
some_array: [u32; 3],
some_struct: StructWithGeneric<u8>,
some_enum: EnumWithGeneric<bool>,
) -> bool {
switch == BOOL && u_8 == U8 && some_tuple.0 == TUPLE.0 && some_tuple.1 == TUPLE.1 && some_array[0] == ARRAY[0] && some_array[1] == ARRAY[1] && some_array[2] == ARRAY[2] && some_struct == STRUCT && some_enum == ENUM
}
可以看到,main 的行为是:只有当每个入参都与对应 configurable 常量的最新值相等时,谓词才返回 true。这意味着你可以编译一次谓词,之后通过"改写常量"来切换有效的解锁参数,而不必为每一种参数重新编译并更换地址。
每个常量对应一个 with 方法
SDK 会为每个 configurable 常量生成一个专用的 with 方法:常量 U8 会得到 with_U8,其参数类型与 Sway 中声明的一致(此处是 u8),依此类推。下面是仓库测试 e2e/tests/predicates.rs 中 predicate_configurables 用法的核心片段——先把若干 with 方法链接起来构造一组新常量,再用与常量一致的参数编码 predicate data,最后整体更新谓词:
abigen!(Predicate(
name = "MyPredicate",
abi = "e2e/sway/predicates/predicate_configurables/out/release/predicate_configurables-abi.json"
));
let new_tuple = (16, false);
let new_array = [123, 124, 125];
let new_struct = StructWithGeneric {
field_1: 32u8,
field_2: 64,
};
let new_enum = EnumWithGeneric::VariantTwo;
let configurables = MyPredicateConfigurables::default()
.with_U8(8)?
.with_TUPLE(new_tuple)?
.with_ARRAY(new_array)?
.with_STRUCT(new_struct.clone())?
.with_ENUM(new_enum.clone())?;
let predicate_data = MyPredicateEncoder::default()
.encode_data(true, 8u8, new_tuple, new_array, new_struct, new_enum)?;
let mut predicate: Predicate = Predicate::load_from(
"sway/predicates/predicate_configurables/out/release/predicate_configurables.bin",
)?
.with_data(predicate_data)
.with_configurables(configurables);
这里的 with_* 方法都返回 Result,因此逐个以 ? 解包后链式调用;encode_data 传入的入参必须与改写后的常量值保持一致(main 会逐项比对),否则花费仍会失败。这也揭示了谓词 data 与 configurables 的分工:
- configurables 决定"什么样的参数是合法的"(即约束条件本身);
- predicate data 是本次花费实际提供的、用来匹配约束的参数。
配置常量的底层原理:改写字节码并重算地址
为什么"改了常量地址还能对上"?秘密在 Predicate::with_configurables 的实现里(packages/fuels-accounts/src/predicate.rs):
pub fn with_configurables(mut self, configurables: impl Into<Configurables>) -> Self {
let configurables: Configurables = configurables.into();
configurables.update_constants_in(&mut self.code);
let address = Self::calculate_address(&self.code);
self.address = address;
self
}
它先把新的常量值直接写回字节码的对应偏移处,然后基于新字节码重新计算谓词地址。而 update_constants_in(定义于 packages/fuels-core/src/lib.rs)做的事情,本质上是按 Configurables 中记录的 (offset, data) 列表把新值逐个拷贝到二进制流里:
pub fn update_constants_in(&self, binary: &mut [u8]) {
for c in &self.offsets_with_data {
let offset = c.offset as usize;
binary[offset..offset + c.data.len()].copy_from_slice(&c.data)
}
}
因此,从调用方视角看,改变 configurable 常量后,谓词"名义地址"变化了(因为字节码内容变化);但只要你持有更新后的 Predicate 实例(其 address 已同步重算),向它转账、由它花费的流程完全不受影响。测试 predicate_configurables 验证了这一闭环:锁定资金后调用 transfer 花掉几乎全部余额(predicate_balance - 1,保留手续费),最终谓词地址余额归零、接收方正确到账。
谓词的更多真实形态与验证
Predicate 并非只适用于"两个数相等"这类玩具逻辑。结合仓库中的 Sway 谓词与端到端测试,可以梳理出它在真实场景中的几种形态:
- 多签校验:谓词内预置多个公钥哈希,花费时必须提供对应数量的签名,并用
ec_recover_address从签名恢复公钥逐一比对。该场景的完整讲解见姊妹文档 docs/src/predicates/send-spend-predicate.md,其 Sway 源码位于 e2e/sway/predicates/signatures/src/main.sw,SDK 侧示例见 examples/predicates/src/lib.rs(predicate_signers、predicate_coins、predicate_load等片段)。 - 代付交易费用:
pay_with_predicate、diff_asset_predicate_payment等测试展示了用谓词资产支付合约部署费与调用费,即"由锁定的资金替用户买单"。 - 定制输入输出 / 见证数据:
predicate_tx_input_output、predicate_witnesses相关测试展示了谓词读取交易首个 input/output 或手工追加的 witness 以决定是否放行,可用来实现更细粒度的交易级策略。 - 复杂动态类型:
predicate_vector、predicate_u128/u256、predicate_bytes等工程把main参数扩展到了向量、大整数、字节等类型,Encoder 均能正确编码(例如encode_data(12, 30, vec![2, 4, 42]))。 - 超大谓词(Blob 加载):当谓词字节码超过交易携带上限时,可将字节码先转换成 loader 并上传为 Blob,再用 loader 派生的代码构造谓词,参见
predicate_blobs、predicate_configurables_in_blobs测试。
最后提醒两个实战要点:
- 编译产物路径:上面所有示例依赖
forc build生成的.bin与-abi.json。不同工作目录下相对路径写法可能不同(examples 中使用../../e2e/...,e2e 测试中使用sway/...或e2e/sway/...),请以你实际运行位置为准。 - data 必须满足谓词约束:
encode_data只负责把参数编码成字节,不负责"让谓词通过"。参数不满足条件时,交易会在链上因PredicateReturnedNonOne之类的失败而回滚,接收方余额不变——这恰恰是谓词托管的安全边界所在。
从"纯函数托管"的概念、P2SH 式地址推导,到 Encoder 编码 data、Configurables 改写字节码常量,再到 Account trait 下的条件花费,一条基于 fuels-rs 的 Predicate 开发与使用路径已经完整打通。你可以直接以 examples/predicates/src/lib.rs 中的测试为蓝本运行体验,或基于 e2e/sway/predicates 目录下的各类谓词工程改造出自己的条件托管方案。
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 StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00