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 的本质是把数据伪装成合约字节码存到链上。部署后的"合约"不是一个真正的合约——没有函数、没有逻辑,只有数据。
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(新合约地址)
read(pointer)
│
├── 1. 获取 pointer 合约的字节码长度:pointer.code.length
│
├── 2. 跳过第 1 个字节(STOP),读取剩余数据
│ └── readBytecode(pointer, DATA_OFFSET=1, length-1)
│
└── 3. 用 EXTCODECOPY 从合约字节码中拷贝数据到内存并返回
pointer 合约的 code:
偏移: 0 1 code.length
├─────┼────────────────────┤
│ 00 │ [user data ......] │
│STOP │ │
└─────┴────────────────────┘
↑
DATA_OFFSET = 1(读取时跳过)
作用:定义数据在合约字节码中的起始偏移量。值为 1,因为字节码的第 0 个字节是 STOP(0x00),不属于用户数据。
uint256 internal constant DATA_OFFSET = 1;
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
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 字节) |
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 到末尾的数据 |
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.length 且 end 也越界 |
require("OUT_OF_BOUNDS") |
先触发 require 越界检查 |
注意:另外两个 read 重载没有越界检查。如果传入的 start 超出数据范围,pointer.code.length - start 会下溢,导致 Panic(0x11)。
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) ← 从字节码拷贝数据到内存
read(pointer, start, end) 重载检查 end 不超出字节码长度| 风险 | 建议 |
|---|---|
| 数据不可修改 | 如需更新,必须重新 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 | 无上限 |
| 适合场景 | 大段不可变数据 | 小量可变状态 |
// 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
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!
