当合约需要存储某些任意的数据的时候,通常的做法是声明一个 bytes
或者 string
链上存储变量(storage variable)并写入。这样会使用此合约的存储空间,这个方式很直观且容易理解,但当需要存储较大数据的时候这种方式可能会很昂贵。智能合约的存储空间是基于槽的,首次初始化每个字(32 字节)的数据会有 20k gas 的开销。那么存储一个长度为 256 字节的数据就会消耗 160k gas。
但如果你需要存储的是明确已知以后不会再需要改动其值的变量,那么在链上就还有另外一种较为便宜的存入数据的位置,并且这种存储同样可以让合约读取到被存入的数据。
每个已部署的智能合约的字节码同样是链上数据的一种,以一种与合约内变量所用的不同的空间存储。这个存储空间被用来存储合约的可执行字节码,编译时使用的常量,以及合约里被为声明为 immutable
的变量。然而,有一种方法可以将任意数据同样存储在此类空间里。
不同于常规的合约内数据存储规则,存储在此类代码空间内的数据只能够在创建合约的时候被设置一次,一旦设置后便不可修改,并且其容量也限制在大约 24KB。然而,这种存储花费的 gas 却可以比常规的成本小许多,尤其体现在存储较大的数据的时候。此类存储产生的费用的计算方式较为复杂,并且与具体的部署执行细节直接相关,但大致上可以用如下的方式来估算:
total_cost = 32k + mem_expansion_cost + code_deposit_cost
mem_expansion_cost = size * 3 + (size ** 2) / 512
code_deposit_cost = 200 * size
可见,存储一个大小为 256 字节的数据大约需要消耗 84k gas,几乎比常规存储方式所需的 160k 节省了一半!需要存的数据越大,节省越多。
但究竟我们应该怎样去利用这种方式去存储任意数据(非合约代码)呢? 在合约被部署的时候,构造函数首先被执行。构造函数是合约被初始化的一部分,通常是用来给状态变量赋值。但是 Solidity 没有在明面上展示给你的一件事情是,当构造函数被执行时,它会返回一个数据,此数据正是这个合约的永久性的可执行字节码,它将被存储在上述提到过的这个合约的代码存储空间中。
通过使用 assembly
你就可以对编译器内置的默认返回值进行修改来令它返回任何你想要的数据,这个数据即被存储在此合约的代码存储空间内。
contract StoreString {
constructor(string memory s) {
// 将这个s变量存储在此合约的代码空间中
assembly {
return(
add(s, 0x20), // 返回值的起始位置
mload(s) // 返回值的大小
)
}
}
}
随后,如果你去读取此合约被部署地址相应的代码数据,即会得到你当时指示其存储的那个任意的数据。注意,你必须要知道这个合约的部署地址才能够去获得那个数据。如下:
address(new StoreString("hello, world")).code // "hello, world"
尽管以此种方式所存储的数据大概率并非可被 EVM 执行的字节码,但是EVM本身是不知道这一点的。所以任何对此合约的调用都会尝试去执行你存在那里的数据因为它被 EVM 当作了可执行字节码,这个尝试会从第一个字节开始。那么,就有一定概率的可能性为,被存储在那里的数据的开头一系列bytes,不管是有意设计还是无意巧合,恰是一种可被 EVM 执行的有意义的字节码,那么他会被执行并产生某些后果。例如,如果存在那里的数据恰好以 33FF
开头,那么任何对此合约的调用都会执行合约的自我销毁,当然存在那里的数据也就被销毁了。出于这个原因,一种比较保险的处理方法是我们应当给我们想要存储的数据人为加上一个“首部数据”。00
这个字节作为首部数据是一个不错的选择,同时它也是 STOP
的操作码,会使执行立即中断。另外一个例子,使用 FE
(意为 INVALID
的操作码)作为首部数据同样有效。
contract StoreString {
constructor(string memory s) {
// 将这个s变量存储在此合约的代码空间中
// 并给它加上一个意为`STOP`的操作码,令任何EVM的执行终止
bytes memory p = abi.encodePacked(hex"00", s);
assembly {
return(
add(p, 0x20), // 返回值的起始位置
mload(p) // 返回值的大小
)
}
}
}
切记当你以后去读取存储在这里的数据时,要将那个人为加上的无意义的“首部数据”舍弃掉!
示例代码(非严格 ERC721)部署了一个 NFT 合约,可令用户铸造一个用户自己所提供的任意永久存于链上的图片作为元数据的 NFT。mint()
函数会触发一个合约代码存储空间来存放 base64 编码的 PNG 图像的代表数据。tokenURI()
函数随后会去那个合约地址的代码存储空间读取此数据,并使用语义 RFC3986 将其代表的图像嵌入到 URI 中。
- SSTORE2 库
- 一个便于使用的 Solidity 库,关于多种对代码存储空间的利用方式,并且提供了一种可将任意
bytes32
变量作为 key 传入用来产生一个已知地址的代码存储空间的方式。
- 一个便于使用的 Solidity 库,关于多种对代码存储空间的利用方式,并且提供了一种可将任意