fuels-ts 中的合约 Storage Slots:部署时初始化合约存储的完整机制与实践
在 Fuel 链上,合约的存储状态在部署那一刻就被"冻结"进交易里——你可以通过部署选项 storageSlots 指定合约初始化的存储槽(key/value 对),从而让新部署的合约天生携带状态。本文基于 fuels-ts 仓库的官方文档 storage-slots.md 展开,讲清 storage slots 的两种指定方式(从 Sway 编译器生成的 JSON 导入、或在代码中内联书写)、Typegen 自动生成代码如何自动加载 storage slots,并深入 ContractFactory 的源码,揭示去重、排序、state root 与 contract ID 计算这一底层链路。
核心概念:Storage Slot 是什么
在 fuels-ts 中,一个存储槽被定义为 256 位的键与 256 位的值,其类型声明位于 packages/transactions/src/coders/storage-slot.ts:
export type StorageSlot = {
/** Key (b256) */
key: string;
/** Value (b256) */
value: string;
};
export class StorageSlotCoder extends StructCoder<{
key: B256Coder;
value: B256Coder;
}> {
constructor() {
super('StorageSlot', {
key: new B256Coder(),
value: new B256Coder(),
});
}
}
即每个槽都是 key: string(32 字节十六进制)加 value: string(32 字节十六进制)的十六进制字符串对。StorageSlotCoder 基于 B256Coder 结构编码,说明每个字段都严格是 32 字节——这与 Sway 存储模型中"每个存储项占一个 256 位槽位"的设计一一对应。
这些 storage slots 会作为合约部署交易(Create 交易)的一部分被编码进交易体中。从 交易编码器 的结构可以看到,storageSlots: StorageSlot[] 是交易编码/解码流程中的一等公民字段:
// packages/transactions/src/coders/transaction.ts
/** List of inputs (StorageSlot[]) */
storageSlots: StorageSlot[];
// 编码时
new ArrayCoder(new StorageSlotCoder(), value.storageSlotsCount.toNumber()).encode(...)
// 解码时
[decoded, o] = new ArrayCoder(new StorageSlotCoder(), storageSlotsCount.toNumber()).decode(...)
换句话说,storage slots 不是 SDK 的"装饰",而是真实写入链上交易、决定合约初始存储根(storage root)的链上数据结构。
方式一:从 Sway 编译器生成的 JSON 导入 storage slots
官方文档给出的第一个例子是:部署合约时,把 Sway 编译器(forc build)生成的 storage slots 直接传给 deploy 选项。完整示例来自仓库中的文档片段 override-storage-slots.ts:
import { Provider, Wallet } from 'fuels';
import { LOCAL_NETWORK_URL, WALLET_PVT_KEY } from '../../../../env';
import {
StorageTestContract,
StorageTestContractFactory,
} from '../../../../typegend';
const provider = new Provider(LOCAL_NETWORK_URL);
const deployer = Wallet.fromPrivateKey(WALLET_PVT_KEY, provider);
const deploymentTx = await StorageTestContractFactory.deploy(deployer, {
storageSlots: StorageTestContract.storageSlots,
});
await deploymentTx.waitForResult();
这里的 StorageTestContract.storageSlots 就是 Typegen 从 Sway 编译器输出的 JSON 文件内联进生成代码的静态属性。其来源链路是:
-
Sway 编译器为每个合约生成一份
*-storage_slots.json工件; -
Typegen 在收集合约文件时,把
-abi.json路径替换为-storage_slots.json并读取内容,逻辑见 collectStorageSlotsFilePaths.ts:filepaths.forEach((abiFilepath) => { const storageSlotsFilepath = abiFilepath.replace('-abi.json', '-storage_slots.json'); const storageSlotsExists = existsSync(storageSlotsFilepath); if (storageSlotsExists) { const storageSlots: IFile = { path: storageSlotsFilepath, contents: readFileSync(storageSlotsFilepath, 'utf-8'), }; storageSlotsFiles.push(storageSlots); } });注意两个细节:只有
programType为合约(ProgramTypeEnum.CONTRACT)时才会去收集 storage slots 文件;如果某个合约没有对应工件,则返回空集合,对应生成代码里storageSlots为空数组。 -
生成模板 factory.hbs 把这些内容织入工厂类的构造函数:
export class {{capitalizedName}}Factory extends __ContractFactory<{{capitalizedName}}> { static readonly bytecode = bytecode; constructor(accountOrProvider: Account | Provider) { super( bytecode, {{capitalizedName}}.abi, accountOrProvider, {{capitalizedName}}.storageSlots ); } static deploy (wallet: Account, options: DeployContractOptions = {}) { const factory = new {{capitalizedName}}Factory(wallet); return factory.deploy(options); } }也就是说,Typegen 生成的工厂类在构造
ContractFactory时就已经把storageSlots作为第四个参数传给了基类,static deploy只需传入钱包即可复用。
方式二:在代码中内联书写 storage slots
官方文档的第二个例子演示了不依赖 JSON 文件、直接在部署选项里手写存储槽的用法。示例来自 override-storage-slots-inline.ts(对应 Sway 侧带 storage 声明的测试合约 storage-test-contract):
import { Provider, Wallet } from 'fuels';
import { StorageTestContractFactory } from '../../../../typegend';
const provider = new Provider(LOCAL_NETWORK_URL);
const deployer = Wallet.fromPrivateKey(WALLET_PVT_KEY, provider);
const deploymentTx = await StorageTestContractFactory.deploy(deployer, {
storageSlots: [
{
key: '02dac99c283f16bc91b74f6942db7f012699a2ad51272b15207b9cc14a70dbae',
value: '0000000000000001000000000000000000000000000000000000000000000000',
},
{
key: '6294951dcb0a9111a517be5cf4785670ff4e166fb5ab9c33b17e6881b48e964f',
value: '0000000000000001000000000000003200000000000000000000000000000000',
},
{
key: 'b48b753af346966d0d169c0b2e3234611f65d5cfdb57c7b6e7cd6ca93707bee0',
value: '000000000000001e000000000000000000000000000000000000000000000000',
},
{
key: 'de9090cb50e71c2588c773487d1da7066d0c719849a7e58dc8b6397a25c567c0',
value: '0000000000000014000000000000000000000000000000000000000000000000',
},
{
key: 'f383b0ce51358be57daa3b725fe44acdb2d880604e367199080b4379c41bb6ed',
value: '000000000000000a000000000000000000000000000000000000000000000000',
},
],
});
await deploymentTx.waitForResult();
注意这里的 key/value 格式要求:两者都必须是 32 字节(64 个十六进制字符)的十六进制字符串,前缀 0x 可有可无(SDK 会统一处理,见下文)。value 是完整的 32 字节槽值,例如 '000000000000001e...' 实际承载的是一个 u64 = 30 之类的整数值,右侧大量零是补位。
源码纵深:部署请求如何消费 storageSlots
两种写法最终都汇入 ContractFactory.createTransactionRequest。contract-factory.ts 中的处理逻辑值得逐行理解:
createTransactionRequest(deployOptions?: DeployContractOptions & { bytecode?: BytesLike }) {
const storageSlots = (deployOptions?.storageSlots ?? [])
.concat(this.storageSlots)
.map(({ key, value }) => ({
key: hexlifyWithPrefix(key),
value: hexlifyWithPrefix(value),
}))
.filter((el, index, self) => self.findIndex((s) => s.key === el.key) === index)
.sort(({ key: keyA }, { key: keyB }) => keyA.localeCompare(keyB));
const options = {
salt: randomBytes(32),
...(deployOptions ?? {}),
storageSlots,
};
// ...
const bytecode = deployOptions?.bytecode || this.bytecode;
const stateRoot = options.stateRoot || getContractStorageRoot(options.storageSlots);
const contractId = getContractId(bytecode, options.salt, stateRoot);
这里有四个关键行为:
- 合并与优先级:
deployOptions.storageSlots排在this.storageSlots(Typegen 工厂传入的那份)之前,合并后再去重——去重规则是"保留第一次出现的项"(findIndex(...) === index),因此部署时显式传入的槽位会覆盖工厂内置的同 key 槽位。 - 规范化:所有 key/value 都经
hexlifyWithPrefix统一为带0x前缀的十六进制字符串,所以内联写法里给不给0x都能工作。 - 去重 + 排序:按 key 去重后按 key 的字典序排序。排序不是可有可无的:
getContractStorageRoot要基于这套槽位计算合约的初始 state root,而 Merkle 根的计算对输入顺序敏感,排序保证了相同输入集合得到确定性的根。 - ID 决定于 state root:
contractId = getContractId(bytecode, salt, stateRoot),state root 又来自存储槽。这意味着改了 storage slots 就会得到不同的 contractId——初始状态是合约身份的一部分。若你显式传入stateRoot选项,则会跳过getContractStorageRoot的自动计算。
此外,部署入口 deploy 会根据链上 consensusParameters.contractParameters.contractMaxSize 自动在 deployAsCreateTx 与 deployAsBlobTx(分块 + loader 合约)之间选择,详见 deploying-contracts.md;无论哪条路径,storage slots 都走上面同一套 createTransactionRequest 逻辑。
测试验证:slots 确实进入了交易
仓库的集成测试 storage-test-contract.test.ts 验证了这条链路的端到端正确性:部署时传入 StorageTestContract.storageSlots(来自 storage_slots.json),或手动构造自定义 storageSlots 数组部署,随后断言交易结果里的槽位与传入一致:
const { waitForResult: waitForDeploy } = await factory.deploy({ storageSlots });
// ...
expect(transactionResultConstructor.transaction.storageSlots).toEqual(expectedStorageSlots);
expect(transactionResultStatically.transaction.storageSlots).toEqual(expectedStorageSlots);
contract-factory.test.ts 中也存在同一模式(deploy({ storageSlots: StorageTestContract.storageSlots }) 以及内联数组的混用测试),证明"工厂内置 + 部署选项覆盖"的合并语义在实际部署中被反复验证。
Typegen 的自动加载(Auto-load)
官方文档最后一段指出:使用 Typegen 生成的代码会 自动加载 Storage Slots。从生成模板可以看到其实现方式:main.hbs 模板会把 storageSlotsJsonString(即 -storage_slots.json 的原始内容,缺省为 '[]')内联为 static readonly storageSlots 静态属性,factory.hbs 再把它传给 ContractFactory 构造函数(前文已展示)。
因此实际工程中你通常不需要手写任何 storage slots 代码——只要 Typegen 在构建时能找到合约的 *-storage_slots.json 工件,XxxFactory.deploy(wallet) 就会自动带上初始状态;只有在需要覆盖某些槽位(例如给多租户部署注入不同参数)时,才需要在 deploy 的选项中显式传入 storageSlots 数组来覆盖同 key 的默认值。
小结
- storage slot 是 32 字节 key + 32 字节 value 的十六进制对,类型与编码器见 packages/transactions/src/coders/storage-slot.ts,并作为 Create 交易的编码字段上链;
- 两种指定方式:
deployer侧传入 Typegen 从*-storage_slots.json生成的XxxContract.storageSlots,或直接在deploy({ storageSlots: [...] })中内联书写; ContractFactory.createTransactionRequest负责合并、hexlifyWithPrefix规范化、按 key 去重(部署选项优先)与排序,并据此计算 state root 与 contractId——初始存储直接影响合约 ID;- Typegen 工厂通过构造函数自动携带 storage slots,实现"零配置"的初始状态部署,这一机制由 factory.hbs 模板与 collectStorageSlotsFilePaths.ts 的文件收集逻辑共同保证,并有 storage-test-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