本文是关于在 Solidity 中进行数学运算的系列文章中的第三篇 。 这次的主题是:百分比和比例。
介绍
金融数学从百分比开始。y的x百分比是多少?y占x的多少百分比?我们都知道答案:y的x百分比是x × y ÷100,y是y ×100÷ x的 x 百分比。这是学校数学。
上面的公式是求解比例的特例。通常,比例是以下形式的等式:a ÷ b = c ÷ d,求解比例就是找到已知其他三个值中的一个值。例如,d可以从a、b和c中找到,如下所示:d = b × c ÷ a。
在主流编程语言中简单明了,在 Solidity 中,这种简单的计算具有惊人的挑战性,正如我们在上一篇文章中所展示的那样。这有两个原因:i) Solidity 不支持分数;ii) Solidity 中的数字类型可能会溢出。
在 Javascript 中,可以像这样简单地计算x × y ÷ zx*y/z
:在 solidity 中这样的表达式不会通过安全审计,因为对于足够大的x和y乘法可能会溢出,从而计算结果可能不正确。使用 SafeMath 帮助不大,因为即使最终计算结果适合 256 位,它也可能导致交易失败。在上一篇文章中,我们将这种情况称为“幻象溢出”。在乘法之前进行除法,例如x/z*y
ory/z*x
可以解决幻象溢出问题,但可能会导致精度下降。
在本文中,我们发现 Solidity 中有哪些更好的方法来处理百分比和比例。
走向全比例
本文的目标是在 Solidity 中实现以下功能:
function mulDiv (uint x, uint y, uint z)
public pure returns (uint)
计算x × y ÷ z ,将结果向下舍入,并在z为零或结果不适合的情况下抛出uint
。让我们从以下简单的解决方案开始:
function mulDiv (uint x, uint y, uint z)
public pure returns (uint)
{
return x * y / z;
}
这个解决方案基本上满足了大部分需求:它似乎计算x × y ÷ z ,将结果向下舍入,并在z为零的情况下抛出。但是,有一个问题:它实际计算的是x × y mod 2²⁵⁶ ÷ z。这就是乘法溢出在 Solidity 中的工作原理。当乘法结果不适合 256 位时,只返回结果的最低 256 位。对于较小的x和y值,当x × y <2²⁵⁶ 时,没有区别,但对于较大的x和y这会产生不正确的结果。所以第一个问题是:
我们如何防止溢出?
剧透:我们不应该。
在 Solidity 中防止乘法溢出的常用方法是使用mul
SafeMath 库中的函数:
function mulDiv (uint x, uint y, uint z)
public pure returns (uint)
{
return mul (x, y) / z;
}
这段代码保证了正确的结果,所以现在所有的要求似乎都满足了,对吧?没那么快。
要求是在结果不适合的情况下恢复uint
,这个实现似乎可以满足它。但是,即使最终结果适合,当x × y不适合时,此实现也会恢复。uint
我们称这种情况为“幻象溢出”。在上一篇文章中,我们展示了如何以精度为代价解决幻象溢出问题,但是该解决方案在这里不起作用,因为我们需要精确的结果。
由于只是恢复幻象溢出不是一种选择,那么
我们如何避免幻影溢出并保持精度?
剧透:简单的数学技巧。
让我们进行以下替换:x = a × z + b和y = c × z + d,其中a、b、c和d是整数,并且 0≤ b < z和 0≤ d < z。然后:
x × y ÷ z =
( a × z + b )×( c × z + d )÷ z =
( a ×c× z² +( a × d + b × c )× z + b × d )÷ z =
a × c × z + a × d + b × c + b × d ÷ z
值a、b、c和d可以通过将x和y分别除以z计算为商和提醒。
因此,该函数可以这样重写:
function mulDiv (uint x, uint y, uint z)
public pure returns (uint)
{
uint a = x / z; uint b = x % z; // x = a * z + b
uint c = y / z; uint d = y % z; // y = c * z + d
return a * b * z + a * d + b * c + b * d / z;
}
这里我们为了可读性使用plain +
and *
operators,而真正的代码应该使用SafeMath函数来防止真正的,即非幻象的溢出。
在此实现中,幻像溢出仍然是可能的,但仅限于最后一项:b * d / z
。但是,当z ≤2¹²⁸时,此代码保证可以正常工作,因为b和d都小于z,因此b × d保证适合 256 位。因此,此实现可用于已知z不超过 2¹²⁸ 的情况。一个常见的例子是 18 位小数的定点乘法:x × y ÷10¹⁸。但,
我们如何才能完全避免幻影溢出?
剧透:使用更宽的数字。
幻象溢出问题的根源在于中间乘法结果不适合 256 位。所以,让我们使用更宽的类型。Solidity 本身不支持大于 256 位的数字类型,因此我们必须模拟它们。我们基本上需要两个操作:uint × uint → wide and wide ÷ uint → uint。
由于两个 256 位无符号整数的乘积不得超过 512 位,因此较宽的类型必须至少为 512 位宽。我们可以通过一对两个 256 位无符号整数分别持有整个 512 位数字的低位和高位 256 位部分来模拟 Solidity 中的 512 位无符号整数。
因此,代码可能如下所示:
function mulDiv (uint x, uint y, uint z)
public pure returns (uint)
{
(uint l, uint h) = fullMul (x, y);
return fullDiv (l, h, z);
}
这里fullMul
函数将两个 256 位无符号整数相乘,并将结果作为 512 位无符号整数分成两个 256 位部分返回。函数fullDiv
除以 512 位无符号整数,作为两个 256 位部分传递,但 256 位无符号整数并返回结果作为 256 位无符号整数。
让我们以学校数学的方式实现这两个功能:
function fullMul (uint x, uint y)
public pure returns (uint l, uint h)
{
uint xl = uint128 (x); uint xh = x >> 128;
uint yl = uint128 (y); uint yh = y >> 128;
uint xlyl = xl * yl; uint xlyh = xl * yh;
uint xhyl = xh * yl; uint xhyh = xh * yh;
uint ll = uint128 (xlyl);
uint lh = (xlyl >> 128) + uint128 (xlyh) + uint128 (xhyl);
uint hl = uint128 (xhyh) + (xlyh >> 128) + (xhyl >> 128);
uint hh = (xhyh >> 128);
l = ll + (lh << 128);
h = (lh >> 128) + hl + (hh << 128);
}
和
function fullDiv (uint l, uint h, uint z)
public pure returns (uint r) {
require (h < z);
uint zShift = mostSignificantBit (z);
uint shiftedZ = z;
if (zShift <= 127) zShift = 0;
else
{
zShift -= 127;
shiftedZ = (shiftedZ - 1 >> zShift) + 1;
}
while (h > 0)
{
uint lShift = mostSignificantBit (h) + 1;
uint hShift = 256 - lShift;
uint e = ((h << hShift) + (l >> lShift)) / shiftedZ;
if (lShift > zShift) e <<= (lShift - zShift);
else e >>= (zShift - lShift);
r += e;
(uint tl, uint th) = fullMul (e, z);
h -= th;
if (tl > l) h -= 1;
l -= tl;
}
r += l / z;
}
这mostSignificantBit
是一个函数,它返回参数最高有效位的从零开始的索引。该功能可以通过以下方式实现:
function mostSignificantBit (uint x) public pure returns (uint r) {
require (x > 0);
if (x >= 2**128) { x >>= 128; r += 128; }
if (x >= 2**64) { x >>= 64; r += 64; }
if (x >= 2**32) { x >>= 32; r += 32; }
if (x >= 2**16) { x >>= 16; r += 16; }
if (x >= 2**8) { x >>= 8; r += 8; }
if (x >= 2**4) { x >>= 4; r += 4; }
if (x >= 2**2) { x >>= 2; r += 2; }
if (x >= 2**1) { x >>= 1; r += 1; }
}
上面的代码相当复杂,可能应该解释一下,但我们现在将跳过解释并专注于不同的问题。这段代码的问题是每次函数调用消耗大约 2.5K gas mulDiv
,这是相当多的。所以,
我们能做得更便宜吗?
剧透:数学魔法!
下面的代码基于令人兴奋的数学发现
. 如果您喜欢这段代码,请点赞他的“mathemagic”文章。
首先,我们重写fullMul
函数:
function fullMul (uint x, uint y)
public pure returns (uint l, uint h)
{
uint mm = mulmod (x, y, uint (-1));
l = x * y;
h = mm - l;
if (mm < l) h -= 1;
}
fullMul
这样每次调用可以节省大约 250 gas 。
然后我们重写mulDiv
函数:
function mulDiv (uint x, uint y, uint z)
public pure returns (uint) {
(uint l, uint h) = fullMul (x, y);
require (h < z);
uint mm = mulmod (x, y, z);
if (mm > l) h -= 1;
l -= mm;
uint pow2 = z & -z;
z /= pow2;
l /= pow2;
l += h * ((-pow2) / pow2 + 1);
uint r = 1;
r *= 2 - z * r;
r *= 2 - z * r;
r *= 2 - z * r;
r *= 2 - z * r;
r *= 2 - z * r;
r *= 2 - z * r;
r *= 2 - z * r;
r *= 2 - z * r;
return l * r;
}
此实现每次调用仅消耗约 550 gas,mulDiv
并且可以进一步优化。比学校数学方法好 5 倍。非常好!但是一个人真的必须获得数学博士学位才能写出这样的代码,而且并不是每个问题都有这样神奇的数学解决方案。如果我们可以,事情会简单得多
在 Solidity 中使用浮点数
正如我们在本文开头所说的那样,在 JavaScript 中,只需简单地编写a * b / c
,语言会处理其余部分。如果我们可以在 Solidity 中做同样的事情呢?
其实我们可以。虽然核心语言不支持浮点,但有些库支持。例如,对于ABDKMathQuad库,可以这样写:
function mulDiv (uint x, uint y, uint z)
public pure returns (uint) {
return
ABDKMathQuad.toUInt (
ABDKMathQuad.div (
ABDKMathQuad.mul (
ABDKMathQuad.fromUInt (x),
ABDKMathQuad.fromUInt (y)
),
ABDKMathQuad.fromUInt (z)
)
);
}
不像 JavaScript 那样优雅,不像数学魔术解决方案那样便宜(甚至比学校数学方法更广泛),但简单明了而且非常精确,因为这里使用的四精度浮点数大约有 33 位有效小数。
此实现的一半以上的气体消耗用于将uint
值转换为浮点数并返回,比例计算本身仅消耗约 1.4K 气体。因此,在所有智能合约中使用浮点数可能比混合使用整数和浮点数要便宜得多。
结论
由于溢出和缺乏分数支持,百分比和比例在 Solidity 中可能具有挑战性。然而,各种数学技巧可以正确有效地解决比例问题。
库支持的浮点数可能会以 gas 和精度为代价让生活变得更好。
在我们的下一篇文章中,我们将更深入地探讨金融数学