Scripts & Simulated Transactions on Nervos CKB

Matthew Hammond
Summa
Published in
6 min readJun 18, 2020

--

Testing in CKB

We recently received a grant from the Nervos Foundation to extend our work on bitcoin-spv to support Nervos CKB scripts. As part of our existing work, we’ve written in-depth test suites for code in a half-dozen languages. Working with CKB, however, presented a few unique challenges. CKB Scripts are written in C, Ruby, or JS and compiled to RISC-V binaries for deployment. Because we expect our work to be used as a library by other projects, we opted to use C. For our testing suite, we wanted to test the library’s stateless code (unit tests), as well as within a CKB Script (integration tests).

Unit tests with libcheck

For bitcoin-spv, we chose to write table tests because they directly map inputs to outputs. This makes them ideal for testing pure functions. Unlike typical golang table tests, we chose to use JSON as a portable data format. Doing so allows us to use the same tables across multiple implementations in different languages. From our work on bitcoin-spv in other languages, we’ve compiled an extensive set of table tests. They check that all versions of the library have the same behavior and that the behavior is correct.

Since we have a good set of test tables, the next step is getting access to them. For parsing the JSON test vector file, we chose JSMN. Most of our testing code is a minimal JSON parser using JSMN to get info from the test vectors file. You can check that out here. The parser is not recommended for production use but should be good enough for testing.

Now, we want a good framework for running our tests. We’re looking for a C library that can automate test running, and report line and branch test coverage. We aim to get 100% test coverage in bitcoin-spv at all times. For the C library, we opted to use libcheck, although there are many equally good alternatives. Just like mocha, jest, pytest, and other testing frameworks, Check allows for coverage, and a number of other useful features. First, we set up the case and runner. We also give it access to the test tables by calling our parser. An “unchecked” fixture means that the code is run once per case, rather than once per test.

From there we lean on Check’s unit test tooling. Check’s START_TEST and END_TEST macros wrap a test function, which you then register as part of the test case. Once the tests are added to the case, we can instruct the runner to run them. In order to run our table tests, we wrote a couple of quick supplemental macros. The first macro loads a test table and starts a loop. The second macro ends the loop.

Now writing our unit tests is easy. Each test starts with the check macro and our loop macro. Then the rest of our test function is inside the test loop. Our test function parses the input and output of the test vector, then calls the function being tested, and finally asserts that the actual output matches the expected output. After cleanup, the loop repeats until it runs out of tests in the table. We close out our function by ending our two macros. This means we write tests as if a single input/output pair were being tested, and the macros handle running that code on all vectors.

Integration tests with simulated CKB transactions

Writing unit tests is very straightforward, but we also want to check the functionality within CKB. Because we don’t want to run a node in development mode, as that adds a lot of complexity to our process, we take a page directly out of the Nervos team’s book and use the transaction validation code directly. In other words, we don’t use a full node, we run the code a full node would run to validate the Script. This requires writing tests in Rust, but Rust is a great language that you should learn anyway :)

A CKB transaction is composed of cells. It lists each cell and runs scripts associated with them (more on this later). We’d like to test a specific Lock Script, so we need to build a transaction that uses it, and then run it through the verifier. The first steps are installing dependencies and loading our Script, by grabbing our compiled RISC-V executable. Then we write a “Dummy” data loader. The data loader stores our fake chain state and will tell the verifier anything we want so that we can have fine-grained control over the verification process.

From a high level, we’ll build a transaction, then run the verifier. We want our transaction to pass verification (and for more in-depth testing, we want to make versions of it that fail verification in many ways). In order to do that, we need to design a valid transaction for our Script. In the case of bitcoin-spv, that Script is fairly complex. It checks a Bitcoin inclusion proof for a transaction and then checks that the CKB output matches some information in that Bitcoin transaction.

You don’t need to worry about understanding the Bitcoin SPV proof, but I would like to talk about each piece of the CKB transaction. CKB transactions have “inputs,” “outputs,” “dependencies.” These describe (respectively) cells that are being consumed, created, and read from. Our transaction will also have a “witness” which contains additional authorization information that the transaction can read. In our case, the witness contains the SPV proof that allows us to spend the input.

Inputs are pointers to cells being consumed. Each cell’s Lock Script must be satisfied to make the transaction valid. In this case, we want an input pointing to a cell with our Lock Script, so we can test it. We have a simple function to create this cell for us. Our input has the Lock Script with static arguments, and a small amount of funds. We build these into a cell and add it to the data loader as well as the transaction. Later, the verifier will read the input, and use it to look up the cell in the data loader.

Outputs describe cells being created. We build a very simple output with a fixed Script and add it to the transaction.

Dependencies are live cells that are being read, but not consumed. Our full Script must be on-chain somewhere, so we read it as a dependency. We simply make a new cell that stores our executable in it, then we add this dependency cell to our data loader, and finally, add it to the transaction.

Witnesses contain information that can be loaded by the Input’s Lock Script. This could include signatures from the user. In our case, it includes a Bitcoin SPV proof (but don’t worry about that). We have a hardcoded witness that we add to the transaction.

Now our transaction has all the necessary pieces. It has an input whose Lock Script is loaded from a Dependency and uses the transaction and Witness to validate the transaction. It then pays some money to the output. We’ve added the previous output and Lock Script executable to our data loader, so all that’s left is to run the resolver to complete the transaction, then run the verifier to execute the Scripts, and check the result.

While the scaffolding is somewhat complex, it’s easy to break down into a number of discrete steps. Hopefully, this helps you write tests for your own CKB libraries and applications!

Summa provides cross-chain architecture and interoperability as a service solutions. We work actively with Ethereum (EIP-152), Keep, The Electric Coin Company (ZIP-221), and the Zcash Foundation. Our partners include Cross-Chain Group, Interchain Foundation, Nervos, NEAR Protocol, Agoric, and Celo.

Stay up to date with Summa on Twitter

--

--

Matthew Hammond
Summa

Head of Growth at Connext. Prev founder @ Summa (acq.), Celo, Storj, Adobe