深入剖析Solmate库 #07:SSTORE2.sol

SSTORE2.sol通过 CREATE 将数据部署为带 STOP 前缀的合约字节码,读取时用 EXTCODECOPY 提取,以 200 gas/字节替代 SSTORE 的 22,100 gas/32 字节,为链上大段不可变数据提供低成本持久化方案。

一、概述

版本说明:[solmate]:main分支 [commit: 89365b8],[forge-std]:v1.15.0

源码链接https://github.com/RevelationOfTuring/solmate/blob/main/src/utils/SSTORE2.sol

SSTORE2 是 solmate 的工具库,利用合约字节码作为廉价的持久化存储方案。核心思路是将数据部署为一个新合约的 runtime bytecode,读取时用 EXTCODECOPY 从合约字节码中提取数据。

解决的核心问题

传统 storage 方案:
  写入:SSTORE → 每 32 字节首次写入 22,100 gas(EIP-2200)
  读取:SLOAD  → 每 32 字节 2,100 gas

SSTORE2 方案:
  写入:部署合约 → 每字节仅 200 gas
  读取:EXTCODECOPY → 100 gas 基础 + 3 gas/字(32 字节)

结论:数据量越大,SSTORE2 越划算(约 > 4KB 时优势明显)

二、适用场景

适合 不适合
链上存储大段不可变数据(元数据、图片、配置) 需要修改/更新的数据(字节码不可变)
大数组/大字符串的链上持久化 需要频繁写入的场景(每次写入都是新合约部署)
一次性写入、多次读取的数据模式 需要在同一地址上更新数据(每次写入产生新地址)

三、合约结构总览

SSTORE2 (library)
│
├── Constants
│   └── DATA_OFFSET : uint256 = 1    ← 跳过字节码开头的 STOP(0x00)
│
├── Write Logic
│   └── write(data) → address        ← 将数据部署为合约字节码,返回 pointer
│
├── Read Logic(3 个重载)
│   ├── read(pointer) → bytes                  ← 读取全部数据
│   ├── read(pointer, start) → bytes           ← 从 start 读到末尾
│   └── read(pointer, start, end) → bytes      ← 读取 [start, end) 范围
│
└── Internal Helper
    └── readBytecode(pointer, start, size) → bytes  ← 底层 EXTCODECOPY 封装

四、核心原理:字节码即存储

SSTORE2 的本质是把数据伪装成合约字节码存到链上。部署后的"合约"不是一个真正的合约——没有函数、没有逻辑,只有数据。

4.1 写入流程

write(data)
  │
  ├── 1. 构造 runtimeCode = [STOP(0x00)] ++ [data]
  │      └── STOP 确保合约不可被意外调用执行
  │
  ├── 2. 构造 creationCode = [initcode(11字节)] ++ [runtimeCode]
  │      └── initcode 的作用:从 code 区提取 runtimeCode,返回给 CREATE
  │
  ├── 3. CREATE 部署
  │      ├── 执行 initcode → RETURN runtimeCode
  │      └── CREATE 将 runtimeCode 写入链上 → 成为新合约的 deployed code
  │
  └── 4. 返回 pointer(新合约地址)

4.2 读取流程

read(pointer)
  │
  ├── 1. 获取 pointer 合约的字节码长度:pointer.code.length
  │
  ├── 2. 跳过第 1 个字节(STOP),读取剩余数据
  │      └── readBytecode(pointer, DATA_OFFSET=1, length-1)
  │
  └── 3. 用 EXTCODECOPY 从合约字节码中拷贝数据到内存并返回

4.3 pointer 合约的字节码布局

pointer 合约的 code:
偏移:  0     1                code.length
      ├─────┼────────────────────┤
      │ 00  │ [user data ......] │
      │STOP │                    │
      └─────┴────────────────────┘
         ↑
        DATA_OFFSET = 1(读取时跳过)

五、源码逐行解析

5.1 DATA_OFFSET

作用:定义数据在合约字节码中的起始偏移量。值为 1,因为字节码的第 0 个字节是 STOP(0x00),不属于用户数据。

uint256 internal constant DATA_OFFSET = 1;

5.2 write

function write(bytes memory data) internal returns (address pointer) {
    // 在数据前加一个 STOP(0x00)操作码
    // 作用:如果有人尝试 call 这个合约,EVM 遇到 STOP 会立即停止
    bytes memory runtimeCode = abi.encodePacked(hex"00", data);

    // 构造 creationCode = initcode(11 字节)++ runtimeCode
    // initcode 的作用:将 runtimeCode 从 code 区拷贝到内存,返回给 CREATE
    bytes memory creationCode = abi.encodePacked(
        hex"60_0B_59_81_38_03_80_92_59_39_F3", // initcode(11 字节)
        runtimeCode                             // [STOP] ++ [data]
    );

    /// @solidity memory-safe-assembly
    assembly {
        // CREATE 部署合约:
        //   - 0:不转 ETH
        //   - add(creationCode, 32):跳过 bytes 的 32 字节长度前缀
        //   - mload(creationCode):读取 creationCode 的长度
        // CREATE 执行 initcode → initcode RETURN runtimeCode → CREATE 将其写入链上
        pointer := create(0, add(creationCode, 32), mload(creationCode))
    }

    // 部署失败则 revert(如 nonce 溢出、gas 不足等)
    require(pointer != address(0), "DEPLOYMENT_FAILED");
}

作用:将任意字节数据部署为一个新合约的字节码,返回合约地址作为"存储指针"。

参数

参数 类型 含义
data bytes memory 要存储的任意字节数据

返回值

类型 含义
address 新部署合约的地址(pointer),用于后续读取

initcode 逐字节解析

hex"60_0B_59_81_38_03_80_92_59_39_F3"(共 11 字节)

指令             栈(左=栈顶)                                        说明
──────────────────────────────────────────────────────────────────────────────────────────────
PUSH1 0x0B      [codeOffset]                                       runtimeCode 从偏移 11 开始
MSIZE           [0, codeOffset]                                    获取空闲内存起始位置(内存未使用,返回 0),作为后续 CODECOPY 的 destOffset
DUP2            [codeOffset, 0, codeOffset]                        复制 codeOffset(后面 CODECOPY 还要用)
CODESIZE        [codeSize, codeOffset, 0, codeOffset]              整个 creationCode 的总长度
SUB             [runtimeCodeLen, 0, codeOffset]                    codeSize - 11 = runtimeCode 的长度(= 1 字节 STOP + data.length)
DUP1            [runtimeCodeLen, runtimeCodeLen, 0, codeOffset]    复制一份长度给 RETURN 用
SWAP3           [codeOffset, runtimeCodeLen, 0, runtimeCodeLen]    重排栈,为 CODECOPY 准备参数
MSIZE           [0, codeOffset, runtimeCodeLen, 0, runtimeCodeLen] 再次获取空闲内存起始位置,作为 CODECOPY 的 destOffset 参数
CODECOPY        [0, runtimeCodeLen]                                CODECOPY(destOffset=0, offset=11, size=runtimeCodeLen)
RETURN          []                                                 RETURN(offset=0, size=runtimeCodeLen) → 返回给 CREATE

5.3 read(全量读取)

function read(address pointer) internal view returns (bytes memory) {
    // pointer.code.length = STOP(1 字节) + data 长度
    // 跳过 DATA_OFFSET(1) 字节,读取剩余全部
    return readBytecode(pointer, DATA_OFFSET, pointer.code.length - DATA_OFFSET);
}

作用:读取 pointer 合约中存储的全部数据。

参数

参数 类型 含义
pointer address write() 返回的合约地址

返回值

类型 含义
bytes memory 存储的原始数据(已跳过开头的 STOP 字节)

5.4 read(从 start 到末尾)

function read(address pointer, uint256 start) internal view returns (bytes memory) {
    // 加上 DATA_OFFSET 转换为字节码中的实际偏移量
    start += DATA_OFFSET;

    return readBytecode(pointer, start, pointer.code.length - start);
}

作用:读取 pointer 合约中从 start 位置开始到末尾的数据(切片读取)。

参数

参数 类型 含义
pointer address write() 返回的合约地址
start uint256 数据的起始偏移量(相对于用户数据,不含 STOP 字节)

返回值

类型 含义
bytes memory 从 start 到末尾的数据

5.5 read(范围切片 [start, end))

function read(
    address pointer,
    uint256 start,
    uint256 end
) internal view returns (bytes memory) {
    // 加上 DATA_OFFSET 转换为字节码中的实际偏移量
    start += DATA_OFFSET;
    end += DATA_OFFSET;

    // 越界检查:end 不能超过合约字节码的总长度
    require(pointer.code.length >= end, "OUT_OF_BOUNDS");

    return readBytecode(pointer, start, end - start);
}

作用:读取 pointer 合约中 [start, end) 范围的数据,左闭右开区间。唯一带越界检查的 read 重载。

参数

参数 类型 含义
pointer address write() 返回的合约地址
start uint256 数据的起始偏移量(相对于用户数据,不含 STOP 字节)
end uint256 数据的结束偏移量(不包含)

返回值

类型 含义
bytes memory [start, end) 范围内的数据

三种 revert 场景

场景 revert 类型 原因
end > data.length require("OUT_OF_BOUNDS") 越界
start > end Panic(0x11) arithmetic underflow end - start 下溢(Solidity 0.8+ checked arithmetic)
start > data.lengthend 也越界 require("OUT_OF_BOUNDS") 先触发 require 越界检查

注意:另外两个 read 重载没有越界检查。如果传入的 start 超出数据范围,pointer.code.length - start 会下溢,导致 Panic(0x11)

5.6 readBytecode

function readBytecode(
    address pointer,
    uint256 start,
    uint256 size
) private view returns (bytes memory data) {
    /// @solidity memory-safe-assembly
    assembly {
        // 获取空闲内存指针
        data := mload(0x40)

        // 更新空闲内存指针,防止后续内存操作覆盖我们的数据
        // 新指针 = data + 32(长度前缀)+ size,并向上对齐到 32 字节边界
        // and(x, not(31)) 等价于 x - (x % 32),即x对32向下取整,更省gas
        // 加31再对32向下取整 = 向上对齐到 32 的倍数
        mstore(0x40, add(data, and(add(add(size, 32), 31), not(31))))

        // 在 data 的前 32 字节存储数据长度(Solidity bytes 的内存布局要求)
        mstore(data, size)

        // 用 EXTCODECOPY 将pointer合约的字节码拷贝到内存中
        // extcodecopy(addr, destOffset, offset, size):
        //   - pointer:目标合约地址
        //   - add(data, 32):跳过 32 字节长度前缀,拷贝到数据区
        //   - start:字节码中的起始偏移量
        //   - size:要拷贝的字节数
        extcodecopy(pointer, add(data, 32), start, size)
    }
}

作用:底层读取函数,用 EXTCODECOPY 从合约字节码中读取指定范围的数据。

参数

参数 类型 含义
pointer address 目标合约地址
start uint256 字节码中的实际偏移量(调用方已加上 DATA_OFFSET)
size uint256 要读取的字节数

返回值

类型 含义
bytes memory 读取到的字节数据

为什么用 and(x, not(31)) 对齐

目标:将 x 向下取整到 32 的倍数
not(31) = 0xFFFF...FFE0(低 5 位全 0)
and(x, not(31)) → 清除 x 的低 5 位 → 等价于 x - (x % 32)

示例:
  x = 50  → and(50, not(31)) = and(50, 0xFFE0) = 32
  x = 64  → and(64, not(31)) = 64(已对齐)
  x = 65  → and(65, not(31)) = 64

加 31 再向下取整 = 向上取整:
  size = 10 → 10 + 32 + 31 = 73 → and(73, not(31)) = 64 → data 占 64 字节
  size = 32 → 32 + 32 + 31 = 95 → and(95, not(31)) = 64 → data 占 64 字节
  size = 33 → 33 + 32 + 31 = 96 → and(96, not(31)) = 96 → data 占 96 字节

六、完整调用流程图

write(data)
  │
  ├── abi.encodePacked(hex"00", data)
  │   └── runtimeCode = [STOP] ++ [data]
  │
  ├── abi.encodePacked(initcode, runtimeCode)
  │   └── creationCode = [11 字节 initcode] ++ [runtimeCode]
  │
  ├── CREATE(0, creationCode)
  │   ├── 执行 initcode:
  │   │   ├── CODESIZE → 获取 creationCode 总长度
  │   │   ├── SUB → 计算 runtimeCode 长度(codeSize - 11)
  │   │   ├── CODECOPY → 从 code 区偏移 11 拷贝 runtimeCode 到内存
  │   │   └── RETURN → 将内存中的 runtimeCode 返回给 CREATE
  │   │
  │   └── CREATE 将 runtimeCode 写入链上 → 成为 pointer 合约的 deployed code
  │
  ├── require(pointer != address(0))
  │   └── 失败场景:nonce 溢出(EIP-2681)、gas 不足
  │
  └── return pointer

read(pointer)                                  read(pointer, start)
  │                                              │
  ├── start = DATA_OFFSET(1)                     ├── start += DATA_OFFSET
  ├── size = code.length - 1                     ├── size = code.length - start
  └── readBytecode(pointer, 1, size)             └── readBytecode(pointer, start, size)

read(pointer, start, end)
  │
  ├── start += DATA_OFFSET
  ├── end += DATA_OFFSET
  ├── require(code.length >= end)  ← 越界检查
  └── readBytecode(pointer, start, end - start)

readBytecode(pointer, start, size)
  │
  ├── data := mload(0x40)                        ← 获取空闲内存指针
  ├── mstore(0x40, ...)                          ← 更新空闲内存指针(32 字节对齐)
  ├── mstore(data, size)                         ← 写入数据长度
  └── extcodecopy(pointer, data+32, start, size) ← 从字节码拷贝数据到内存

七、设计思想

7.1 极简主义

  • initcode 仅 11 字节,手写裸字节码,不依赖 Solidity 编译器生成

7.2 成本优化

  • 利用 EVM 的存储定价不对称性:合约字节码写入(200 gas/字节)远低于 storage 写入(22,100 gas/32 字节)
  • 读取同样更便宜:EXTCODECOPY(100 + 3 gas/字)vs SLOAD(2,100/32 字节)

7.3 安全防护

  • STOP(0x00) 前缀:防止 pointer 合约被意外调用执行数据
  • require 校验:部署失败时立即 revert
  • 越界检查:read(pointer, start, end) 重载检查 end 不超出字节码长度

7.4 不可变性

  • 数据一旦写入不可修改——合约字节码在部署后是 immutable 的
  • 这不是缺陷,而是设计选择:简化实现、消除状态管理复杂度、天然防篡改

八、安全注意事项

风险 建议
数据不可修改 如需更新,必须重新 write 并更新所有引用 pointer 的地方
数据上限约 24,575 字节 EIP-170 限制合约大小为 24,576 字节,减去 1 字节 STOP
read(pointer)read(pointer, start) 无越界检查 start 超出范围会导致 arithmetic underflow panic
pointer 地址不可预测 使用 CREATE 部署,地址取决于 deployer 的 nonce
pointer 可以被 call STOP 会阻止执行,但调用仍会消耗少量 gas

九、与同类方案对比

对比项 SSTORE2 传统 Storage
写入成本 200 gas/字节 22,100 gas/32 字节(首次)
读取成本 100 + 3 gas/字 2,100 gas/32 字节
可修改性 ❌ 不可变 ✅ 可修改
数据上限 ~24 KB 无上限
适合场景 大段不可变数据 小量可变状态

十、实战:使用 SSTORE2 存储链上元数据

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;

import {SSTORE2} from "solmate/utils/SSTORE2.sol";

/*
 * 使用 SSTORE2 在链上存储 NFT 元数据
 * 适用于需要完全链上存储的 NFT 项目
 */
contract OnChainMetadata {
    // tokenId → pointer(存储元数据的合约地址)
    mapping(uint256 => address) public metadataPointers;

    /*
     * @dev 为指定 tokenId 存储元数据
     * @param tokenId NFT 的 token ID
     * @param metadata 元数据的 JSON 字节
     */
    function setMetadata(uint256 tokenId, bytes memory metadata) external {
        // 将元数据部署为合约字节码,返回 pointer
        metadataPointers[tokenId] = SSTORE2.write(metadata);
    }

    /*
     * @dev 读取指定 tokenId 的元数据
     * @param tokenId NFT 的 token ID
     * @return 元数据的 JSON 字节
     */
    function getMetadata(uint256 tokenId) external view returns (bytes memory) {
        address pointer = metadataPointers[tokenId];
        require(pointer != address(0), "NO_METADATA");
        return SSTORE2.read(pointer);
    }

    /*
     * @dev 读取元数据的指定范围(切片读取,节省 gas)
     * @param tokenId NFT 的 token ID
     * @param start 起始偏移量
     * @param end 结束偏移量(不包含)
     * @return [start, end) 范围的数据
     */
    function getMetadataSlice(
        uint256 tokenId,
        uint256 start,
        uint256 end
    ) external view returns (bytes memory) {
        address pointer = metadataPointers[tokenId];
        require(pointer != address(0), "NO_METADATA");
        return SSTORE2.read(pointer, start, end);
    }
}

十一、测试实战

Mock 合约https://github.com/RevelationOfTuring/foundry-solmate/blob/main/src/utils/MockSSTORE2.sol

全部 Foundry 测试合约https://github.com/RevelationOfTuring/foundry-solmate/blob/main/test/utils/SSTORE2.t.sol

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论