首页
/ fuels-ts 代理合约(Proxy Contracts):SRC14 拥有的代理手动部署与合约升级实战指南

fuels-ts 代理合约(Proxy Contracts):SRC14 拥有的代理手动部署与合约升级实战指南

2026-09-05 18:57:48作者:柯茵沙

在 Fuel 网络上,合约一经部署其字节码即不可变更。本文基于 fuels-ts 官方文档 Proxy Contracts,完整讲解如何利用 SRC14 标准实现的「拥有的代理合约」(Owned Proxy)手动完成代理部署、初始化、目标切换与合约升级的全流程,并结合仓库中 fuels CLI 的部署实现与生成的代理合约类型,说明每一步操作背后的机制(包括存储槽初始化这一关键细节),读完即可掌握 Fuel 上可升级合约的完整落地方案。

1. 整体流程与核心思路

官方推荐优先使用 fuels deploy 命令来部署和升级基于代理的合约,因为它会自动处理全部细节;但如果希望自行实现这套机制,官方文档给出了完整的手动流程。其推荐使用的底层代理是 SRC14 标准的合规拥有的代理合约(来自 Sway 标准实现仓库),该代理的 TypeScript 类型实现由 fuels 包直接导出为 Src14OwnedProxySrc14OwnedProxyFactory,二者在仓库中的实际定义位于 Src14OwnedProxy.tsSrc14OwnedProxyFactory.ts(由 @fuel-ts/recipes 包重新导出)。

手动部署代理合约的整体流程共五步:

  1. 部署你的业务合约;
  2. 部署代理合约;
  3. 将代理合约的 target(目标)设置为已部署业务合约的 ID;
  4. 后续所有调用都通过代理合约的 ID 进行;
  5. 升级时:部署新版本合约,再把代理合约的 target 指向新合约 ID。

关键注意(官方文档原文明确强调):当新版本合约新增了存储槽时,这些槽必须先通过一次写入操作在代理合约下初始化,之后才能被读取;否则相关交易会 revert。这一点在本文第 4 节的代理部署步骤中有具体体现。

2. 前置示例:Counter 业务合约

文档以计数器合约为例。第一个版本(v1)定义于仓库中的 counter/src/main.sw,包含一个存储槽 counter 和三个函数:

// #region proxy-1
contract;

abi Counter {
    #[storage(read)]
    fn get_count() -> u64;

    #[storage(write, read)]
    fn increment_count(amount: u64) -> u64;

    #[storage(write, read)]
    fn decrement_count(amount: u64) -> u64;
}

storage {
    counter: u64 = 0,
}

impl Counter for Contract {
    #[storage(read)]
    fn get_count() -> u64 {
        storage.counter.try_read().unwrap_or(0)
    }

    #[storage(write, read)]
    fn increment_count(amount: u64) -> u64 {
        let current = storage.counter.try_read().unwrap_or(0);
        storage.counter.write(current + amount);
        storage.counter.read()
    }

    #[storage(write, read)]
    fn decrement_count(amount: u64) -> u64 {
        let current = storage.counter.try_read().unwrap_or(0);
        storage.counter.write(current - amount);
        storage.counter.read()
    }
}
// #endregion proxy-1

注意 #[storage(read)] / #[storage(write, read)] 注解:它们声明了每个函数对存储的读写权限,这也是后续代理部署时「必须传入全部存储槽」的原因——存储槽列表决定了部署交易需要携带哪些初始化存储项。

3. 第一步:部署 Counter 合约

官方文档对应片段位于 snippets/proxy-contracts.ts(标记为 proxy-2 区域)。环境准备与业务合约部署代码如下:

// #region proxy-2
import {
  Provider,
  Wallet,
  Src14OwnedProxy,
  Src14OwnedProxyFactory,
} from 'fuels';

import { LOCAL_NETWORK_URL, WALLET_PVT_KEY } from '../../../env';
import {
  Counter,
  CounterFactory,
  CounterV2,
  CounterV2Factory,
} from '../../../typegend';

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

const counterContractFactory = new CounterFactory(wallet);
const deploy = await counterContractFactory.deploy();
const { contract: counterContract } = await deploy.waitForResult();
// #endregion proxy-2

要点说明:

  • Provider 连接本地测试网(LOCAL_NETWORK_URL),Wallet.fromPrivateKey 创建部署者账户;
  • CounterFactoryCounterV2Factory 是由 fuels-typegen 根据 Forc 编译产物生成的合约工厂(文档中位于 typegend 目录),工厂的 storageSlots 属性会携带该合约全部默认存储槽;
  • deploy() 返回的 deploy.waitForResult() 解构出 contract,其中 contract.id 就是随后要写入代理的 target。

4. 第二步:部署代理合约并设置目标

这是整个流程中最容易出错的环节。文档对应的 proxy-3 片段如下:

// #region proxy-3
/**
 * It is important to pass all storage slots to the proxy in order to
 * initialize the storage slots.
 */
const storageSlots = counterContractFactory.storageSlots.concat(
  Src14OwnedProxy.storageSlots
);
/**
 * These configurables are specific to our recommended SRC14 compliant
 * contract. They must be passed on deployment and then `initialize_proxy`
 * must be called to setup the proxy contract.
 */
const configurableConstants = {
  INITIAL_TARGET: { bits: counterContract.id.toB256() },
  INITIAL_OWNER: {
    Initialized: { Address: { bits: wallet.address.toB256() } },
  },
};

const proxyContractFactory = new Src14OwnedProxyFactory(wallet);
const proxyDeploy = await proxyContractFactory.deploy({
  storageSlots,
  configurableConstants,
});

const { contract: proxyContract } = await proxyDeploy.waitForResult();
const { waitForResult } = await proxyContract.functions
  .initialize_proxy()
  .call();

await waitForResult();
// #endregion proxy-3

结合 Src14OwnedProxy.ts 中自动生成的 ABI,可以深入理解这段代码的三个关键点:

  1. 存储槽必须合并业务合约与代理自身的槽位Src14OwnedProxy.storageSlots 是代理合约自带的 4 个初始存储槽(见该文件末尾的 storageSlots 常量),而 counterContractFactory.storageSlots 是业务合约的槽位。二者合并后传入 deploy(),保证代理部署完成后业务合约的所有存储槽都处于「已写入」状态——这正是第 1 节官方 Note 所说的「新增存储槽必须先写入才能读取」的工程化落实。
  2. 可配置常量(configurables)决定了代理的初始目标与所有者。从生成的 ABI 可见,INITIAL_TARGET 的类型是 Option<ContractId>INITIAL_OWNER 的类型是 State 枚举(Uninitialized / Initialized / Revoked),因此示例中 INITIAL_TARGET{ bits: <b256> }(即 Some(ContractId) 的编码形式),INITIAL_OWNERInitialized(Address) 变体。这两个值只是写入字节码的可配置常量区,并不会立即生效
  3. 必须调用 initialize_proxy() 完成初始化。ABI 中的文档注释明确:该方法用 INITIAL_TARGETINITIAL_OWNER 可配置常量的值写入存储,「此方法只能被调用一次」,若 proxy_owner 不是 State::Uninitialized 状态会 revert(对应错误枚举 InitializationError::CannotReinitialized)。这就是为什么部署代理后必须紧接着发起这次调用交易。

5. 第三步:通过代理 ID 调用合约

proxy-4 片段展示了通过代理访问业务合约的正确姿势:

// #region proxy-4
/**
 * Make sure to use only the contract ID of the proxy when instantiating
 * the contract as this will remain static even with future upgrades.
 */
const proxiedContract = new Counter(proxyContract.id, wallet);

const incrementCall = await proxiedContract.functions.increment_count(1).call();
await incrementCall.waitForResult();

const { value: count } = await proxiedContract.functions.get_count().get();
// #endregion proxy-4

console.log('count:', count.toNumber() === 1);

注意代码中的强调注释:必须始终用代理合约 ID 来实例化业务合约类。因为代理的 SRC14 语义是把未匹配自身 ABI 的调用全部转发(fallback)到当前 target,所以 Counter 实例虽然挂在代理 ID 上,increment_countget_count 实际执行的是被代理合约的字节码。这样即使未来 target 切换,前端持有的合约 ID 永远不变。

从源码结构看,这套代理合约共暴露五个方法:proxy_target()(读当前目标,Option<ContractId>)、set_proxy_target(new_target)(仅 proxy_owner 可调用)、proxy_owner()(读所有权状态)、initialize_proxy()(一次性初始化)、set_proxy_owner(new_proxy_owner)(转移或撤销所有权,不能设置为 Uninitialized)。其中 set_proxy_target 的文档注释明确写明「只能由 proxy_owner 调用,否则 revert」,并声明存储访问为 1 读 1 写。

6. 第四步:升级合约——部署 v2 并切换 target

升级场景中,我们给计数器新增了 increments 存储槽与 get_increments() 方法。v2 合约源码位于 counter-v2/src/main.sw,与 v1 相比的关键差异:

storage {
    counter: u64 = 0,
    increments: u64 = 0,   // 新增存储槽
}

以及新增的 get_increments()#[storage(read)])和在 increment_count 内对 increments+1 写入。

部署 v2 并切换代理 target(proxy-6 片段):

// #region proxy-6
const deployV2 = await CounterV2Factory.deploy(wallet);
const { contract: contractV2 } = await deployV2.waitForResult();

const updateTargetCall = await proxyContract.functions
  .set_proxy_target({ bits: contractV2.id.toB256() })
  .call();

await updateTargetCall.waitForResult();
// #endregion proxy-6

这里有两点值得注意:

  • CounterV2Factory.deploy(wallet) 是静态调用形式,等价于 new CounterV2Factory(wallet) 后再 deploy()
  • 切换 target 前,v2 新增的 increments 存储槽同样需要在代理下先行写入才能读取——与 v1 部署时合并 CounterV2Factory.storageSlots(或等价的 v1 槽位列表)一起传入代理部署参数,就是为升级后立即可用做准备的实践。

随后用同一个代理 ID 实例化 v2 合约实例(proxy-7 片段):

// #region proxy-7
/**
 * Again, we are instantiating the contract with the same proxy ID
 * but using a new contract instance.
 */
const upgradedContract = new CounterV2(proxyContract.id, wallet);

const incrementCall2 = await upgradedContract.functions
  .increment_count(1)
  .call();

await incrementCall2.waitForResult();

const { value: increments } = await upgradedContract.functions
  .get_increments()
  .get();

const { value: count2 } = await upgradedContract.functions.get_count().get();
// #endregion proxy-7

console.log('secondCount', count2.toNumber() === 2);
console.log('increments', increments);

可以看到:升级前后 get_count() 的返回值连续(1 次 + 1 次 = 2),证明代理下的存储状态在 target 切换后完整保留,且新槽位 increments 也正常工作——这正是「合约 ID 不变、存储不丢失」的可升级性核心承诺。

7. fuels deploy 的自动化实现对照

官方文档反复建议用 fuels deploy 完成同一件事,仓库中 deployContracts.ts 展示了它自动化的确切逻辑,可与本文手动流程一一对应:

  • 读取用户 Forc.toml 中的 [proxy] 配置:tomlContents?.proxy?.enabled 决定是否需要代理,tomlContents?.proxy?.address 记录已部署的代理合约 ID(测试夹具 proxy-contract/Forc.toml 即此类 Forc 项目示例);
  • address 已存在(升级场景):重新部署业务合约后,用 new Contract(proxyAddress, proxyAbi, wallet) 加载代理并调用 set_proxy_target({ bits: targetContract.id.toB256() })——与本文 proxy-6 片段完全一致;
  • address 不存在(首次部署):合并业务合约与代理的存储槽(targetStorageSlots.concat(proxyStorageSlots))、传入可配置常量部署代理、调用 initialize_proxy(),最后把代理合约 ID 回写到用户的 Forc.toml 中。

这印证了第 4 节所述机制:CLI 的自动化本质上就是把「合并存储槽 + 可配置常量部署 + initialize_proxy + set_proxy_target」这条手动链路封装了起来。另外,Src14OwnedProxy 类型本身是通过 build-proxy-contract.ts 脚本对预编译的代理 ABI 运行 fuels-typegen 生成,并将 fuels 绝对导入替换为相对路径后内置于 fuels 包,这也是用户能直接从 fuels 顶层导入 Src14OwnedProxyFactory 的原因。

8. 关键要点小结

环节 操作 依据 / 注意
部署业务合约 CounterFactory(wallet).deploy() contract.id 作为代理初始 target
部署代理 合并 factory.storageSlotsSrc14OwnedProxy.storageSlots,传入 INITIAL_TARGET / INITIAL_OWNER 可配置常量 新增存储槽必须先写入才能读取,否则交易 revert
初始化代理 调用一次 initialize_proxy() ABI 注释:只能调用一次,重复初始化会 revert
调用合约 一律 new Counter(proxyContract.id, wallet) 代理 ID 在升级中保持不变
升级 部署 v2 → set_proxy_target({ bits: v2.id.toB256() }) 仅代理 owner 可调用;存储状态跨版本保留
简化方案 fuels deploy + Forc.toml[proxy].enabled 自动化执行上述全部步骤并回写 Forc.toml

更多背景可进一步查阅 Fuel 官方文档体系中关于 forc-client 代理合约、Sway Upgradability Library 与 SRC-14(Simple Upgradable Proxies)标准的章节(原始文档 proxy-contracts.md 末尾列出了这些延伸阅读),并结合仓库中 proxy-contracts.ts 完整片段与 countercounter-v2 两个 Sway 示例,即可在本机复现整条部署-调用-升级链路。

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