首页
/ fuels-ts 中的合约 Storage Slots:部署时初始化合约存储的完整机制与实践

fuels-ts 中的合约 Storage Slots:部署时初始化合约存储的完整机制与实践

2026-09-05 14:28:35作者:瞿蔚英Wynne

在 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 文件内联进生成代码的静态属性。其来源链路是:

  1. Sway 编译器为每个合约生成一份 *-storage_slots.json 工件;

  2. 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 为空数组。

  3. 生成模板 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.createTransactionRequestcontract-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);

这里有四个关键行为:

  1. 合并与优先级deployOptions.storageSlots 排在 this.storageSlots(Typegen 工厂传入的那份)之前,合并后再去重——去重规则是"保留第一次出现的项"(findIndex(...) === index),因此部署时显式传入的槽位会覆盖工厂内置的同 key 槽位。
  2. 规范化:所有 key/value 都经 hexlifyWithPrefix 统一为带 0x 前缀的十六进制字符串,所以内联写法里给不给 0x 都能工作。
  3. 去重 + 排序:按 key 去重后按 key 的字典序排序。排序不是可有可无的:getContractStorageRoot 要基于这套槽位计算合约的初始 state root,而 Merkle 根的计算对输入顺序敏感,排序保证了相同输入集合得到确定性的根。
  4. ID 决定于 state rootcontractId = getContractId(bytecode, salt, stateRoot),state root 又来自存储槽。这意味着改了 storage slots 就会得到不同的 contractId——初始状态是合约身份的一部分。若你显式传入 stateRoot 选项,则会跳过 getContractStorageRoot 的自动计算。

此外,部署入口 deploy 会根据链上 consensusParameters.contractParameters.contractMaxSize 自动在 deployAsCreateTxdeployAsBlobTx(分块 + 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 等集成测试佐证。
登录后查看全文
热门项目推荐
相关项目推荐