一些简单的 Gas 优化基础
编写智能合约是很难的。不仅是要确保代码没有漏洞,而且你的编写方式还会影响到用户与它交互时的开销。
当你在编译智能合约时,每一行 Solidity 代码都会转换为一系列的操作(操作码),这些操作都有对应的 gas 消耗。你的目标就是要让你的程序使用尽可能少的操作码(或者用更便宜的)。
当然,这些都很复杂,所以,我们要慢慢来。与其陷入操作码兔子洞,不如尝试一些可以直接应用到合约里的简单优化。
升级 Solidity 版本
合约中,Solidity 版本是在文件最顶部定义的,像这样:
pragma solidity ^0.8.0;
在这里,^0.8.0
意思是合约使用0.8.x
系列最新可用的Solidity版本。
更新的 Solidity 版本有时会在修复bug和安全补丁时就优化了 gas ,所以,升级到最新版本不仅会让你的代码更安全,通常也会更便宜。
要捕获最近大多数优化,请确保你的版本在0.8.4
以上:
pragma solidity ^0.8.4;
放弃 Counters.sol
如果你的 NFT 项目或者代币正在使用 OpenZeppelin 合约,很可能你正在用 OZ 的Counters.sol
库。
在较新的 Solidity 版本(0.8
的更高版本),这个库并不是很有用,用常规整数替代它可以节省一些gas:
contract TestCounters {
- using Counters for Counters.Counter;
- Counters.Counter private _tokenIds;
+ uint256 private _tokenId;
function mint() public {
- _tokenIds.increment();
- uint256 tokenId = _tokenIds.current();
+ uint256 tokenId = _tokenId++;
}
}
标记不可变变量
无论是代币的小数位数,USDC 的地址,还是支付账户,有时我们并不打算更改合约变量。此时,将它们标记为常量(如果你在代码中编写它们)或者不可变量(如果你计划之后给它们赋值,比如,通过构造函数)可以降低访问这些值时的开销:
contract TestImmutable {
uint256 internal constant DECIMALS = 18;
address public immutable currencyToken;
constructor(address _currencyToken) {
currencyToken = _currencyToken;
}
}
unchecked {}
从 Solidity 0.8
开始,所有数学运算都包括溢出检查。这是很棒的(替换了 SafeMath 库,如果你还在用可以丢弃了),但是它需要额外的 gas 开销,所以我们想在不必要的时候绕开它。
溢出检查的意义在于帮你检查是否存在从 0 减去或者加到 2
256
(Solidity 可以处理的最大数)以上。所以,如果你只是增加代币id或者存储ERC20值,你应该用unchecked {}
退出溢出检查:
contract TestUnchecked is ERC721 {
ERC20 internal immutable paymentToken = ERC20(address(0x1));
uint256 internal _tokenId;
mapping(address => uint256) _balances;
function mint(uint256 amount) public {
_mint(msg.sender, _tokenId);
unchecked {
_balances[msg.sender] += amount;
++tokenId;
}
paymentToken.transferFrom(msg.sender, address(this), amount);
}
}
这在for循环中特别方便,i的值永远不会溢出,所以在每次迭代中节省了gas:
contract TestUncheckedFor {
ERC20 internal immutable token = ERC20(address(0x1));
function refundAddresses(address[] calldata accounts) {
// 💡 pro tip: save the array length to a variable instead of
// inlining to save gas on every iteration.
uint256 len = accounts.length;
for (uint256 i = 0; i < len; ) {
token.transfer(accounts[i], 1 ether);
unchecked { ++i; }
}
}
}
避免将参数复制到内存
对于某些类型的参数,如字符串或者数组,Solidity 会强制指定存储位置(memory
或者calldata
)。这里用calldata
会便宜的多,所以你会希望尽可能多的使用它,而memory
只在你需要修改参数时才用(因为calldata
会让它们只读)。
使用自定义错误
Solidity 0.8.4
有一个新功能,允许开发者自定义错误,就像定义事件一样:
contract TestErrors {
// first, define the error
error Unauthorized();
// errors can have parameters, like events
error AlreadyMinted(uint256 id);
// 💡 pro tip: this gets set to the deployer address
// sometimes, you don't need Ownable :)
address internal immutable owner = msg.sender;
mapping(uint256 => address) _ownerOf;
function ownerMint(uint256 tokenId) public {
if (msg.sender != owner) revert Unauthorized();
if (_ownerOf[tokenId] != address(0)) revert AlreadyMinted(tokenId);
_ownerOf[tokenId] = msg.sender;
}
}
你应该尝试用自定义错误代替以前的返回字符串(require(true, "error message here")
),因为不同的错误信息可能会额外增加gas开销。
其他
当使用任何一种计数器(如_tokenId
),从1开始而不是从0开始,会让第一次 mint 便宜一些。通常,写入没有值的槽比写入有值的槽更贵。
此外,整数递增,++i
(返回上一次的值,然后再加1)比i++
(加1,然后返回新的值)更便宜。如果你仅仅需要一个计数器而不需要它的返回值,你可能会更想要第一种。
除法,Solidity 插入了一个检查,确保没有被0除。如果你可以确定除数不为0,你可以使用汇编来执行操作,这样可以节省一些额外的gas:
contract TestDivision {
function divide_by_2(uint256 a) public pure returns (uint256 result) {
assembly {
result := div(a, 2)
}
}
}
最后,标记为payable
的函数会比其他函数调用时便宜。将所有函数标记为 payable
可能会影响用户体验,因为在使用 Etherscan 时会有一个额外字段,可能会意外向合约发送 ETH 。相对安全的优化是将构造函数标记为payable
,可以稍微降低一点部署的开销。
结束
虽然很难,但 Solidity 和 EVM 的世界真的很有意思。有些开发者可能花费数天时间来调整代码,只为压缩一点额外的gas消耗。
我希望上面的清单可以成为一个好的参考,帮助你的合约便宜一点。