EVM同业界著名的虚拟机,例如JVM、WebAssembly等不同,它并不支持编程语言中常用的基本数据类型如int、long、char、float、double等等,它仅仅支持一种基本数据类型,即256位的长整数。如此设计EVM,也有一定的合理性,例如:
- 哈希函数的输出一般为256位
- 椭圆曲线计算时,使用256位的长整数
- 使用256位长整数来实现有理数,在绝大多数场景下,可以替代浮点数,且可以规避浮点计算的平台相关性
- EVM作为解释执行的VM,解释本身的代价,比整数计算要高,使用256位整数未必会比64位整数慢多少
但是如此一来,就必须设计专门的编程语言(例如Solidity、Vyper)来适配EVM,给大家带来了学习和理解的工作量。我们先来学习一下Solidity提供了哪些基本的数据类型。
Solidity当中的整数类型
Solidity当中支持长度为8*N(1<=N<=32)的有符号整数和无符号整数,类型的名字是int或uint后面跟着长度,例如int8、int16、int64、int112、int256、uint32、uint128、uint256,如果没有跟着长度,即int、uint,那么默认长度就是256。
在底层,Solidity是如何实现int8、int16、int64、int112、uint32、uint128等等这些更短的整数呢?其实很简单:在内存和栈上,这些更短的整数占用的空间同256位的整数完全一样,并不节省;在计算过程中,完成一次计算之后,用“与”操作将它们高位用不到的bits清零即可。
总之,短整数并不节省内存和栈的空间,相反,它们在计算时需要更多的步骤。因此,我们要尽可能避免使用短整数——但有一个例外:在使用Storage时,应该使用短整数来节约空间。本系列文章另有专门的文章来介绍如何节约使用Storage空间。
Solidity所支持的整数运算、对应的算符及其Gas消耗如下表所示:
运算类别 | 算符 | Gas |
---|---|---|
加减 | +、- | 3 |
比较 | ==、!=、>、<、>=、<= | 3 |
位域计算 | &、|、!、^、>>、<< | 3 |
乘除和取模 | *、/、% | 5 |
乘方 | ** | 10+50*结果的字节数 |
整数溢出问题和SafeMath
我们知道,当采用二进制补码来表示整数的时候,会出现下面这些溢出的问题:
- 两个长度为N的整数a、b相加,结果需要N+1个二进制位来表示,如果强行将结果截断为N位,那么它会比a和b都小
- 两个长度为N的正整数a、b相减,结果可能是一个负数,如果强行将结果解释为正整数,那么它会是一个比a和b都大的数
- 两个长度为N的正整数a、b相乘,结果可能需要2*N个二进制位才能容纳
Solidity对于各种长度的整数进行算术计算时,所生产的结果数据同输入数据的长度是一样的,这样在某些场景之下就可能出现溢出。溢出往往会导致严重的程序错误。例如,美链(BEC)就因为一个乘法溢出的错误,导致数十亿市值烟消云散。为了在出现错误的时候终止程序的运行,很多项目选择使用SafeMath库,包括Oneswap项目:
import "./libraries/SafeMath256.sol";
abstract contract OneSwapERC20 is IOneSwapERC20 {
using SafeMath256 for uint;
...
这里导入(import)了SafeMath256的库,并且将其应用在256位无符号整数类型uint上。这个库仅对256位无符号整数是适用的。如果需要其他长度的SafeMath库,那么请您思考:
- 是不是必须这么做?因为更短的整数在计算过程中会更加耗费Gas,最好的选择是先把短整数显式地转换为256位整数,完成计算后再显式地转回来
- 如果您一定要这么做,那么可以参考这篇文章
SafeMath256以及同它类似的库,根据一些简单的规则来工作,包括:
- 加法的和,比任意一个加数小的话,则没有发生溢出
- 减数应当小于等于被减数
- 乘法的商,除以被乘数,应当等于乘数
- 除法和取模操作的分母,不能等于零
如果输入参数违背了这些规则,SafeMath256中的函数就会revert。
上面的代码中的using SafeMath256 for uint
能够让当前合约中uint类型的数据,使用类似于面向对象的语法来调用SafeMath256当中的函数。例如下面的例子:
uint public override totalSupply;
mapping(address => uint) public override balanceOf;
function _mint(address to, uint value) internal {
totalSupply = totalSupply.add(value);
balanceOf[to] = balanceOf[to].add(value);
emit Transfer(address(0), to, value);
}
这里的totalSupply.add(value)
,其实相当于写SafeMath256.add(totalSupply, value)
,solidity提供的语法糖,让我们把代码写得更加简短。
使用了SafeMath之后,代码更加安全,但是为了进行规则检查,会付出更多的Gas,而且EVM字节码也会膨胀得更大。因此,我们在做一系列连续的计算之时,如果可以确定输入数据只有有限的位数,那么可以通过仔细分析每一个计算步骤是否会发生算术溢出,来确定可以安全地使用普通Solidity算符的地方。在OneSwap当中,大量使用了这一技巧,例如下面的代码:
uint stockAmount;
if(isBuy) {
uint a = ctx.remainAmount/*112bits*/ * priceInBook.denominator/*76+64bits*/;
uint b = priceInBook.numerator/*54+64bits*/ * ctx.stockUnit/*64bits*/;
stockAmount = a/b;
} else {
stockAmount = ctx.remainAmount/ctx.stockUnit;
}
if(uint(orderInBook.amount) < stockAmount) {
stockAmount = uint(orderInBook.amount);
}
require(stockAmount < (1<<42), "OneSwap: STOCK_TOO_LARGE");
uint stockTrans = stockAmount/*42bits*/ * ctx.stockUnit/*64bits*/;
uint moneyTrans = stockTrans * priceInBook.numerator/*54+64bits*/ / priceInBook.denominator/*76+64bits*/;
这里使用了原生的乘法和除法算符,没有使用过SafeMath库。代码中的注释中标注了数据所可能的最大长度,根据分析,在长度最大时,也不会发生乘法的溢出。这里只有一个require语句,对stockAmount的有效位数长度做了检查,在检查之后,就可以放心地假定它的长度不超过42。如果使用SafeMath的话,每个乘号和除号都会带来一个require语句,Gas消耗会显著上升。
有理数思维(Thinking in Rational)
EVM和Solidity不支持浮点数,这的确带来了一些不便。浮点数其实是一种特殊的有理数,它的分母必须是二的整数次幂。如果我们用有理数来思考问题,那么就可以达到和使用浮点数相同的效果。有理数由一个整数分子和一个整数分母构成,表示它需要两个整数。
OneSwap的交易对在查询价格的时候,返回三个价格:卖一的价格,买一的价格,以及AMM池子的价格,每个价格由一个分子和一个分母来表示,总共6个整数,如下列代码所示:
function getPrices() external override returns (
uint firstSellPriceNumerator,
uint firstSellPriceDenominator,
uint firstBuyPriceNumerator,
uint firstBuyPriceDenominator,
uint poolPriceNumerator,
uint poolPriceDenominator)
OneSwap使用的32位十进制浮点数来作为价格的压缩表示,当它被“解压缩后”,形成一个有理数表示的价格(Rational Price),用下面的结构体来表示:
// A price presented as a rational number
struct RatPrice {
uint numerator; // at most 54bits
uint denominator; // at most 76bits
}
OneSwap在计算费率时,使用BPS(Base Points)来表示,在OneSwapFactory中记录的变量feeBPS,要除以10000,才是费率的值。例如feeBPS=50,那么费率是50/10000=0.5%。
使用有理数时,要特别注意有效位数的问题。OneSwap在计算同AMM资金池的Token交换量时,需要计算:reserveStock*(sqrt(numerator/denominator)-1)
,这里的numerator/denominator是一个略大于1的有理数。如果有浮点数支持,这个表达式可以直接计算,因为浮点数的sqrt可以正常接收略大于1的浮点数作为输入。但是在Solidity当中,直接按照这个表达式计算的话,numerator/denominator的结果将是1,最终的结果将是0,这肯定是不行的。
为了保证有足够多的有效位数,我们调整表达式为:reserveStock*(sqrt(numerator*2**64/denominator)-1)/2**32
,对应的代码如下:
numerator = numerator * (1<<64);
uint quotient = numerator / denominator;
uint root = Math.sqrt(quotient); //root is at most 110bits
uint diff = root - (1<<32); //at most 110bits
result = reserveStock * diff;
result /= (1<<32);
这里的quotient,将是一个大于2**64的数,开方的结果root将大于2**32,有足够的位数保证最终result的精确。
舍入机制(Rounding)
在C语言当中,我们使用floor函数对浮点数下取整,使用ceil函数来上取整,使用round函数来四舍五入,solidity不支持浮点数,如何实现类似的效果呢?在进行整数除法的时候,余数部分是会被抛弃掉的,相当于向下取整。因此,我们需要向下取整的时候,直接写除号即可。
向上取整可以用下面的技巧来实现:
uint fee = (amountToTaker * feeBPS + 9999) / 10000;
OneSwap用这行语句来计算交易费fee。amountToTaker是Taker应该获得的Token,乘以费率feeBPS之后得到应当被扣除的交易费。刚才我们提到过,交易费率其实是个有理数:feeBPS/10000。乘以有理数得到的结果如何实现向上取整呢?只需要给分子加上“分母减一”,再进行正常的除法即可。
类似的原理,可以实现有理数a/b
的四舍五入:(a+b/2)/b
。
在设计智能合约时,在舍入问题上,往往都采用“我要占便宜,他人不得薅我羊毛”的原则。根据这个原则,如果向下取整对我有利,则向下;如果向上取整对我有利,则向上;四舍五入不能确定是对谁有利,因此极少被采用。
以太坊上的ERC20 Token,往往都采用18位十进制精度,这样的话,舍入误差能够让“我”所占到的“便宜”,最多也只是10-18个Token,价值极低了,所以对他人而言,也算不上多么的不公平。
尽可能维持精度(Gradual Accuracy Loss)
刚才我们提到,OneSwap在计算同AMM资金池的Token交换量时,需要计算开方,而为了开方计算能精确,需要给分子先乘以2**64。然而这个乘法是有可能发生算术溢出的!
使用SafeMath256中防溢出的乘法函数是否可行呢?虽然这样可以保证程序不会在算术计算时出错,但是它也拒绝了在一些正常的情形下为用户提供服务,粗暴地回滚交易,让用户白白交了Gas,却没有获得预期的结果。
因为我们不能断言绝大多数情况下分子的必然小于2**192,不能断言所有导致分子乘以2**64后发生溢出的情形,都是恶意用户攻击系统导致的,所以还是要想一个办法,在分子大于等于2**192的情况下,也能正常计算开方,哪怕为此会损失一些精度。
分子分母同时除以2的N次幂的话,有理数的值保持不变。如果分子分母的二进制表示中,最低的N位都是零的话,我们可以放心地把分子分母都右移N位。如果最低N位不是零,但是我们也把分子分母同时右移了,那么就会造成精度损失,所幸精度损失会很小,下面是用Python跑出来的一个例子,可以看到最低的36位为零或者不为零,对最终结果的影响很小。
>>> 0xfedcba9876543210987654321*(1<<64)/0x123456789abcdef0123456789
258254417031933725993L
>>> 0xfedcba9876543210000000000*(1<<64)/0x123456789abcdef0000000000
258254417031933725999L
基于这一观察,我们可以试探性地同时右移分子和分母,直到分子足够小,和2**64相乘也不会溢出,在OneSwap的代码中,是这样写的:
while(numerator >= (1<<192)) {
numerator >>= 16;
denominator >>= 16;
}
require(denominator != 0, "OneSwapPair: DIV_BY_ZERO");
numerator = numerator * (1<<64);
这里仍然有一个require语句,但它只有在分子分母相差2**128倍以上时才会回滚交易,而这几乎是不可能的。现在我们可以确信,对于绝大多数情况,这段代码都能够正常地执行下去,只不过对一些特殊情况,会稍微损失一些精度而已。
查表法(Table Lookup)
前面我们提到,乘方的Gas消耗是“10+50*结果的字节数”,算是比较大的消耗了。以普遍的ERC20的token精度18为例,10**18的结果需要用8个字节来表示,因此10**18这个表达式的Gas消耗就是10+50*8=410,相当于上百次普通的算术运算。
当乘方的指数不算特别大的时候,可以使用查表法来计算乘方的结果,例如OneSwap在32位十进制浮点数的“解压缩”过程中,用下面的两个函数来帮助计算乘方的结果:
// 10 ** (i + 1)
function powSmall(uint32 i) internal pure returns (uint) {
uint x = 2695994666777834996822029817977685892750687677375768584125520488993233305610;
return (x >> (32*i)) & ((1<<32)-1);
}
// 10 ** (i * 8)
function powBig(uint32 i) internal pure returns (uint) {
uint y = 3402823669209384634633746076162356521930955161600000001;
return (y >> (64*i)) & ((1<<64)-1);
}
上面代码中的256位整数x,是由8个32位的整数拼合而成的,powSmall从中选取第i个32位整数;而y是由4个64位的整数拼合而成的,powBig从中选取第i个64位整数。通过事先计算好x和y,可以保证powSmall(i)==10 ** (i + 1),而powBig(i)==10 ** (i * 8)。
泰勒展开
Solidity并不直接支持开方运算。Uniswap和OneSwap所使用的Math库中的sqrt函数,使用了巴比伦算法来精确地计算平方根。巴比伦算法是牛顿迭代法用于开平方时的一种特例,需要很多步循环迭代才能得到精确结果,因此它消耗的Gas也是比较高的,往往要4000以上。
考虑到这里被开根号的有理数numerator / denominator,是一个略大于1的值,可以认为在绝大多数时候,它都小于1.25。因此这里我们可以使用过泰勒展开来加速计算,只需展开到第三项,精度就足够好了。在1.0附近将开平方根函数展开到第三项,得到:
将上述公式中的表示为有理数x/(2**64)
,我们得到下面的代码:
numerator = numerator * (1<<64);
uint quotient = numerator / denominator;
uint x = quotient - (1<<64);
uint y = x*x;
y = x/2 - y/(8*(1<<64)) + y*x/(16*(1<<128));
result = reserveStock * y;
result /= (1<<64);
这段代码以非常少的Gas,完成了reserveStock*(sqrt(numerator/denominator)-1)
的计算。
假如在我们的应用中,有理数numerator / denominator可能的取值范围比较大怎么办?例如它位于[1,10]区间内。这时候,我们可以把这个区间按等比数列分成好几段,例如8段:[1.0, 1.33]、[1.33, 1.78]、[1.78, 2.37]、[2.37, 3.16]、[3.16, 4.22]、[4.22, 5.62]、[5.62, 7.50]、[7.50, 10.0]。在每段内部做泰勒展开,然后把每段中泰勒展开的系数,记录在一张表里;计算时先判断落在哪一段里,再用上面说的查表法来取出系数。
所有对实数做计算的复杂函数,例如三角函数、自然对数、双曲函数等等,都可以用这种“分段查表+泰勒展开”的方法,快速获得精度足够的结果。泰勒展开可以拟合任意函数的特征,保证了我们可以进行任意复杂函数的计算,即使是在Solidity欠缺浮点数支持的情况下。
总结
在本文中,我们首先介绍了Solidity所支持的整数类型以及在这些整数上进行的计算,接着解释了计算溢出的问题及其规避方法。然后,针对Solidity不支持浮点数这一弱点,介绍了若干弥补的措施:使用有理数来代替浮点数;如何模拟浮点数的舍入;如何在256位总长度的限制保证足够的有效位数又不至于产生溢出;利用查表法和泰勒展开,以最少的Gas消耗完成复杂函数的计算。
原文:《OneSwap Series 3-Arithmetic Operations in Solidity》https://medium.com/@OneSwap/oneswap-series-3-arithmetic-operations-in-solidity-2f32fbb2ccbc
翻译:OneSwap中文社区