/ Developer

How TypeScript Makes Smart Contracts Easier to Test and More Robust

Integrating with smart contracts can be tough. In particular, solidity doesn’t really have runtime errors with descriptive messages, so a lot of failed transactions just fail with the generic message invalid opcode.

If it is painful to write tests, you are probably going to write fewer. Test coverage is an important piece of the puzzle for building robust software. At Bloom, we want to do our best to build reliable contracts so we quickly looked for strategies to reduce this testing pain.

Writing tests for smart contracts can be especially tough. In the example gif below, I’m trying to test the buyTokens function and I make a series of mistakes along the way. The test output usually tells me something is wrong, but not much more.

The mistakes in this example are:

  1. Saying amount: 50 instead of value: 50 to specify how much wei to send in a transaction
  2. Calling balanceOf on a sale contract instead of a token contract
  3. Forgetting to wait for the Promise to resolve when getting the return value from a contract function
  4. Sending ether to a non-payable function

These are all mistakes I’ve made a few times before while writing contract tests and it can take much longer to resolve the issues than it takes in the gif above.

We have the technology

The frustrating thing when writing these javascript tests for our contracts is that all of the type information is sitting right next door in our contracts. In fact, our truffle compile step also puts the very simple contract ABIs in our build folder. For the really simple contract we’re testing

pragma solidity ^0.4.15;

import "zeppelin/crowdsale/Crowdsale.sol";

contract SimpleSale is Crowdsale {
   function SimpleSale (
    uint256 _startTime,
    uint256 _endTime,
    uint256 _rate,
    address _wallet
  ) Crowdsale(_startTime, _endTime, _rate, _wallet) {
  }
}

the ABI has a lot of helpful type info:

[
  {
    "constant": true,
    "inputs": [],
    "name": "rate",
    "outputs": [{ "name": "", "type": "uint256" }],
    "payable": false,
    "type": "function"
  },
  {
    "constant": false,
    "inputs": [{ "name": "beneficiary", "type": "address" }],
    "name": "buyTokens",
    "outputs": [],
    "payable": true,
    "type": "function"
  },
  // ...
]

This is only two of the functions the contract exposes, but for these two we see:

  • The function rate does not take any arguments, it does not accept ether, and it returns a 256 bit integer
  • The function buyTokens takes an address as an argument, it doesn’t return anything, and it accepts ether

With a little bit of elbow grease, we can translate this knowledge into TypeScript typings:

import * as Web3 from "web3";
import * as BigNumber from "bignumber.js";

type Address = string;
type TransactionOptions = Partial<Transaction>;
type PayableTransactionOptions = Partial<PayableTransaction>;
type UInt = number | BigNumber.BigNumber;

interface Transaction {
  hash: string;
  nonce: number;
  blockHash: string | null;
  blockNumber: number | null;
  transactionIndex: number | null;
  from: Address | ContractInstance;
  to: string | null;
  gasPrice: UInt;
  gas: number;
  input: string;
}

interface PayableTransaction extends Transaction {
  value: UInt;
}

interface ContractInstance {
  address: string;
  sendTransaction(options?: PayableTransactionOptions): Promise<void>;
}

export interface SimpleSaleInstance extends ContractInstance {
  rate(options?: TransactionOptions): Promise<BigNumber.BigNumber>;
  buyTokens(
    beneficiary: Address,
    options?: PayableTransactionOptions
  ): Promise<void>;
  // Etc...
}

This builds on the fantastic web3 typings from the 0x project. Configuring our workspace to use TypeScript typings improves our workflow a lot compared to the gif earlier. Take a look:

TypeScript catching mistakes

In this example, the TypeScript typings are:

  1. Giving me a list of functions I can call on sale
  2. Telling me the arguments for buyTokens
  3. Reminding me that the function returns a promise which I need to wait for
  4. Correcting me when I type amount instead of value for specifying how much ether to send
  5. Reminding me to change sale to token for calling balanceOf
  6. Pointing out that I cannot send ether to balanceOf and therefore I’m not allowed to specify value as an option

Pretty big difference! Every mistake I had to figure out after seeing the test fail in the first example was shown to me immediately in my editor. Thanks, TypeScript!

Don’t repeat yourself

Typings are nice, but it would be pain to update the type annotations every time we update a contract. Luckily, we can generate our type annotations too.

Looking back at part of our ABI from before and the corresponding type annotations, we can see what information we need to compile into valid TypeScript source:

{
  "constant": false,
  "inputs": [{ "name": "beneficiary", "type": "address" }],
  "name": "buyTokens",
  "outputs": [],
  "payable": true,
  "type": "function"
}

buyTokens(
  beneficiary: Address,
  options?: PayableTransactionOptions
): Promise<void>;

If we write a bit of code to traverse this JSON, we can then just visit each input and match it to a type we define in our generated TypeScript:

// Simplified type mapping

function translateType(type: SolidityType): string {
  switch (type) {
    case "string":
      return "string";
    case "address":
      return "Address";
    case "bool":
      return "boolean";
    case "uint256":
      return 'number | BigNumber.BigNumber';
    default:
      throw `Unexpected case! ${type}`;
  }
}

If we pull out the name of the function and do the same for the outputs (using TypeScript’s tuple types) we can generate each functions source code. Once we generate interfaces for each contract, we can maintain a corresponding declaration file which maps our truffle requires to interfaces:

// truffle.d.ts

import { SimpleSaleInstance } from "./contracts";

declare global {
  var artifacts: Artifacts;
}

interface Artifacts {
  require(name: "SimpleSale"): Contract<SimpleSaleInstance>;
}

Now, our type definitions automatically work when we require the contract in our tests:

Zero overhead type annotations

My current solution is pretty rough, but it works. Checkout our sample repo to see how it works with a basic truffle project.

Further reading

How TypeScript Makes Smart Contracts Easier to Test and More Robust
Share this