首页
/ fuels-rs 账户体系解析:ViewOnlyAccount 与 Account Trait 的资产查询和转移实战

fuels-rs 账户体系解析:ViewOnlyAccount 与 Account Trait 的资产查询和转移实战

2026-09-05 11:19:28作者:彭桢灵Jeremy

本文以 fuels-rs(Fuel Network Rust SDK)的账户体系文档为核心,讲解 ViewOnlyAccountAccount 两个 trait 的职责划分与实现方式,并结合 packages/fuels-accounts 的源码实现,完整演示 transferforce_transfer_to_contractwithdraw_to_base_layer 三个资产转移方法的实际调用流程、参数含义与底层交易构建机制,帮助你在 dApp、合约测试与链上集成中正确地为交易分配资源并处理手续费。

账户抽象:两个 Trait 的职责划分

fuels-rs 的账户模块位于 fuels-accounts 包 中,其核心是两层 trait 抽象:

  • ViewOnlyAccount:提供只读的账户查询接口,即查询余额、未花费的币(coins)与消息(messages)、交易记录等。该 trait 定义见 account.rs
  • Account:在 ViewOnlyAccount 基础上继承扩展,提供转移资产的能力。当你执行任何会产生链上交易的操作时,SDK 通常都需要一个 Account 实例,用来为交易分配所需资源——包括转账金额本身和交易手续费(gas)。

这两个 trait 由 SDK 中的两类账户类型实现:

  • Wallet:基于私钥签名,是最常见的账户形式;
  • Predicate:基于合约代码与数据,通过验证谓词而非签名来授权支出。

trait 定义源码 可以看到,Account 直接继承 ViewOnlyAccounttrait 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),其工作流程为:

  1. 从 provider 获取共识参数,确定 base asset ID(手续费结算资产);
  2. 通过 accounts_utils.rs 中的 calculate_missing_base_amount 估算最大费用(estimate_max_fee),计算当前输入中已有 base 资产与"最大费用 + 已预留金额"之间的差额;若交易没有其他可花费输入,则强制至少补充 1 个 base 单位(因为交易必须至少含一个可花费输入);
  3. 若差额大于 0,调用 get_asset_inputs_for_amount 补充新的 base asset 输入;
  4. 最后由 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 的执行链为:

  1. get_asset_inputs_for_amount(asset_id, amount, None) —— 向 provider 请求合计至少 amount 的可花费输入;
  2. 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;
  3. 通过 ScriptTransactionBuilder::prepare_transfer 构建脚本交易,并调用 add_witnesses 注入签名(Wallet 实现见 wallet.rs);
  4. adjust_for_fee 补齐手续费;
  5. 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,同时实现 ViewOnlyAccountAccount。其 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)、codedata。其使用模式为:

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 抽象了账户能力,WalletPredicate 分别代表"签名授权"和"数据验证授权"两条落地路径。三个转移方法各有明确语义:transfer 做地址间转账并自动找零、force_transfer_to_contract 以合约输入槽位将资产打入合约(注意资产可能不可逆)、withdraw_to_base_layer 通过消息输出把 base asset 提回 base layer 并以 nonce 作为后续消费凭证。所有方法都会经由 adjust_for_fee 自动补齐手续费输入,你只需正确传入 TxPolicies 控制交易策略即可。相关实现集中于 packages/fuels-accounts/src/account.rswallet.rspredicate.rs,可运行示例见 examples/wallets/src/lib.rs

登录后查看全文
热门项目推荐
相关项目推荐