fuels-ts 合约调用中的多钱包与多 Provider 切换:Account/Provider 可替换机制源码解析
本篇技术指南围绕 fuels-ts 中「以不同钱包或 Provider 调用同一个合约实例」这一主题展开:讲清楚如何在不重新实例化 Contract 的前提下,通过赋值 contract.account 和 contract.provider 两个属性切换调用钱包与连接的 fuel-core 节点;并结合 @fuel-ts/program 包的源码,剖析每次合约调用时 account 与 provider 各自在签名、出费、交易发送与节点查询中的具体作用路径,以及多节点环境下 Provider 优先级的判定规则。
背景:Contract 实例如何同时持有 Account 与 Provider
在 fuels-ts 中,对合约的每一次调用都需要回答两个独立的问题:
- 谁来签名并支付费用? —— 由合约实例上的
account(一个Account/Wallet对象)决定; - 交易发到哪个节点? —— 由合约实例上的
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 时,
account为null。这样的实例无法执行.call(),但可以进行不花燃料的只读调用(见下文get()分析)。这一行为在 contract.test.ts 中有直接测试印证:用new Contract(contract.id, contract.interface, contract.provider)构造无账户合约,contractToCall.account为null时functions.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_URL与WALLET_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+feePayerAccount走provider.assembleTx完成自动出币。若未显式设置feePayerAccount,则默认以合约的account作为 feePayer。
因此「先部署、后切换钱包」是完全支持的模式:部署者钱包完成部署后,把合约交给一个持有足够燃料的新钱包去执行后续调用(例如运营钱包持有 gas 代币,调用方钱包负责业务签名)。
没有钱包时能做什么:get() 与 dryRun() 的边界
切换钱包时,一个常见配套问题是「未充值/未解锁的钱包能调用什么」。contract.test.ts 中的一组用例给出了精确边界:
| 场景 | 行为 | 源码依据 |
|---|---|---|
.call() 且 account 为 null |
抛出 '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 节点。
这一行为与源码完全吻合:
- 构造函数中,传入 Wallet 时是
this.provider = accountOrProvider.provider的一次性拷贝,之后contract.account被替换成别的 Wallet,并不会触发contract.provider的重新赋值; - 调用链上取节点永远走
getProvider()→this.program.provider(base-invocation-scope.ts),新钱包的内部 provider 在整个调用链中从未被读取。
因此,如果你的两个钱包分别绑定不同的 fuel-core 实例,而期望「新钱包 + 新节点」一起生效,正确做法是两条赋值都执行:
const newWallet = Wallet.fromPrivateKey(NEW_KEY, newProvider); // 新钱包绑定新节点
contract.provider = newProvider; // 显式切换合约的节点
contract.account = newWallet; // 再切换钱包
只有当所有钱包都连同一个节点时,该优先级规则可以忽略(正如文档 Note 末句所述)。
小结与实操要点
Contract实例的account与provider是相互独立的公开属性(contract.ts),随时可分别赋值,且每次调用都动态读取,因此「部署后换钱包、换节点」都是受支持的常规模式;- 切换
account改变的是签名者 + gas 费用支付者:.call()要求账户存在,.simulate()还要求是解锁钱包;.get()/dryRun()对未充值钱包甚至无钱包也工作,可作为切换前的零成本预演(contract.test.ts 用例); - 切换
provider改变的是合约调用所连的 fuel-core 节点,影响getChain、assembleTx、getContractBalance等一切节点侧交互; - 多节点环境下记住文档的优先级规则:仅替换
contract.account不会替换contract.provider,需要两者都显式赋值才能让「新钱包 + 新节点」成对生效。
参考文档与代码位置:using-different-wallets.md、using-different-wallet.ts、contract.ts、base-invocation-scope.ts、contract.test.ts。
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