把协议写两遍
有一类缺陷,任何测试集都抓不到,无论多么周全。不是因为测试写得差——而是因为测试和代码出自同一个人、同一种理解,而它们在某件错事上达成了一致。
与自己的测试达成一致的缺陷
通常的测试问的是:代码做的是不是我期望的?你把期望写成断言,让机器去检查。这能找出代码里的错误,而找不出期望里的错误。
如果你读错了规范——把未阻尼的值当成了阻尼值,把可覆盖多个单位的操作当成了只覆盖一个——你会带着同样的误读去写测试。测试通过了。覆盖率完整。缺陷还在,而且从内部看不见。
你写的每一个测试都是你理解的一份副本。理解错的地方,副本在同一处也错,而且两者会永远互相同意。
差分测试
出路是造一份不共享第一份假设的实现,然后用相同的输入跑两边并比较。哪里不一致,至少有一边是错的——而你必须回到规范去弄清是哪一边。
独立性是全部要点,而它很容易丢失:
- 依据规范写,而不是依据代码写。先读代码会把代码的假设一并带进来,你得到的就是一次昂贵的翻译,而不是一次核查。
- 用另一种语言。不同的算术、不同的溢出行为、不同的写法习惯——由此产生的不一致是有信息量的,而不是恼人的。
- 允许它慢。参考实现不会被部署。清晰胜过效率;照文档陈述的样子把公式写出来。
- 最好换一个人来写。并非总能做到。做不到时,就在两次之间留出时间——重新读一遍规范,读到的东西是不一样的。
它实际找到了什么
本项目的两个例子,都由比对发现,都不是测试发现的:
- 混用的一对数值。某项安全检查中的余量计算用了未阻尼的数字,而它所保护的那个量用的是阻尼值。所有单元测试都通过,因为测试是用同样的方式算的。直接照公式写的参考实现,在小资金池上给出了不同的数字——而在小资金池上,安全阈值可能被击穿。
- 未定义的情形。发行价格是针对单个单位定义的,而操作一次可以覆盖非常多的单位。合约和测试都默默假设了小的那种情形。照字面读,一笔大额存入会以起始价格拿下整整一个阶段。
两者都不稀奇。它们都是同一个头脑写下两边之后的寻常结果。
参考实现的长期用处
它不是一次性的练习。一旦存在,参考实现会持续带来回报:
- 属性测试可以向两边扔上千个随机输入并比对——这是一台带了判准的模糊测试器,而不是只会找崩溃的那种。
- 改动会拿它来校验,于是悄悄改变行为的重构会以“不一致”的形式暴露出来。
- 它对不读 Solidity 的人也是可读的,这让更多双眼睛得以参与审阅。
- 当两边不一致而规范本身含糊时,那就是规范的缺陷——在任何人部署任何东西之前被找了出来。
相关的手法:攻击你自己的测试
第二个问题是:这些测试到底有没有在看。变异测试回答它:在编译后的代码里注入一个故意的缺陷——翻转一个比较、改动一个常数——然后跑测试集。如果它仍然通过,那么这个缺陷所在的区域没有任何东西在观察。
这种不适感是有用的。覆盖率说明哪些行被执行了;变异测试说明这些行如果是错的,会不会有人察觉。两者不是一回事,而只有后者是测试自身的属性。
它的成本
为这种规模的协议写一份参考实现是以天计,而不是以月计,恰恰因为它被允许又慢又朴素。与部署之后才发现一个阈值缺陷的代价相比,这笔账算不上接近。
Common questions
这和形式化验证是一回事吗?
不是。形式化验证证明性质对所有输入成立;差分测试只在你试过的输入上比较两份实现。验证更强,也昂贵得多。两者回答不同的问题,不是替代关系。
同一个人写两份还有用吗?
比两个人差,比什么都不做强——尤其是中间隔了时间,并且以规范而非代码为来源。上面那两个错误正是这样被找到的。
如果错的是参考实现呢?
这会发生,而且仍然是收获:不一致把你送回规范,你离开时知道哪一种读法是对的,而不是靠假设。
本项目是怎么做的
Assetrix 协议被实现了两遍:一遍用 Solidity 写成合约,一遍依据白皮书用 Python 写成,并刻意不先读合约。两者用相同的输入比对,上面描述的两个错误正是这次比对的产物。
测试集包含 481 项本地测试,外加 8 项针对 Arbitrum One 主网分叉的测试,其本身还由针对编译后字节码的变异测试来检验。这一切都不能替代由未参与编写的人所做的审阅——那还没有发生,下面那一页把这点说得很清楚。