Solidity Gas优化的奇技淫巧

以太坊上存储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定义、赋值

在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:

  • timestamp变量
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。

二、数据压缩

  1. 使用Merkle树减少存储加载

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) {}
}
  • Filter
# 安装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优化和安全漏洞总结后,会多写写这些方面的内容。

Reference:

Subscribe to Mobius
Receive the latest updates directly to your inbox.
Verification
This entry has been permanently stored onchain and signed by its creator.