编写智能合约是很难的。不仅是要确保代码没有漏洞,而且你的编写方式还会影响到用户与它交互时的开销。

当你在编译智能合约时,每一行 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消耗。

我希望上面的清单可以成为一个好的参考,帮助你的合约便宜一点。