引言
众所周知,区块链世界的准则是:Code is law
,基于solidity开发的以太坊智能合约,包含一系列的存储状态,来支持Dapp的功能;在Dapp提供服务的过程中,由于区块链的无审查、去中心化特性,任何组织和个人都可以随意调用,此时,为了保护合约按照预定的逻辑运行,需要在整个合约的运行过程中,时刻注意,来避免合约的状态偏离预定轨道,出现安全隐患。
所谓的安全隐患,不仅仅包括比较引人注意的盗币事故;为了规避安全隐患,我们至少要做到:
- 安全检验:避免异常的外部输入或者其他合约返回值导致本合约的执行过程发生意料之外的情况,造成资金损失或者内部状态的混乱
- 防呆:诚实的用户,由于自己的不慎,用错误的方式调用合约,或者调用了错误的合约,就回造成自己的损失;一个设计良好的合约,会尽量避免用户的损失,发现用户的错误则停止执行,或者在用户犯错误之后,仍然有办法补救
- 摩擦:物体在摩擦系数大的表面上很难滑动,智能合约如果处处都遭遇异常和revert,那么就会执行不下去;严重的情况下,摩擦会导致智能合约无法正常地为用户服务。黑客有可能利用合约里面的摩擦,进行损人不利己的攻击:即使黑客自己拿不到好处,也不让你的合约正常工作
本文首先介绍Solidity语言中提供的同异常检查相关的关键字,接着以OneSwap为例,介绍在合约编写时,对于安全检验、防呆和摩擦,有怎样的考量。
solidity的异常检查机制
以太坊提供了三种异常检查机制,来检查合约接收的参数、以及合约运行过程中产生的一些中间状态;既可以避免恶意用户的输入,破坏合约内部的持久化状态;也可以在合约运行时,当中间状态不符合预定需求时,及时中断合约执行,减少用户的gas消耗。
在以太坊中,用solidity编写的合约,在遇到异常时,会回退当前调用对合约状态所做的所有更改;而且,当异常发生在子调用过程时,异常会被继续向上层传递,用于回退上层合约的状态变化。注意,此处有些例外情况:因为solidity支持在编写合约时,插入低级别的调用指令(如:send
, call
, delegatecall
, staticcall
),这些低级别的调用指令通过返回值来表示执行结果,执行出错时,指令的第一个返回值返回false,并不会向上层抛出异常,上层调用通过检测汇编指令的返回值,来获悉指令的执行情况,决定是否回退上层合约的执行状态。
- 注意:用低级指令
call
,delegatecall
,staticcall
调用不存在的账户时,指令的第一个返回值也为true(这种情况是EVM底层实现导致的);所以,账户的存在性检查必须在低级调用之前进行。
下面介绍solidity抛出异常的三种关键字assert
、 require
和revert
,以及它们适用的场景。
require
和assert
关键字用来检查传入的表达式,表达式为false时抛出异常;格式为 require(expression, "some reason with error")
, assert(expression, "some reason with error")
。之所以在发生异常时,回退合约的所有状态变更,是因为在没有得到期望结果时,合约剩余的指令已无法安全执行;同时为了保持交易的原子性,最安全的操作方式就是回退所有状态的变更,使整个交易对链上数据不造成任何影响。
require
表达式用来检查调用合约的输入参数或者合约执行后的返回值。如果表达式的结果为false时,require
抛出异常,回退当前交易造成的所有状态变更。在solidity语言的底层实现中,require
表达式用0xfd
指令(REVERT)来实现,该指令不会消耗用户剩余的未使用gas。
assert
用来检测合约内部的出错情况,对合约的执行状态进行检查。正常情况下,合约的执行路径不会触发assert
异常,但对于恶意用户精心构造的一些场景,可能会不满足assert
的检查条件,触发异常;作为惩罚,assert
会消耗当前交易所剩余的所有gas。
基于不同指令对剩余gas的处理方式不同,合约开发者在相关外部函数的入口,可以使用require
来检查合约方法的输入参数、返回值、以及一些可能出错的地方,以便当出现不符合预期的情况时,及时中断执行,减少用户的gas消耗。对于合约中,不应该出现错误的逻辑,合约开发者可以使用assert
来进行惩罚性保护。
示例代码如下
contract DemoContract{
uint const TICKET = 10;
address pool;
function addPositive(uint a, uint b) public payable returns(uint){
require(a > 0 && b > 0, "params are not Positive integer");
// error reason is optional
require(this.balance >= TICKET );
assert(pool.send(TICKET), "transfer eth failed");
}
}
revert
也是一种在出错情况下触发异常的机制,底层指令实现与require
相同;它主要用在,检测表达式比较多,无法用一行代码实现的场景。
示例代码如下
contract DemoRevert{
uint state;
function operation(uint num) public{
if (num > 0 && num < 10){
// some operation
......
if (num > 3){
revert("Invalid Calculate")
}
}
}
}
安全校验
安全校验的必要性大家很容易理解。有很多黑客在盯着链上的合约,你必须假定合约的每个可以被外部调用的函数,都会被黑客以各种可能的方法来调用,尝试看能否暴露出什么漏洞,让黑客占到便宜。所以,对输入参数的合法性校验,对用户交易金额的数量校验,都是必不可少和非常常见的操作了。
例如,下限价单之后,我们需要对价格和下单的金额做检查:
require((amount >> 42) == 0, "OneSwap: INVALID_AMOUNT");
uint32 m = price32 & DecFloat32.MANTISSA_MASK;
require(DecFloat32.MIN_MANTISSA <= m && m <= DecFloat32.MAX_MANTISSA, "OneSwap: INVALID_PRICE");
利用价格和下单的金额计算出用户应当打给合约的币的数量并且将其保存为ctx.remainAmount之后,需要查询余额,确认用户的确打了这么多的币:
function _checkRemainAmount(Context memory ctx, bool isBuy) private view {
ctx.reserveChanged = false;
uint diff;
if(isBuy) {
uint balance = _myBalance(ctx.moneyToken);
require(balance >= ctx.bookedMoney + ctx.reserveMoney, "OneSwap: MONEY_MISMATCH");
diff = balance - ctx.bookedMoney - ctx.reserveMoney;
if(ctx.remainAmount < diff) {
ctx.reserveMoney += (diff - ctx.remainAmount);
ctx.reserveChanged = true;
}
} else {
uint balance = _myBalance(ctx.stockToken);
require(balance >= ctx.bookedStock + ctx.reserveStock, "OneSwap: STOCK_MISMATCH");
diff = balance - ctx.bookedStock - ctx.reserveStock;
if(ctx.remainAmount < diff) {
ctx.reserveStock += (diff - ctx.remainAmount);
ctx.reserveChanged = true;
}
}
require(ctx.remainAmount <= diff, "OneSwap: DEPOSIT_NOT_ENOUGH");
}
如果用户没有打足够的币,则报错并revert;如果用户打的币太多了,就把多余的币算作合约的收益。注意,用户打的币太多,虽然不会造成合约中的资金损失,但会造成数据的不一致,因此也必须对这种情况加以处理。
又比如,在BuyBack合约当中,具体去哪个Pair进行Token的兑换,是通过合约的参数指定的,如果恶意用户指定一个假的Pair导致资金打进去之后被侵吞怎么办?这时我们需要去Factory合约那里查询一下看看这是不是一个假Pair:
function _removeLiquidity(address pair) private {
(address a, address b) = IOneSwapFactory(factory).getTokensFromPair(pair);
require(a != address(0) || b != address(0), "OneSwapBuyback: INVALID_PAIR");
......
}
防呆设计
与现实世界的各种实体工具类似(如:电池的正负极形状设计,防止用户放错方向),用solidity开发的合约,也可以加入类似的防呆设计(如:防止用户误打入ETH到合约账户,或将用户多转入的资产还给用户等);在OneSwap的合约设计中,添加了很多这样的防呆设计。
举一个例子,ONES这个Token的所有权是可以转让的,转让的过程不是由A直接转给B,而是分两步:先由A发一个交易宣布说,我准备把所有权转让给B了;然后B再发一个交易说,我接受所有权。如下面代码所示:
modifier onlyOwner() {
require(msg.sender == _owner, "OneSwapToken: MSG_SENDER_IS_NOT_OWNER");
_;
}
modifier onlyNewOwner() {
require(msg.sender == _newOwner, "OneSwapToken: MSG_SENDER_IS_NOT_NEW_OWNER");
_;
}
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;
}
function updateOwner() public override onlyNewOwner {
_owner = _newOwner;
emit OwnerChanged(_newOwner);
}
为什么这样设计?因为当A发交易把所有权转给B的时候,地址B可能是一个无法找到私钥的地址,或者一个不相干的合约地址。如果分两步走的话,只有在地址B的私钥的确存在且可以发交易的时候,交易权才最终转给B。一旦发现地址B有问题,A还可以发交易宣布把所有权转给C。
又比如,在OneSwapRouter合约中,去除了默认接收ETH的回调 receive() external payable {}
;该方法的作用是,用户可以直接将ETH转入合约账户,删除该方法后,用户就无法直接向合约地址转入ETH资金。
在OneSwapRouter合约中,只有必要的对外接口,才会添加payable
修饰符,允许接收ETH,避免用户在调用合约方法时,误操作转入ETH至合约账户,例如:removeLiquidity 该移除流动性接口,就未添加payable
修饰符。
看到这里,你可能会问,上面checkRemainAmount函数的例子中,如果用户打的币太多了,就把多余的币算作合约的收益,这算不算对犯傻的用户不太友好呢?不是,因为普通用户是通过Router来调用Pair的,而Router中会帮用户算清楚打多少币(例如:下面进行挂单交易时,在买单时,先计算用户实际需要转入资金池的money数量,然后再将计算好的金额转入资金池;同时,也可以看到下单时,对用户转入的金额也包含了防呆检测,避免用户意外转入ETH。)
function limitOrder(bool isBuy, address pair, uint prevKey, uint price, uint32 id,
uint stockAmount, uint deadline) external payable override ensure(deadline) {
(address stock, address money) = _getTokensFromPair(pair);
{
(uint _stockAmount, uint _moneyAmount) = IOneSwapPair(pair).calcStockAndMoney(uint64(stockAmount), uint32(price));
if (isBuy) {
if (money != address(0)) { require(msg.value == 0, 'OneSwapRouter: NOT_ENTER_ETH_VALUE'); }
_safeTransferFrom(money, msg.sender, pair, _moneyAmount);
}
......
}
IOneSwapPair(pair).addLimitOrder(isBuy, msg.sender, uint64(stockAmount), uint32(price), id, uint72(prevKey));
}
由于AMM算法的特点和区块链的先天特性,用户每次向资金池质押token获取流动性时,无法精确得到交易上链那一时刻资金池两种资产的比值,导致用户调用OneSwapRouter合约质押token时,转入的token大部分情况下都会有一方有剩余(因为基于uniswap的AMM算法,质押的两种token需要成比例),此时,为了保证用户的资产不受损失,OneSwapRouter合约在每次质押token后,会将用户的多余的资产自动转账至用户地址。
function _safeTransferFrom(address token, address from, address to, uint value) internal {
if (token == address(0)) {
_safeTransferETH(to, value);
uint inputValue = msg.value;
if (inputValue > value) { _safeTransferETH(msg.sender, inputValue - value); }
return;
}
.....
}
避免摩擦
并不是合约中出现的所有不合理参数,都应该用require
抛出异常,举一个最简单的例子:ERC20 token转账的金额为0,虽然金额为0的转账不符合常理,但此时并不会对合约、用户造成任何影响,直接return
,不做任何动作即可;过多的报错,可能会使调用自己的合约无端终止,毕竟基于常识来说,转移资产为0时,不会造成任何状态的变更。这里的“使调用自己的合约无端终止”,就是所谓的“产生了摩擦”。
合约执行过程中,每一次调用外部合约,都可能遇到调用失败,导致自己回滚,哪怕是转一些ETH或者ERC20这种平淡无奇的操作,也有可能会Fail。转ETH给一个合约时,可能会触发这个合约的代码逻辑,代码执行时Fail掉了。而ERC20转账失败,一个最常见的原因就是接收Token的人被列入了黑名单。
Taker在吃掉对手单的时候,要转移一定数额的ETH或ERC20 Token给对手单的所有者,这个转账操作如果失败导致交易revert的话,意味着这个对手单无法成交,卡在那里,也阻碍了后面的订单成交。在OneSwap当中,用来转账的函数是这样的:
// safely transfer ERC20 tokens, or ETH (when token==0)
function _safeTransfer(address token, address to, uint value, address ones) internal {
if(value==0) {return;}
if(token==address(0)) {
// limit gas to 9000 to prevent gastoken attacks
// solhint-disable-next-line avoid-low-level-calls
to.call{value: value, gas: 9000}(new bytes(0)); //we ignore its return value purposely
return;
}
// solhint-disable-next-line avoid-low-level-calls
(bool success, bytes memory data) = token.call(abi.encodeWithSelector(_SELECTOR, to, value));
success = success && (data.length == 0 || abi.decode(data, (bool)));
if(!success) { // for failsafe
address onesOwner = IOneSwapToken(ones).owner();
// solhint-disable-next-line avoid-low-level-calls
(success, data) = token.call(abi.encodeWithSelector(_SELECTOR, onesOwner, value));
require(success && (data.length == 0 || abi.decode(data, (bool))), "OneSwap: TRANSFER_FAILED");
}
}
转账ETH时,忽略call
的返回值,如果转账失败,那么接收者必然是个恶意的合约账户,故意构造一些恶意场景来阻塞订单簿的正常运转,此时基于订单簿中其他用户的考虑,只能让恶意黑客承担此次损失;对外部账户、正常合约转账ETH,不可能失败。转账ERC20 Token时,如果转账失败,就把原本转给接收者的资产,转让给ONES这个Token的所有人。这算是一个中心化的解决方案:如果接收者因为进入黑名单而无法收到Token,那么久让OneSwap项目的所有人来代替你保管这些Token,等你离开黑名单后再还给你这些Token。无论如何,订单簿不能因为转账时产生的摩擦,无法正常工作。
如OneSwap项目的回购合约 BuyBack中,也使用了类似的理念,来减少合约执行过程中产生的摩擦(如下所示:提取交易对的手续费收入,用于回购ONES;在查询到相关的交易对中手续费收入为0时,此时BuyBack合约只是简单的停止执行,并不会抛出任何异常)。
function removeLiquidity(address[] calldata pairs) external override {
for (uint256 i = 0; i < pairs.length; i++) {
_removeLiquidity(pairs[i]);
}
}
function _removeLiquidity(address pair) private {
....
uint256 amt = IERC20(pair).balanceOf(address(this));
if (amt == 0) { return; }
....
}
总结
文章中主要介绍了 solidity的三种抛出异常机制,用于去中心化情况下的出错处理;对于无法在去中心化情况下处理的问题,可以引入半中心化的机制,来保障用户的资产(如上述所描述的订单簿可能被转账阻塞的问题);同时,也描述了很多减少调用摩擦的示例,尽可能减少合约被调用过程中用户产生的疑惑;也介绍了一些常用的防呆设计,避免用户由于误操作,造成可能的资产损失。
Error handling: Assert, Require, Revert and Exceptions
原文:《OneSwap Series 11 - Security Verification, Fool-proof Design, and Friction Prevention for ETH Contracts》
链接:https://oneswap.medium.com/oneswap-series-11-security-verification-fool-proof-design-and-friction-prevention-for-eth-11102cd3ad69
翻译:OneSwap中文社区