首页
/ fuels-ts 合约调用中的多钱包与多 Provider 切换:Account/Provider 可替换机制源码解析

fuels-ts 合约调用中的多钱包与多 Provider 切换:Account/Provider 可替换机制源码解析

2026-09-05 16:12:42作者:裘晴惠Vivianne

本篇技术指南围绕 fuels-ts 中「以不同钱包或 Provider 调用同一个合约实例」这一主题展开:讲清楚如何在不重新实例化 Contract 的前提下,通过赋值 contract.accountcontract.provider 两个属性切换调用钱包与连接的 fuel-core 节点;并结合 @fuel-ts/program 包的源码,剖析每次合约调用时 accountprovider 各自在签名、出费、交易发送与节点查询中的具体作用路径,以及多节点环境下 Provider 优先级的判定规则。

背景:Contract 实例如何同时持有 Account 与 Provider

在 fuels-ts 中,对合约的每一次调用都需要回答两个独立的问题:

  1. 谁来签名并支付费用? —— 由合约实例上的 account(一个 Account/Wallet 对象)决定;
  2. 交易发到哪个节点? —— 由合约实例上的 provider(一个 Provider 对象,指向某个 fuel-core 实例的 URL)决定。

Contract 类把这两者设计为独立的、可公开读写的实例属性,这正是本文主题「切换钱包 / 切换 Provider」能够成立的前提。其源码位于 contract.ts,关键属性定义如下:

export default class Contract implements AbstractContract {
  /**
   * The provider for interacting with the contract.
   */
  provider!: Provider;

  /**
   * The account associated with the contract, if available.
   */
  account!: Account | null;
  // ...
}

构造函数接收一个 accountOrProvider: Account | Provider 参数,用于区分传入的是钱包还是 Provider。源码注释说明,SDK 刻意不使用 instanceof 判断(因为可能存在多版本或多 bundle 环境导致类引用不一致),而是通过鸭子类型检查 'provider' in accountOrProvider 来判定:

constructor(id: AddressInput, abi: JsonAbi | Interface, accountOrProvider: Account | Provider) {
  this.interface = abi instanceof Interface ? abi : new Interface(abi);
  this.id = new Address(id);

  if (accountOrProvider && 'provider' in accountOrProvider) {
    this.provider = accountOrProvider.provider; // 传入 Wallet:取其内部 provider
    this.account = accountOrProvider;
  } else {
    this.provider = accountOrProvider;           // 传入 Provider:无钱包
    this.account = null;
  }
  // ...
}

这里有两个重要推论:

  • 传入 Wallet 时,合约的 provider 取自该 Wallet 自身绑定的 Provider(在 fuels-ts 中 Wallet 创建时就会绑定一个 Provider,如 Wallet.fromPrivateKey(key, provider))。合约自身的 provider 属性并不会引用钱包内部的 provider 对象,而是持有其副本;
  • 只传 Provider 时,accountnull。这样的实例无法执行 .call(),但可以进行不花燃料的只读调用(见下文 get() 分析)。这一行为在 contract.test.ts 中有直接测试印证:用 new Contract(contract.id, contract.interface, contract.provider) 构造无账户合约,contractToCall.accountnullfunctions.sum(10, 5).get() 依然能成功返回 15

类型层面,AbstractProgram 抽象基类(types.ts)规定了 account: AbstractAccount | null 可为空、provider 必须存在,Contract 通过实现 AbstractContract 继承该契约。

切换钱包:修改 contract.account 属性

官方文档给出的标准做法是:把新钱包直接赋值给合约实例的 account 属性,即可让后续所有合约调用都以新钱包身份发起。文档对应的完整可运行示例位于 using-different-wallet.ts(该片段即文档正文中引用的 using-different-wallet 代码块),完整代码如下:

// #region using-different-wallet
import { Provider, Wallet } from 'fuels';

import { LOCAL_NETWORK_URL, WALLET_PVT_KEY } from '../../../../env';
import { ReturnContextFactory } from '../../../../typegend';

const provider = new Provider(LOCAL_NETWORK_URL);
const deployer = Wallet.fromPrivateKey(WALLET_PVT_KEY, provider);

const deployContract = await ReturnContextFactory.deploy(deployer);
const { contract } = await deployContract.waitForResult();

// Update the wallet
const newWallet = Wallet.generate({ provider });
contract.account = newWallet;
// #endregion using-different-wallet

console.log(
  'Contract account should equal the new wallet address',
  contract.account.address.toB256() === newWallet.address.toB256()
);

示例中的要素说明:

  • LOCAL_NETWORK_URLWALLET_PVT_KEY 来自文档工作区的环境配置 env.ts
  • ReturnContextFactory 由 fuels typegen 基于 Forc.toml 定义的 Sway 工程(return-context 等程序)生成,是典型的 typegen 类型化工厂用法;
  • 核心操作只有一行:contract.account = newWallet;。由于 account 是普通公开属性,赋值立即生效——之后该合约实例上任何 contract.functions.xxx(...).call() 调用,都会由 newWallet 负责签名与支付 gas 费用。

仓库测试中也大量使用同样的手法来验证钱包切换,例如 assemble-tx.test.ts 中的 contract.account = wallet1;,以及 predicate-with-contract.test.ts 中把合约账户换成收款地址 contract.account = receiver;

为什么切换 account 是安全的

base-invocation-scope.ts 的调用链可以确认,account 只在每次调用发起时被读取,而不是在实例化时固化:

  • .call() 提交交易前执行 assert(this.program.account, 'Wallet is required!'),然后通过 this.program.account.sendTransaction(...) 发送交易(L503-L528)。也就是说,谁被赋值在 contract.account 上,谁就是这笔交易的签名者和费用支付者;
  • 资金填充阶段 fundWithRequiredCoins() 同样动态读取:const account = (this.program.account ?? Wallet.generate({ provider })) as Account;L264-L283),并用 accountCoinQuantities + feePayerAccountprovider.assembleTx 完成自动出币。若未显式设置 feePayerAccount,则默认以合约的 account 作为 feePayer。

因此「先部署、后切换钱包」是完全支持的模式:部署者钱包完成部署后,把合约交给一个持有足够燃料的新钱包去执行后续调用(例如运营钱包持有 gas 代币,调用方钱包负责业务签名)。

没有钱包时能做什么:get() 与 dryRun() 的边界

切换钱包时,一个常见配套问题是「未充值/未解锁的钱包能调用什么」。contract.test.ts 中的一组用例给出了精确边界:

场景 行为 源码依据
.call()accountnull 抛出 'Wallet is required!' BaseInvocationScope.call() 开头的 assert(this.program.account, ...)
.simulate() 使用未充值钱包 抛出 INSUFFICIENT_FUNDS_OR_MAX_COINS("Insufficient funds or too many small value coins...") simulate 需要真实出币后 assembleTx
.simulate() 使用锁定钱包(Wallet.fromAddress 抛出 'An unlocked wallet is required to simulate a contract call.' simulate() 中检查 populateTransactionWitnessesSignature 方法是否存在
.get() 使用未充值钱包,甚至无钱包 正常返回结果且不消耗任何资金 get()generateFakeResources 伪造 UTXO,仅做 assembleTx 模拟

get() 的实现值得注意:它把 maxFee/gasLimit 归零、过滤掉 InputType.Coin 输入、为账户生成假资源(fake resources)后调用 provider.assembleTx 拿到 receipts。测试用例 should ensure "get" does not spend any funds 验证了调用前后余额完全一致。这意味着在切换钱包之前,你可以先让旧账户(哪怕未充值)用 .get() 预演调用,确认参数无误再换钱包执行 .call()

切换 Provider:修改 contract.provider 属性

与切换钱包完全对称,切换节点只需给合约实例的 provider 属性赋值一个新 Provider 实例:

const newProvider = new Provider(NEW_URL);
deployedContract.provider = newProvider;

(以上代码块原文出自文档正文;文档中同时注明该片段对应的历史测试已被移除/变更,属于待补片段,此处保留原始示例。)

provider 的赋值影响范围从源码看是全方位的——所有节点侧交互都经由合约自己的 provider 字段:

  • 调用前组装交易脚本时,updateScriptRequest() 通过 getProvider()this.program.provider,调用 provider.getChain() 获取共识参数(如 maxInputs)来生成合约调用脚本(base-invocation-scope.ts);
  • 出币阶段用 provider.getBaseAssetId() 确定基础资产、用 provider.assembleTx() 组装交易并计算 gas 价格(L277-L314);
  • 只读查询如 contract.getBalance(assetId) 也是直接委托 this.provider.getContractBalance(...)contract.ts)。

而交易最终的广播仍由 this.program.account.sendTransaction(...) 完成(见上文 .call() 分析)。因此切换 Provider 的典型用途是:同一钱包同时连接多个网络/节点(例如一个主网节点 + 一个本地 launchTestNode 节点),在同一个合约实例上按需切换目标节点,或使用自定义 Provider 包装器实现重试、代理等逻辑。

多节点共存时的 Provider 优先级规则

文档特别强调了一条优先级规则,原文如下:

Note: When connecting a different wallet to an existing contract instance, the provider used to deploy the contract takes precedence over the newly set provider. If you have two wallets connected to separate providers (each communicating with a different fuel-core instance), the provider assigned to the deploying wallet will be used for contract calls. This behavior is only relevant when multiple providers (i.e. fuel-core instances) are present and can be ignored otherwise.

翻译成实操语义:把「自带另一个 Provider 的新钱包」赋值到 contract.account 时,合约实例自身持有的 provider 并不会被新钱包的内部 provider 覆盖——合约调用仍然走原合约实例(即部署时)关联的那个 fuel-core 节点。

这一行为与源码完全吻合:

  1. 构造函数中,传入 Wallet 时是 this.provider = accountOrProvider.provider一次性拷贝,之后 contract.account 被替换成别的 Wallet,并不会触发 contract.provider 的重新赋值;
  2. 调用链上取节点永远走 getProvider()this.program.providerbase-invocation-scope.ts),新钱包的内部 provider 在整个调用链中从未被读取。

因此,如果你的两个钱包分别绑定不同的 fuel-core 实例,而期望「新钱包 + 新节点」一起生效,正确做法是两条赋值都执行

const newWallet = Wallet.fromPrivateKey(NEW_KEY, newProvider); // 新钱包绑定新节点
contract.provider = newProvider; // 显式切换合约的节点
contract.account = newWallet;     // 再切换钱包

只有当所有钱包都连同一个节点时,该优先级规则可以忽略(正如文档 Note 末句所述)。

小结与实操要点

  • Contract 实例的 accountprovider 是相互独立的公开属性(contract.ts),随时可分别赋值,且每次调用都动态读取,因此「部署后换钱包、换节点」都是受支持的常规模式;
  • 切换 account 改变的是签名者 + gas 费用支付者.call() 要求账户存在,.simulate() 还要求是解锁钱包;.get()/dryRun() 对未充值钱包甚至无钱包也工作,可作为切换前的零成本预演(contract.test.ts 用例);
  • 切换 provider 改变的是合约调用所连的 fuel-core 节点,影响 getChainassembleTxgetContractBalance 等一切节点侧交互;
  • 多节点环境下记住文档的优先级规则:仅替换 contract.account 不会替换 contract.provider,需要两者都显式赋值才能让「新钱包 + 新节点」成对生效。

参考文档与代码位置:using-different-wallets.mdusing-different-wallet.tscontract.tsbase-invocation-scope.tscontract.test.ts

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