OneSwap系列三之 Solidity当中的算术运算

EVM同业界著名的虚拟机,例如JVM、WebAssembly等不同,它并不支持编程语言中常用的基本数据类型如int、long、char、float、double等等,它仅仅支持一种基本数据类型,即256位的长整数。如此设计EVM,也有一定的合理性,例如:

  1. 哈希函数的输出一般为256位
  2. 椭圆曲线计算时,使用256位的长整数
  3. 使用256位长整数来实现有理数,在绝大多数场景下,可以替代浮点数,且可以规避浮点计算的平台相关性
  4. 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

我们知道,当采用二进制补码来表示整数的时候,会出现下面这些溢出的问题:

  1. 两个长度为N的整数a、b相加,结果需要N+1个二进制位来表示,如果强行将结果截断为N位,那么它会比a和b都小
  2. 两个长度为N的正整数a、b相减,结果可能是一个负数,如果强行将结果解释为正整数,那么它会是一个比a和b都大的数
  3. 两个长度为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库,那么请您思考:

  1. 是不是必须这么做?因为更短的整数在计算过程中会更加耗费Gas,最好的选择是先把短整数显式地转换为256位整数,完成计算后再显式地转回来
  2. 如果您一定要这么做,那么可以参考这篇文章

SafeMath256以及同它类似的库,根据一些简单的规则来工作,包括:

  1. 加法的和,比任意一个加数小的话,则没有发生溢出
  2. 减数应当小于等于被减数
  3. 乘法的商,除以被乘数,应当等于乘数
  4. 除法和取模操作的分母,不能等于零

如果输入参数违背了这些规则,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中文社区

你可能感兴趣的:(OneSwap系列三之 Solidity当中的算术运算)