fuels-ts 代理合约(Proxy Contracts):SRC14 拥有的代理手动部署与合约升级实战指南
在 Fuel 网络上,合约一经部署其字节码即不可变更。本文基于 fuels-ts 官方文档 Proxy Contracts,完整讲解如何利用 SRC14 标准实现的「拥有的代理合约」(Owned Proxy)手动完成代理部署、初始化、目标切换与合约升级的全流程,并结合仓库中 fuels CLI 的部署实现与生成的代理合约类型,说明每一步操作背后的机制(包括存储槽初始化这一关键细节),读完即可掌握 Fuel 上可升级合约的完整落地方案。
1. 整体流程与核心思路
官方推荐优先使用 fuels deploy 命令来部署和升级基于代理的合约,因为它会自动处理全部细节;但如果希望自行实现这套机制,官方文档给出了完整的手动流程。其推荐使用的底层代理是 SRC14 标准的合规拥有的代理合约(来自 Sway 标准实现仓库),该代理的 TypeScript 类型实现由 fuels 包直接导出为 Src14OwnedProxy 与 Src14OwnedProxyFactory,二者在仓库中的实际定义位于 Src14OwnedProxy.ts 与 Src14OwnedProxyFactory.ts(由 @fuel-ts/recipes 包重新导出)。
手动部署代理合约的整体流程共五步:
- 部署你的业务合约;
- 部署代理合约;
- 将代理合约的 target(目标)设置为已部署业务合约的 ID;
- 后续所有调用都通过代理合约的 ID 进行;
- 升级时:部署新版本合约,再把代理合约的 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创建部署者账户;CounterFactory与CounterV2Factory是由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,可以深入理解这段代码的三个关键点:
- 存储槽必须合并业务合约与代理自身的槽位。
Src14OwnedProxy.storageSlots是代理合约自带的 4 个初始存储槽(见该文件末尾的storageSlots常量),而counterContractFactory.storageSlots是业务合约的槽位。二者合并后传入deploy(),保证代理部署完成后业务合约的所有存储槽都处于「已写入」状态——这正是第 1 节官方 Note 所说的「新增存储槽必须先写入才能读取」的工程化落实。 - 可配置常量(configurables)决定了代理的初始目标与所有者。从生成的 ABI 可见,
INITIAL_TARGET的类型是Option<ContractId>,INITIAL_OWNER的类型是State枚举(Uninitialized/Initialized/Revoked),因此示例中INITIAL_TARGET取{ bits: <b256> }(即Some(ContractId)的编码形式),INITIAL_OWNER取Initialized(Address)变体。这两个值只是写入字节码的可配置常量区,并不会立即生效。 - 必须调用
initialize_proxy()完成初始化。ABI 中的文档注释明确:该方法用INITIAL_TARGET与INITIAL_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_count 与 get_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.storageSlots 与 Src14OwnedProxy.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 完整片段与 counter、counter-v2 两个 Sway 示例,即可在本机复现整条部署-调用-升级链路。
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