Solidity语言并不难学,使用Solidity语言编写以太坊智能合约也并不难,然而想要写出完全没有安全隐患的智能合约却非常难。为了帮助Solidity程序员编写更加健壮的智能合约,fravoll总结了14种常用的Solidity设计模式。OneSwap项目在开发过程中充分参考和借鉴了这些设计模式,并且还开创了几种新的设计模式。本文将介绍fravoll总结的这14种设计模式,以及它们在OneSwap项目中的具体应用。下面是这14种设计模式的列表:
- Behavioral Patterns
- Security Patterns
- Upgradeability Patterns
- Economic Patterns
OneSwap项目并没有用到所有的模式,下面仅对OneSwap直接使用的模式进行介绍。
行为模式(Behavioral Patterns)
- Guard Check
Guard Check
在编写合约时,我们应该应用Guard Check模式做好各种检查,例如用户输入参数、外边合约的返回值、各种计算的溢出情况、合约内部状态和不变量等等。一旦检查失败,便回退整个交易。Solidity语言提供了三个内置函数来帮助我们进行这些检查:assert()
、require()
、revert()
。关于这三个函数的详细用法,可以参考Solidity文档或者在网上搜索相关文章,本文不再详细介绍。下表对这三个错误处理函数进行了简单总结:
错误处理函数 | 底层指令 | 是否可以提供错误信息 | 是否返还剩余Gas |
---|---|---|---|
assert(condition) | 0xFE (Invalid) | No | No |
require(condition, msg?) | 0xFD (REVERT) | Yes | Yes |
revert(msg?) | 0xFD (REVERT) | Yes | Yes |
OneSwap项目大量使用了require()
函数进行检查,并在少数地方使用了assert()
函数。这些检查随处可见,下面仅以OneSwapBlackList
抽象合约的changeOwner()
函数为例展示require()
函数的用法:
function changeOwner(address ownerToSet) public override onlyOwner {
require(ownerToSet != address(0), "OneSwapToken: INVALID_OWNER_ADDRESS");
require(ownerToSet != _owner, "OneSwapToken: NEW_OWNER_IS_THE_SAME_AS_CURRENT_OWNER");
require(ownerToSet != _newOwner, "OneSwapToken: NEW_OWNER_IS_THE_SAME_AS_CURRENT_NEW_OWNER");
_newOwner = ownerToSet;
}
安全模式(Security Patterns)
- Access Restriction
- Checks Effects Interactions
- Secure Ether Transfer
- Pull over Push
Access Restriction
OneSwap主要由7个合约构成,其中4个合约应用了Access Restriction模式来限制合约的特权操作:
-
OneSwapBlackList
抽象合约:只有该合约owner才能管理黑名单或者转移owner。 -
OneSwapFactory
合约:只有OneSwapGov
合约才能设置其feeTo
和feeBPS
状态。 -
OneSwapGov
合约:只有ONES(OneSwap治理代币)的owner才能发起非文本提案。 -
OneSwapBuyback
合约:只有ONES的owner才能管理主流币列表。
如果特权操作比较少(比如一两个函数),那么使用前面介绍的require()
函数进行检查即可。如果特权操作较多,那么使用Solidity语言提供的函数修饰符(modifier)特性更方便。仍然以OneSwapBlackList
抽象合约为例,下面是onlyOwner()
修饰符的定义:
modifier onlyOwner() {
require(msg.sender == _owner, "OneSwapToken: MSG_SENDER_IS_NOT_OWNER");
_;
}
changeOwner()
、addBlackLists()
和removeBlackLists()
函数都必须是owner才能操作,因此只要添加上面定义的修饰符即可,以addBlackLists()
函数为例:
function addBlackLists(address[] calldata _evilUser) public override onlyOwner {
for (uint i = 0; i < _evilUser.length; i++) {
_isBlackListed[_evilUser[i]] = true;
}
emit AddedBlackLists(_evilUser);
}
Checks Effects Interactions
我们都知道,重入攻击(Reentrancy Attack)是可以对智能合约进行的最可怕的攻击之一,目前已经有多个知名项目因代码存在漏洞而遭受了这个攻击并造成了严重的损失。应用Checks-Effects-Interactions模式可以使我们编写的智能合约免受这一攻击的威胁。简单来说,当需要和非信任的外部合约进行交互时,要分三步进行:首先检查状态,然后更新状态,最后调用合约进行交互。我们以OneSwap项目LockSend
合约的unlock()
函数为例进行说明,下面是该函数的代码:
function unlock(address from, address to, address token, uint32 unlockTime) public override afterUnlockTime(unlockTime) {
bytes32 key = _getLockedSendKey(from, to, token, unlockTime);
uint amount = lockSendInfos[key];
require(amount != 0, "LockSend: UNLOCK_AMOUNT_SHOULD_BE_NONZERO");
delete lockSendInfos[key];
_safeTransfer(token, to, amount);
emit Unlock(from, to, token, amount, unlockTime);
}
首先,afterUnlockTime
修饰符以及require()
函数确保解锁时间和金额有效。然后,修改状态(将整个锁定转账信息删掉)。最后,调用_safeTransfer()
函数进行ERC20转账。这里只是结合LockSend
合约介绍了Checks-Effects-Interactions模式的用法,我们将在其他文章中专门介绍重入攻击的原理、危害以及各种应对方式(包括完全禁止重入)。
Secure Ether Transfer
如果要在合约B中给地址A转eher,可以在地址A上调用三个内置函数之一:send()
、transfer()
、call()
。对于前两个函数,地址必须是payable的,第三个函数则没有此限制。作为Solidity程序员,我们必须知道这三种转账方式的用法、实现方式以及优缺点,在具体的场景使用最为适合的方式进行ether转账。下面的表格对这三个种转账方式的用法、转发的Gas数以及错误处理进行了总结:
Function Usage | Amount of Gas Forwarded | Exception Propagation |
---|---|---|
payableAddr.send(amt) | 2300 (not adjustable) | returns false on failure |
payableAddr.transfer(amt) | 2300 (not adjustable) | reverts on failure |
addr.call{value: amt, gas: gasLimit}(payload) | all remaining gas (adjustable) | returns success condition and return data |
为了彻底理解这三种转账方式的差异,我们写一个简单的智能合约,看看这三种内置函数到底是如何实现的:
pragma solidity =0.6.12;
contract TransferDemo {
function testSend(address payable addr) external {
addr.send(0x1234);
}
function testTransfer(address payable addr) external {
addr.transfer(0x5678);
}
function testCall(address addr) external {
addr.call{value: 0xABCD}("");
}
}
编译上面的合约,然后反汇编生成的合约运行时字节码。为了清晰起见,下面只给出关键的三个测试函数的反汇编结果:
function testSend( uint256 arg0) public return () {
var7 = uint160(arg0).call.gas(((0x1234 == 0) * 0x8FC)).value(0x1234)(0x80, 0x0);
return();
}
function testTransfer( address arg0) public return () {
var7 = uint160(arg0).call.gas(((0x5678 == 0) * 0x8FC)).value(0x5678)(0x80, 0x0);
if (var7) {
return();
} else {
returndatacopy(0x0, 0x0, returndatasize);
revert(0x0, returndatasize);
}
}
function testCall( uint256 arg0) public return () {
var7 = uint160(arg0).call.gas(0xEFFF).value(0xABCD)(0x80, 0x0);
if ((returndatasize == 0x0)) {
return();
} else {
mstore(0x40, (0x80 + ((returndatasize + 0x3F) & ~0x1F)));
mstore(0x80, returndatasize);
returndatacopy(0xA0, 0x0, returndatasize);
return();
}
}
可见,这三种转账方式都是通过EVM提供的CALL
指令来实现的。对于send()
和transfer()
函数,编译器为我们固定转发了2300(0x9FC)个Gas,call()
函数则允许我们自己指定要转发的Gas数量。对于transfer函数,编译会帮我们检查返回值并自动调用revert()
函数,其他两个函数则需要我们自己对返回值进行检查和处理。
注意,由于以太坊一直在调整某些EVM指令的Gas消耗(例如EIP-1884将SLOAD
指令的Gas消耗从200提高到了800),现如今2300个Gas已经显得捉襟见肘。遵从这篇文章的建议,OneSwap项目中仅使用了call()
函数进行ether转账,并在必要时自己控制转发的Gas数量。以OneSwapRouter
合约为例,ether转账逻辑被封装进了_safeTransferETH()
函数,代码如下所示:
function _safeTransferETH(address to, uint value) internal {
(bool success,) = to.call{value:value}(new bytes(0));
require(success, "TransferHelper: ETH_TRANSFER_FAILED");
}
Pull over Push
该模式本来是针对ether转账的,但是对于ERC20的转账也同样适用。例如OneSwap项目的OneSwapGov
合约需要提案者以及投票者质押一定数量的OneSwap治理代币ONES。如果提案计票之后需要立刻返回这些ONES给质押者,那么很可能会因为投票者过多而导致提案计票失败(因为需要花费大量Gas来返回ONES)。通过使用Pull over Push模式,这个问题引刃而解:提案计票之后,质押者需要自己取回ONES。此外,由于该模式的使用,质押信息只需要用映射来存储即可,并不需要考虑遍历问题,代码也得到了简化。下面给出OneSwapGov
合约withdrawOnes()
函数的代码:
function withdrawOnes(uint112 amt) external override {
VoterInfo memory voter = _voters[msg.sender];
require(_proposalType == 0 || voter.votedProposal < _proposalID, "OneSwapGov: IN_VOTING");
require(amt > 0 && amt <= voter.depositedAmt, "OneSwapGov: INVALID_WITHDRAW_AMOUNT");
_totalDeposit -= amt;
voter.depositedAmt -= amt;
if (voter.depositedAmt == 0) {
delete _voters[msg.sender];
} else {
_voters[msg.sender] = voter;
}
IERC20(ones).transfer(msg.sender, amt);
}
升级模式(Upgradeability Patterns)
- Proxy Delegate
Proxy Delegate
We can solve any problem by introducing an extra level of indirection.
OneSwap项目的核心逻辑在OneSwapPair
合约里,该合约较为复杂,由此产生了两个问题。第一:合约代码复杂,那么编译之后的字节码就比较大,创建合约就比较消耗Gas。由于OneSwap需要通过OneSwapFactory
合约为每个交易对部署单独的Pair合约,所以创建交易对就会是一个相对而言比较昂贵的操作。第二:合约代码越复杂,那么存在bug的可能性也越大。如果OneSwapPair
在部署之后被发现存在bug,需要能够进行升级。通过代理模式的使用(引入中间层),这两个问题都引刃而解。下面是OneSwapFactory
、OneSwapPairProxy
和OneSwapPair
这三个合约之间的关系:
+----------------+ create +------------------+ forward to +-------------+
| OneSwapFactory | -------> | OneSwapPairProxy | -----------> | OneSwapPair |
+----------------+ +------------------+ +-------------+
注意,真正的Pair逻辑在OneSwapPair
合约里,OneSwapPairProxy
合约只做转发。由于OneSwapPair
合约只部署一次即可,所以较高的Gas消耗是可以容忍的。OneSwapPairProxy
只做转发,所以逻辑较为简单,部署代价较低。而一旦OneSwapPair
合约被发现存在bug,只要fix问题、重新部署新版Pair合约、并调用Factory合约的setPairLogic()
函数更新Pair逻辑即可。下面给出OneSwapPairProxy
合约的完整代码:
contract OneSwapPairProxy {
uint[10] internal _unusedVars;
uint internal _unlocked;
uint internal immutable _immuFactory;
uint internal immutable _immuMoneyToken;
uint internal immutable _immuStockToken;
uint internal immutable _immuOnes;
uint internal immutable _immuOther;
constructor(address stockToken, address moneyToken, bool isOnlySwap, uint64 stockUnit, uint64 priceMul, uint64 priceDiv, address ones) public {
_immuFactory = uint(msg.sender);
_immuMoneyToken = uint(moneyToken);
_immuStockToken = uint(stockToken);
_immuOnes = uint(ones);
uint temp = isOnlySwap ? 1 : 0;
temp = (temp<<64) | stockUnit;
temp = (temp<<64) | priceMul;
temp = (temp<<64) | priceDiv;
_immuOther = temp;
_unlocked = 1;
}
receive() external payable { }
// solhint-disable-next-line no-complex-fallback
fallback() payable external {
uint factory = _immuFactory;
uint moneyToken = _immuMoneyToken;
uint stockToken = _immuStockToken;
uint ones = _immuOnes;
uint other = _immuOther;
address impl = IOneSwapFactory(address(_immuFactory)).pairLogic();
// solhint-disable-next-line no-inline-assembly
assembly {
let ptr := mload(0x40)
let size := calldatasize()
calldatacopy(ptr, 0, size)
let end := add(ptr, size)
// append immutable variables to the end of calldata
mstore(end, factory)
end := add(end, 32)
mstore(end, moneyToken)
end := add(end, 32)
mstore(end, stockToken)
end := add(end, 32)
mstore(end, ones)
end := add(end, 32)
mstore(end, other)
size := add(size, 160)
let result := delegatecall(gas(), impl, ptr, size, 0, 0)
size := returndatasize()
returndatacopy(ptr, 0, size)
switch result
case 0 { revert(ptr, size) }
default { return(ptr, size) }
}
}
}
值得特别指出的是,为了将整体Gas消耗降至最低水平,OneSwap项目大量使用了immutable状态变量。在引入代理模式之前,Pair合约也使用了许多immutable状态变量。为了鱼(代理模式)和熊掌(immutable状态变量)兼得,OneSwap首创了“Immutable Forwading”模式,这也是上面给出的fallback()
函数较为复杂的原因之一。我们会在专门的文章中介绍immutable状态变量的原理,以及“Immutable Forwading”模式的实现细节。
经济模式(Economic Patterns)
- Tight Variable Packing
- Memory Array Building
Tight Variable Packing
我们在之前的文章中讨论过Solidity合约状态变量的实现原理,由文章介绍可知:
- 合约的状态变量存储在Storage中,而Storage的读写(
SLOAD
和SSTORE
指令)是非常消耗Gas的。 - Solidity编译器会尽可能将连续的状态变量塞进同一个slot(256比特)里,但不会为此重新排列状态变量。
- 作为合约编写者,需要仔细排列状态变量的顺序,帮助Solidity进行存储优化。
每一个Solidity程序员都需要按照Tight Variable Packing模式来思考,OneSwap项目更是在Gas优化上做到了极致,甚至会手动来进行优化。以OneSwapPair
合约为例,整个订单的信息被塞进了一个uint256整数里:
contract OneSwapPair is OneSwapPool, IOneSwapPair {
// the orderbooks. Gas is saved when using array to store them instead of mapping
uint[1<<22] private _sellOrders;
uint[1<<22] private _buyOrders;
... // 其他代码省略
}
除了上面这个例子,OneSwap项目还有多处都仔细运用了该模式来优化存储,这里就不一一列举了。
Memory Array Building
我们已经多次提到过了:读写Storage是非常消耗Gas的,最好能够完全避免。通过应用Memory Array Building模式,我们能够以非常经济(0 Gas消耗)的方式从链上聚合并获取合约状态。简单来说,该模式使用了下面这些技巧:
- 选择一个可遍历数据结构存储数据,例如数组。关于Solidity数据结构的更多介绍可以参考本系列其他文章。
- 定义一个使用
view
修饰符标记的函数,用于读取数据。由于view
函数是只读的,不会修改任何状态,因此并不会产生实际的Gas消耗。 - 在内存中构造需要返回的数据。
OneSwap项目的OneSwapPair
合约利用该技巧返回订单薄数据,逻辑在getOrderList()
函数中。不过由于订单薄可能较大,所以该函数还支持分页,可以通过fromId
和maxCount
参数指定范围。下面是getOrderList()
函数的代码:
// Get the orderbook's content, starting from id, to get no more than maxCount orders
function getOrderList(bool isBuy, uint32 id, uint32 maxCount) external override view returns (uint[] memory) {
if(id == 0) {
id = isBuy ? uint32(_bookedStockAndMoneyAndFirstBuyID>>224)
: uint32(_reserveStockAndMoneyAndFirstSellID>>224);
}
uint[1<<22] storage orderbook;
orderbook = isBuy ? _buyOrders : _sellOrders;
//record block height at the first entry
uint order = (block.number<<24) | id;
uint addrOrig; // start of returned data
uint addrLen; // the slice's length is written at this address
uint addrStart; // the address of the first entry of returned slice
uint addrEnd; // ending address to write the next order
uint count = 0; // the slice's length
// solhint-disable-next-line no-inline-assembly
assembly {
addrOrig := mload(0x40) // There is a “free memory pointer” at address 0x40 in memory
mstore(addrOrig, 32) //the meaningful data start after offset 32
}
addrLen = addrOrig + 32;
addrStart = addrLen + 32;
addrEnd = addrStart;
while(count < maxCount) {
// solhint-disable-next-line no-inline-assembly
assembly {
mstore(addrEnd, order) //write the order
}
addrEnd += 32;
count++;
if(id == 0) {break;}
order = orderbook[id];
require(order!=0, "OneSwap: INCONSISTENT_BOOK");
id = uint32(order&_MAX_ID);
}
// solhint-disable-next-line no-inline-assembly
assembly {
mstore(addrLen, count) // record the returned slice's length
let byteCount := sub(addrEnd, addrOrig)
return(addrOrig, byteCount)
}
}
总结
编写智能合约不难,然而想要编写“gas-efficient”、“bug-free”的智能合约就非常困难了。fravoll总结了14个Solidity设计模式,可以帮助我们更好的编写以太坊智能合约。通过对其中8种模式的直接应用,OneSwap项目收益良多。不仅如此,OneSwap项目还结合自身特点以及Solidity最新特性总结了几种新的设计模式,我们会在专门的文章中介绍这些设计模式。