输入以搜索 · Esc 关闭

把协议写两遍

有一类缺陷,任何测试集都抓不到,无论多么周全。不是因为测试写得差——而是因为测试和代码出自同一个人、同一种理解,而它们在某件错事上达成了一致。

与自己的测试达成一致的缺陷

通常的测试问的是:代码做的是不是我期望的?你把期望写成断言,让机器去检查。这能找出代码里的错误,而找不出期望里的错误。

如果你读错了规范——把未阻尼的值当成了阻尼值,把可覆盖多个单位的操作当成了只覆盖一个——你会带着同样的误读去写测试。测试通过了。覆盖率完整。缺陷还在,而且从内部看不见。

你写的每一个测试都是你理解的一份副本。理解错的地方,副本在同一处也错,而且两者会永远互相同意。

差分测试

出路是造一份不共享第一份假设的实现,然后用相同的输入跑两边并比较。哪里不一致,至少有一边是错的——而你必须回到规范去弄清是哪一边。

独立性是全部要点,而它很容易丢失:

  • 依据规范写,而不是依据代码写。先读代码会把代码的假设一并带进来,你得到的就是一次昂贵的翻译,而不是一次核查。
  • 用另一种语言。不同的算术、不同的溢出行为、不同的写法习惯——由此产生的不一致是有信息量的,而不是恼人的。
  • 允许它慢。参考实现不会被部署。清晰胜过效率;照文档陈述的样子把公式写出来。
  • 最好换一个人来写。并非总能做到。做不到时,就在两次之间留出时间——重新读一遍规范,读到的东西是不一样的。

它实际找到了什么

本项目的两个例子,都由比对发现,都不是测试发现的:

  • 混用的一对数值。某项安全检查中的余量计算用了未阻尼的数字,而它所保护的那个量用的是阻尼值。所有单元测试都通过,因为测试是用同样的方式算的。直接照公式写的参考实现,在小资金池上给出了不同的数字——而在小资金池上,安全阈值可能被击穿。
  • 未定义的情形。发行价格是针对单个单位定义的,而操作一次可以覆盖非常多的单位。合约和测试都默默假设了小的那种情形。照字面读,一笔大额存入会以起始价格拿下整整一个阶段。

两者都不稀奇。它们都是同一个头脑写下两边之后的寻常结果。

参考实现的长期用处

它不是一次性的练习。一旦存在,参考实现会持续带来回报:

  • 属性测试可以向两边扔上千个随机输入并比对——这是一台带了判准的模糊测试器,而不是只会找崩溃的那种。
  • 改动会拿它来校验,于是悄悄改变行为的重构会以“不一致”的形式暴露出来。
  • 它对不读 Solidity 的人也是可读的,这让更多双眼睛得以参与审阅。
  • 当两边不一致而规范本身含糊时,那就是规范的缺陷——在任何人部署任何东西之前被找了出来。

相关的手法:攻击你自己的测试

第二个问题是:这些测试到底有没有在看。变异测试回答它:在编译后的代码里注入一个故意的缺陷——翻转一个比较、改动一个常数——然后跑测试集。如果它仍然通过,那么这个缺陷所在的区域没有任何东西在观察。

这种不适感是有用的。覆盖率说明哪些行被执行了;变异测试说明这些行如果是错的,会不会有人察觉。两者不是一回事,而只有后者是测试自身的属性。

它的成本

为这种规模的协议写一份参考实现是以天计,而不是以月计,恰恰因为它被允许又慢又朴素。与部署之后才发现一个阈值缺陷的代价相比,这笔账算不上接近。

Common questions

这和形式化验证是一回事吗?

不是。形式化验证证明性质对所有输入成立;差分测试只在你试过的输入上比较两份实现。验证更强,也昂贵得多。两者回答不同的问题,不是替代关系。

同一个人写两份还有用吗?

比两个人差,比什么都不做强——尤其是中间隔了时间,并且以规范而非代码为来源。上面那两个错误正是这样被找到的。

如果错的是参考实现呢?

这会发生,而且仍然是收获:不一致把你送回规范,你离开时知道哪一种读法是对的,而不是靠假设。

本项目是怎么做的

Assetrix 协议被实现了两遍:一遍用 Solidity 写成合约,一遍依据白皮书用 Python 写成,并刻意不先读合约。两者用相同的输入比对,上面描述的两个错误正是这次比对的产物。

测试集包含 481 项本地测试,外加 8 项针对 Arbitrum One 主网分叉的测试,其本身还由针对编译后字节码的变异测试来检验。这一切都不能替代由未参与编写的人所做的审阅——那还没有发生,下面那一页把这点说得很清楚。