Type to search · Esc to close

Writing the protocol twice

There is a class of bug that no test suite catches, no matter how thorough. Not because the tests are bad — because the tests and the code were written by the same person, from the same understanding, and they agree about something that is wrong.

The bug that agrees with its test

Testing normally asks: does the code do what I expect? You write the expectation as an assertion and the machine checks it. This finds mistakes in the code and misses mistakes in the expectation.

If you misread the specification — took a damped value where the formula meant an undamped one, assumed an operation covers one unit where it can cover many — you will write the test with the same misreading. The test passes. Coverage is complete. The bug is still there, and it is invisible from inside.

Every test you write is a copy of your understanding. Where the understanding is wrong, the copy is wrong in the same place, and the two agree with each other forever.

Differential testing

The way out is to build a second implementation that does not share the first one’s assumptions, then run both on the same inputs and compare. Where they disagree, at least one is wrong — and you have to go back to the specification to find out which.

Independence is the whole point, and it is easy to lose:

  • Write it from the specification, not from the code. Reading the code first imports its assumptions, and you get an expensive translation rather than a check.
  • Use a different language. Different arithmetic, different overflow behaviour, different idioms — the mismatches this produces are informative rather than annoying.
  • Let it be slow. The reference is not going to be deployed. Clarity beats efficiency; write the formula the way the document states it.
  • Ideally, a different person. Not always possible. Where it is not, put time between the two — a specification read afresh is read differently.

What it actually finds

Two examples from this project, both found by comparison and neither by tests:

  • A mixed pair of values. The headroom calculation in one safety check used undamped figures while the quantity it was protecting used the damper. Every unit test passed, because the tests computed the same way. The reference, written straight from the formula, produced a different number on a small pool — and on a small pool the safety threshold could be breached.
  • An undefined case. The issue price was specified for a unit, and operations can cover very many units at once. The contract and the tests both silently assumed the small case. Read literally, a large deposit would have claimed a whole phase at the opening price.

Neither is exotic. Both are the ordinary result of one mind writing both sides.

Where the reference stays useful

It is not a one-off exercise. Once it exists, the reference keeps paying:

  • Property tests can throw thousands of random inputs at both and compare — a fuzzer with an oracle attached, rather than a fuzzer looking only for crashes.
  • Changes get checked against it, so a refactor that quietly alters behaviour shows up as a disagreement.
  • It is readable by people who do not read Solidity, which makes review possible for a wider set of eyes.
  • When the two disagree and the specification is ambiguous, that is a defect in the specification — found before anyone deployed anything.

The related trick: attack your own tests

A second question is whether the tests are looking at all. Mutation testing answers it: inject a deliberate defect into the compiled code — flip a comparison, change a constant — and run the suite. If it still passes, that defect is in a region nothing observes.

It is uncomfortable in a useful way. Coverage says which lines ran; mutation says whether anything would have noticed if those lines were wrong. The two are not the same, and only the second is a property of the tests.

What it costs

A reference implementation of a protocol of this size is days, not months, precisely because it is allowed to be slow and plain. Set against the cost of finding a threshold bug after deployment, the arithmetic is not close.

Common questions

Is this the same as formal verification?

No. Formal verification proves properties hold for all inputs; differential testing compares two implementations on the inputs you try. Verification is stronger and much more expensive. The two answer different questions and are not alternatives.

Does it help if the same person writes both?

Less than two people, more than nothing — especially with time in between and the specification rather than the code as the source. The two examples above were found exactly that way.

What if the reference is the one that is wrong?

That happens, and it is still a win: a disagreement sends you back to the specification, and you leave knowing which reading is right instead of assuming.

How this was done here

The Assetrix protocol was implemented twice: once in Solidity as the contract, and once in Python from the white paper, deliberately without reading the contract first. The two are compared on the same inputs, and the two errors described above came out of that comparison.

The suite is 481 local tests plus 8 against a fork of live Arbitrum One, and it is itself checked by mutation testing against the compiled bytecode. None of this substitutes for review by people who did not write it — that has not happened yet, and the page below says so plainly.