fuels-rs 账户体系解析:ViewOnlyAccount 与 Account Trait 的资产查询和转移实战
本文以 fuels-rs(Fuel Network Rust SDK)的账户体系文档为核心,讲解 ViewOnlyAccount 与 Account 两个 trait 的职责划分与实现方式,并结合 packages/fuels-accounts 的源码实现,完整演示 transfer、force_transfer_to_contract、withdraw_to_base_layer 三个资产转移方法的实际调用流程、参数含义与底层交易构建机制,帮助你在 dApp、合约测试与链上集成中正确地为交易分配资源并处理手续费。
账户抽象:两个 Trait 的职责划分
fuels-rs 的账户模块位于 fuels-accounts 包 中,其核心是两层 trait 抽象:
ViewOnlyAccount:提供只读的账户查询接口,即查询余额、未花费的币(coins)与消息(messages)、交易记录等。该 trait 定义见 account.rs。Account:在ViewOnlyAccount基础上继承扩展,提供转移资产的能力。当你执行任何会产生链上交易的操作时,SDK 通常都需要一个Account实例,用来为交易分配所需资源——包括转账金额本身和交易手续费(gas)。
这两个 trait 由 SDK 中的两类账户类型实现:
从 trait 定义源码 可以看到,Account 直接继承 ViewOnlyAccount(trait Account: ViewOnlyAccount),并额外提供一个 add_witnesses 方法:当底层账户是钱包时,该方法会向交易构建器(TransactionBuilder)注入签名者;而 Predicate 的默认实现则是空操作(见 predicate.rs 中空的 impl Account for Predicate {}),因为谓词账户不产生 witness 签名。
ViewOnlyAccount 提供的只读能力
ViewOnlyAccount trait 的默认方法覆盖了日常查询需求(见 account.rs):
| 方法 | 作用 |
|---|---|
address() |
返回账户地址 |
get_transactions(request) |
分页查询该地址的交易记录 |
get_coins(asset_id) |
获取该账户指定资产的所有未花费 coins |
get_asset_balance(&asset_id) |
获取指定资产的可花费余额(各 UTXO 金额之和,而非 UTXO 列表) |
get_messages() |
获取账户名下所有未花费的消息(来自 base layer 的存款) |
get_balances() |
获取账户所有资产的可花费余额,返回 HashMap<String, u128> |
get_spendable_resources(asset_id, amount, excluded_coins) |
获取合计至少为 amount 的可花费资源,UTXO 数量经过优化以防止粉尘积累 |
get_asset_inputs_for_amount(...) |
上述资源转换为交易输入 Vec<Input>(由各实现自行完成) |
adjust_for_fee(tb, used_base_amount) |
为交易补充 base asset 输入以覆盖估算费用,并在需要时追加 change 输出 |
其中 get_balances 的用法示例来自 examples/wallets/src/lib.rs:
use fuels::prelude::*;
let wallet = launch_provider_and_get_wallet().await?;
// 查询单一资产余额
let asset_id = AssetId::zeroed();
let balance: u128 = wallet.get_asset_balance(&asset_id).await?;
// 查询所有资产的余额(键为资产 ID 的字符串形式)
let balances: HashMap<String, u128> = wallet.get_balances().await?;
let asset_balance = balances.get(&asset_id.to_string()).unwrap();
费用补偿机制:adjust_for_fee 的底层逻辑
文档中提到"执行 SDK 中会产生交易的操作时,需要提供账户来分配交易资源,包括交易费"。这一逻辑的具体实现就是 adjust_for_fee(见 account.rs),其工作流程为:
- 从 provider 获取共识参数,确定 base asset ID(手续费结算资产);
- 通过 accounts_utils.rs 中的
calculate_missing_base_amount估算最大费用(estimate_max_fee),计算当前输入中已有 base 资产与"最大费用 + 已预留金额"之间的差额;若交易没有其他可花费输入,则强制至少补充 1 个 base 单位(因为交易必须至少含一个可花费输入); - 若差额大于 0,调用
get_asset_inputs_for_amount补充新的 base asset 输入; - 最后由
add_base_change_if_needed追加 base asset 的 change 输出,把找零退给账户地址。
这解释了为什么转账时你不需要手动为 gas 预留资产——SDK 会在构建交易时自动补齐。
转移资产:三个核心方法
Account trait 定义了三个资产转移方法(见 account.rs),均以 TxPolicies 作为最后一个参数用于控制交易策略(如 gas limit):
transfer:将资产从本账户转移到目标地址;force_transfer_to_contract:将资产无条件转入合约;withdraw_to_base_layer:将 base asset 提款到 base layer(以太坊)链上的地址。
以下示例均基于 Wallet 账户,取自 examples/wallets/src/lib.rs 中对应的可运行测试。使用 Predicate 账户时流程类似,但在花费其名下资源前,通常需要先用 with_data 设置好 predicate data。
1. transfer:地址到地址的资产转账
use fuels::prelude::*;
// 启动本地测试节点,初始化 2 个钱包,各持有 1 枚币,每枚面值 2
let num_wallets = 2;
let coins_per_wallet = 1;
let coin_amount = 2;
let wallets = launch_custom_provider_and_get_wallets(
WalletsConfig::new(Some(num_wallets), Some(coins_per_wallet), Some(coin_amount)),
None,
None,
).await?;
// 从 wallet 1 向 wallet 2 转账 1 单位 base asset
let transfer_amount = 1;
let asset_id = Default::default();
let res = wallets[0]
.transfer(
wallets[1].address(),
transfer_amount,
asset_id,
TxPolicies::default(),
)
.await?;
// 验证:wallet 2 现在持有 2 枚未花费的币(原有的 1 枚 + 新转入的 1 枚)
let wallet_2_final_coins = wallets[1].get_coins(AssetId::zeroed()).await?;
assert_eq!(wallet_2_final_coins.len(), 2);
对应源码(见 account.rs)中 transfer 的执行链为:
get_asset_inputs_for_amount(asset_id, amount, None)—— 向 provider 请求合计至少amount的可花费输入;get_asset_outputs_for_amount(to, asset_id, amount)—— 构造两个输出:Output::coin(to, amount, asset_id)与Output::change(self.address(), 0, asset_id)。注意源码注释指出 change 的实际金额由节点计算,SDK 只需声明找零归属人和资产 ID;- 通过
ScriptTransactionBuilder::prepare_transfer构建脚本交易,并调用add_witnesses注入签名(Wallet 实现见 wallet.rs); adjust_for_fee补齐手续费;provider.send_transaction_and_await_commit(tx)提交并等待提交成功,最终返回TxResponse(含交易状态与交易 ID)。
若转账金额超过账户可花费余额,get_asset_inputs_for_amount 会失败,transfer 随之返回错误——这与源码文档注释 "Fails if amount for asset ID is larger than address's spendable coins" 一致。
2. force_transfer_to_contract:向合约转入资产
force_transfer_to_contract 用于将指定资产的余额直接打入合约(常见于为合约提供流动性或质押场景)。该方法在源码中带有明确警告:如果合约没有对应的提取/转出逻辑,转入的币可能永久丢失(见 account.rs 中的 "PERMANENT LOSS OF COINS" 注释)。
use fuels::prelude::*;
// 部署一个测试合约(此处使用 e2e 测试合约的二进制产物)
let contract_id = Contract::load_from(
"../../e2e/sway/contracts/contract_test/out/release/contract_test.bin",
LoadConfiguration::default(),
)?
.deploy(&wallet, TxPolicies::default())
.await?
.contract_id;
// 确认合约当前没有任何资产
let contract_balances = wallet
.try_provider()?
.get_contract_balances(&contract_id)
.await?;
assert!(contract_balances.is_empty());
// 向合约转入 300 单位的某资产
let amount = 300;
let asset_id = random_asset_id;
let res = wallet
.force_transfer_to_contract(contract_id, amount, asset_id, TxPolicies::default())
.await?;
// 验证:合约在该资产下恰好有 300 的余额
let contract_balances = wallet
.try_provider()?
.get_contract_balances(&contract_id)
.await?;
assert_eq!(*contract_balances.get(&asset_id).unwrap(), 300);
从 源码实现 可以看到,与普通转账不同,此方法在输入列表的最前面显式插入了一个 Input::contract(对应合约输入槽位),输出端则配套构造 Output::contract(0, ...) 与 Output::change,再经 ScriptTransactionBuilder::prepare_contract_transfer 组装。这一"合约输入必须在输入数组开头以保持索引一致"的约束,也正是 adjust_for_fee 文档注释中特别强调的前提(见 account.rs)。
3. withdraw_to_base_layer:提款到 base layer
withdraw_to_base_layer 将 base asset 从 Fuel 链提取到 base layer 链(以太坊)上的指定地址。源码实现(见 account.rs)通过 ScriptTransactionBuilder::prepare_message_to_output 构造一个消息输出(message output),交易提交成功后从收据中提取消息 nonce,最终返回 WithdrawToBaseResponse:
pub struct WithdrawToBaseResponse {
pub tx_status: Success,
pub tx_id: TxId,
pub nonce: Nonce,
}
nonce 是后续在 base layer 上消费该消息(完成实际提币)的关键凭证。完整示例:
use std::str::FromStr;
use fuels::prelude::*;
let wallets = launch_custom_provider_and_get_wallets(
WalletsConfig::new(Some(1), None, None),
None,
None,
).await?;
let wallet = wallets.first().unwrap();
let amount = 1000;
// 从字符串构造 base layer 目标地址
let base_layer_address = Address::from_str(
"0x4710162c2e3a95a6faff05139150017c9e38e5e280432d546fae345d6ce6d8fe",
)?;
// 提款 1000 单位 base asset 到该地址
let response = wallet
.withdraw_to_base_layer(base_layer_address, amount, TxPolicies::default())
.await?;
// 产出新区块以产生消息
let _block_height = wallet.provider().produce_blocks(1, None).await?;
// 通过交易 ID + nonce 从 provider 获取消息证明
let proof = wallet
.try_provider()?
.get_message_proof(&response.tx_id, &response.nonce, None, Some(2))
.await?;
// 验证金额与接收方
assert_eq!(proof.amount, amount);
assert_eq!(proof.recipient, base_layer_address);
Wallet 与 Predicate 两种实现的差异
理解两个 trait 的"实现者"是正确使用账户体系的关键。
Wallet:带锁状态的签名账户
Wallet 是一个泛型结构,其状态 S 决定了能力边界(见 wallet.rs):
Wallet<Unlocked<PrivateKeySigner>>(默认类型):持有Signer,同时实现ViewOnlyAccount与Account。其add_witnesses会将签名者加入交易构建器,签名在build阶段解析并写入 witnesses;Wallet<Locked>:仅保留地址,只实现ViewOnlyAccount。可通过wallet.lock()从解锁态转换为锁定态,适合只需查询余额、不可发起交易的场景。
相关构造方式包括 Wallet::new(signer, provider)、Wallet::random(rng, provider) 以及 Wallet::new_locked(addr, provider)(见 wallet.rs)。SDK 的单元测试 sign_tx_and_verify 验证了完整签名链路:向构建器添加 signer → build 解析签名写入 witness → 从 witness 提取的签名与手动对交易 ID 签名完全一致,并可通过 signature.recover 还原出签名地址,印证了"账户为交易分配签名资源"的机制。
Predicate:以数据验证代替签名
Predicate 结构(见 predicate.rs)包含三个核心字段:address(由代码哈希推导,Self::calculate_address(&code) 即 fuel_tx::Input::predicate_owner)、code 与 data。其使用模式为:
let predicate = Predicate::load_from("out/release/my_predicate.bin")?
.with_data(data) // 设置谓词验证所需的运行时数据
.with_provider(provider);
关键差异在 get_asset_inputs_for_amount 的实现:Wallet 将资源映射为 Input::resource_signed,而 Predicate 映射为 Input::resource_predicate(resource, code, data)(见 predicate.rs)。这意味着 predicate 花费资源时,节点会执行其代码对 data 进行验证而非检查 witness 签名——这正是文档中"Predicate 账户在花费资源前可能需要先设置 predicate data"的底层原因。此外 Predicate::try_provider 在 provider 未设置时会返回错误,因此使用 with_provider 完成绑定是查询与转账的前提。
小结
fuels-rs 用 ViewOnlyAccount(查询)与 Account(查询 + 转移 + 签名/谓词授权)两个 trait 抽象了账户能力,Wallet 与 Predicate 分别代表"签名授权"和"数据验证授权"两条落地路径。三个转移方法各有明确语义:transfer 做地址间转账并自动找零、force_transfer_to_contract 以合约输入槽位将资产打入合约(注意资产可能不可逆)、withdraw_to_base_layer 通过消息输出把 base asset 提回 base layer 并以 nonce 作为后续消费凭证。所有方法都会经由 adjust_for_fee 自动补齐手续费输入,你只需正确传入 TxPolicies 控制交易策略即可。相关实现集中于 packages/fuels-accounts/src/account.rs、wallet.rs 与 predicate.rs,可运行示例见 examples/wallets/src/lib.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