以太坊上存储256 bit数据大约消耗20k Gas、如此换算,仅1 GB存储资源要花费32,000ETH,按2021年12月14日ETH价格,大约要花费超过1亿美元。且不说当前身为贵族链Gas费很有可能继续水涨船高,放在早些年其Gas消耗也不是一笔小数目。因此,以太坊Gas优化是Dapp开发一直难绕的问题,也是Solidity开发者From Zero To Senior的必修之技。本文从我最近半年的以太坊项目开发的实战经验出发,从2个维度归纳了Gas优化的trick。
多数时候,定义大小为256bit的变量(以便填满整个slot)是gas消耗最优的选择。EVM一个slot 256bit,当数据未填满slot,则需额外操作将剩余slot填为0。此外,数值类型也会先转化为uint256再进行计算。
(1)某些时候,将struct中连续定义的变量打包在一个slot中也可以减少gas消耗。需注意的是,如果不是对这些变量同时进行读写操作,那么也难以实现gas优化的效果。
Coding Examples:
在struct内,需将存储在同一slot内的变量进行连续赋值。如此,编译器便会将小于等于256 bits的变量堆并赋值。需要注意的是,该优化只有在SOLC optimize模式开启,与此同时,function内的局部变量不超过16个才有效。
struct Data {
uint128 a;
uint128 b;
uint256 c;
}
Data public data;
constructor(uint128 a_, uint128 b_, uint256 c_) public {
Data.a = a_;
Data.b = b_;
Data.c = c_;
}
更简单粗暴的方式是直接用汇编语言将变量堆并。如此极限操作若非对gas锱铢必较的场景,大可不必使用,毕竟汇编语言大大降低了代码的可读性。
// 将变量堆并写入,类似编码过程
function encode(uint64 a_, uint64 b_, uint128 c_) public returns (bytes32 x) {
assembly {
// 从0x20开始的32 bytes内存储依次存入c_,b_,a_;但内存空间中位置顺序依此为a_,b_,c_
mstore(0x20, c_)
mstore(0x10, b_)
mstore(0x8, a_)
x := mload(0x20)
}
}
// 将变量读取,类似解码过程
function decode(bytes32 x_) public returns (uint64 a, uint64 b, uint128 c) {
assembly {
c := x_
mstore(0x18, x_)
a := mload(0)
mstore(0x10, x_)
b := mload(0)
}
}
(2)定义常量数据时添加constant关键字。带有constant关键字的变量在合约部署时,被存为合约的bytecode,不用占用storage的slot,同时也减免了SLOAD变量所需的200 gas消耗。需要注意的是,若将constant变量赋值为timestamp类型,则会在读取时根据运行的context动态变化。
Code Examples:
uint256 public constant PRESALE_START_DATE = now;
uint256 public constant PRESALE_END_DATE = PRESALE_START_DATE + 15 minutes;
uint256 public constant OWNER_CLAWBACK_DATE = PRESALE_START_DATE + 20 minutes;
以上三个变量存储的不是固定的timestamp,而是在now基础上的动态timestamp。
Merkle树在区块链的应用十分广泛,在此就不赘述。由于Merkle根信息包含了压缩之后的全局信息,因此,只需在合约中存储Merkle根信息即可完成信息的验证。
Code Examples:
bytes32 public merkleRoot;
function check(
bytes32 hash4,
bytes32 hash12,
uint256 a,
uint32 b,
bytes32 c,
string d,
string e,
bool f,
uint256 g,
uint256 h
)
public view returns (bool success)
{
bytes32 hash3 = keccak256(abi.encodePacked(a, b, c, d, e, f, g, h));
bytes32 hash34 = keccak256(abi.encodePacked(hash3, hash4));
require(keccak256(abi.encodePacked(hash12, hash34)) == merkleRoot, "Wrong Element");
return true;
}
需注意,当特定变量被频繁访问或更新时,将其直接存储在合约中是更直接高效的方式。此外,根部分支变量太多可能会超出一个transaction允许最大的slot占用量。
2. 无状态合约
将数据存在链上的方式不仅限于在合约中加入storage变量,通过transaction input和event calls也能将数据完整存于链上。因此,通过transaction input和event calls替代storage变量的读写也是gas优化的一条蹊径。
Code Examples:
下面的案例通过无状态合约定义function的input,作为transaction存储信息的接口;通过后端Filter将transaction记录中的input信息捕获。
// 存储状态的合约
contract DataStore {
mapping(address => mapping(bytes32 => string)) public store;
event Save(address indexed from, bytes32 indexed key, string value);
function save(bytes32 key, string value) {
store[msg.sender][key] = value;
Save(msg.sender, key, value);
}
}
// 无状态合约
contract DataStore {
function save(bytes32 key, string value) {}
}
# 安装transaction解析函数:InputDataDecoder
npm install ethereum-input-data-decoder
// 定义transaction input解码函数
const decoder = new InputDataDecoder(abi);
const decodeInput = input => decoder.decodeData(input);
// 定义input数据处理函数
const processArgs = input =>
input.inputs.map((arg, i) => {
const type = input.types[i];
if (type === "string") {
return arg;
}
if (type === "bytes32") {
const toHex = `0x${arg.toString("hex")}`;
return web3.toUtf8(toHex);
}
return arg;
});
// 后端存储的接口abi信息
const abi = [
{
constant: false,
inputs: [
{ name: "key", type: "bytes32" },
{ name: "value", type: "string" }
],
name: "save",
outputs: [],
payable: false,
type: "function"
}
];
// transaction input信息捕获
const run = async () => {
const tx = "0xc9fdf51d...";
const transaction = await web3.eth.getTransaction(tx);
const input = decodeInput(transaction.input);
if (input.name === "save") {
const args = processArgs(input);
const address = transaction.from;
const key = args[0];
const value = args[1];
// save the address / key / value to a database
...
}
};
上述方法的缺点在于,其他合约无法访问这些通过transaction input的数据;为了更快定位transaction,需要在function中添加空event用于标记。
3. 在IPFS等去中心化存储网络中存储数据,然后将指向源数据的hash上链。
从技术细节的角度看,无论是数据类型优化,还是数据压缩,都是根据以太坊的存储设计、编译原理以及EVM特性,有的放矢地做了一些优化。从更宏观的视角看,伴随以太坊往PoS转型、Layer2的技术发展,从可扩展性的层面做数据存储容量扩展才是大势所趋,因此,完成以太坊Gas优化和安全漏洞总结后,会多写写这些方面的内容。