scrypt / how-to-test-a-contract.md
codenlighten's picture
Upload 34 files
711e9c6
metadata
sidebar_position: 5

How to Test a Contract

Before using a smart contract in production, one should always test it carefully, especially because any bug in it may cause real economic losses.

There are two different kinds of tests recommended for every project using sCrypt:

  • Local Unit Testing
  • Testnet Integration Testing

Now we will take a look at the file tests/local/demo.ts. This file contains code for deployment of our Demo contract on the Bitcoin testnet and a subsequent public method call on the contract.

But before going into details, you should learn some basic models of sCrypt for signing and sending transactions.

Compile the Contract

First, call function SmartContract.compile() to compile the contract before doing any testing.

await Demo.compile()

Provider

A Provider is an abstraction of a standard Bitcoin node that provides connection to the Bitcoin network, for read and write access to the blockchain.

sCrypt already has a few built-in providers:

  • DummyProvider: A mockup provider just for local tests. It does not connect to the Bitcoin blockchain and thus cannot send transactions.

  • DefaultProvider: The default provider is the safest, easiest way to begin developing on Bitcoin, and it is also robust enough for use in production. It can be used in testnet as well as mainnet.

  • See full list of providers here.

You can initialize these providers like this:

let dummyProvider = new DummyProvider();

// Mainnet
let provider = new DefaultProvider();
// Or explicitly: let provider = new DefaultProvider(bsv.Networks.mainnet);

// Testnet
let provider = new DefaultProvider(bsv.Networks.testnet);

Signer

A Signer is an abstraction of private keys, which can be used to sign messages and transactions. A simple signer would be a single private key, while a complex signer is a wallet.

TestWallet

For testing purposes only, we have a built-in wallet called TestWallet. It can be created like this:

const signer = new TestWallet(privateKey, provider);

privateKey can be a single private key or an array of private keys that the wallet can use to sign transactions. The ability of the wallet to send transactions is assigned to provider. In other words, a TestWallet serves as both a signer and a provider.

Test a Contract Locally

Compared to other blockchains, smart contracts on Bitcoin are pure.

  • Given the same input, its public method always returns the same boolean output: success or failure. It has no internal state.
  • A public method call causes no side effects.

Smart contracts are similar to mathematical functions. Thus, we can test a contract locally without touching the Bitcoin blockchain. If it passes tests off chain, we are confident it will behave the same on chain.

Prepare a Signer and Provider

For local testing, we can use the TestWallet, with a mock provider. The TestWallet and DummyProvider combination would be ideal for local tests because it can sign the contract call transactions without actually sending them.

Such a signer may be declared as below:

let signer = new TestWallet(privateKey, new DummyProvider());

Don't forget to connect the signer to the contract instance as well:

await instance.connect(signer);

Call a Public Method

Similar to what we described in this section, you can call a contract's public @method on the blockchain as follows:

// build and send tx for calling `foo`
const { tx, atInputIndex } = await instance.methods.foo(arg1, arg2, options);
console.log(`Smart contract method successfully called with txid ${tx.id}`);

Remember that the tx is not actually sent anywhere in a local test because we connect to a mock provider.

Verify the Tx input for the method call

In the previous step, the signed tx for the contract call and its input index are returned. You can call verifyScript on the returned tx to verify that the contract method call at the given tx input index is successful.

let result = tx.verifyScript(atInputIndex)
console.log(result.success) // Output: true or false

Integrate with a testing framework

You can use whatever testing framework you like to write unit tests for your contract. For example, a local test using Mocha is shown below:

describe('Test SmartContract `Demo`', () => {
  let signer;
  let demo;

  before(async () => {
    // compile contract
    await Demo.compile()

    // create a test wallet as signer, connected to a dummy provider
    signer = new TestWallet(privateKey, new DummyProvider())

    // initialize a contract instance
    demo = new Demo(1n, 2n)

    // connect the instance to signer
    await demo.connect(signer)
  })

  it('should pass the public method unit test successfully.', async () => {
    // call `demo.methods.add` to get a signed tx
    const { tx: callTx, atInputIndex } = await demo.methods.add(
      // pass in the right argument
      3n,
      // set method call options
      {
        // Since `demo.deploy` hasn't been called before, a fake UTXO of the contract should be passed in.
        fromUTXO: dummyUTXO
      } as MethodCallOptions<Demo>
    )

    let result = callTx.verifyScript(atInputIndex)
    expect(result.success, result.error).to.eq(true)
  })

  it('should pass the non-public method unit test', () => {
    expect(demo.sum(3n, 4n)).to.be.eq(7n)
  })

  it('should throw error', () => {
    return expect(
      // Using the wrong argument when calling this function just results in an error.
      demo.methods.add(4n, { fromUTXO: dummyUTXO })
    ).to.be.rejectedWith(/add check failed/)
  })
})

Test a Stateful Contract

Stateful contact testing is very similar to what we have described above. The only different is that you have to be aware of smart contract instance changes after method calls.

As described in the Overview, for each method call, a tx contains new contract UTXO(s) with the latest updated state, i.e., the next instance. From the perspective of the current spending tx, the public @method of a contract instance is called in one of its inputs, and the next contract instance is stored in one (or more) of its outputs.

Now, let's look at how to test the incrementOnChain method call:

// initialize the first instance, i.e., deployment
let counter = new Counter(0n);
// connect it to a signer
counter.connect(dummySigner());

// set the current instance to be the first instance
let current = counter;

// create the next instance from the current
let nextInstance = current.next();

// apply the same updates on the next instance locally
nextInstance.increment();

// call the method of current instance to apply the updates on chain
const { tx: tx_i, atInputIndex } = await current.methods.incrementOnChain(
  {
    // Since `counter.deploy` hasn't been called before, a fake UTXO of the contract should be passed in.
    fromUTXO: getDummyUTXO(balance),

    // the `next` instance and its balance should be provided here
    next: {
      instance: nextInstance,
      balance
    }
  } as MethodCallOptions<Counter>
);

// check the validity of the input script generated for the method call.
let result = tx_i.verifyScript(atInputIndex);
expect(result.success, result.error).to.eq(true);

In general, we call the method of a stateful contract in 3 steps:

1. Build the current instance

The current instance refers to the contract instance containing the latest state on the blockchain. The first instance is in the deployment transaction. In the above example, we initialize the current instance to be the first instance like this:

let current = counter;

2. Create a next instance and apply updates to it off chain

The next instance is the new instance in the UTXO of the method calling tx.

To create the next of a specific contract instance, you can simply call next() on it:

let nextInstance = instance.next();

It will make a deep copy of all properties and methods of instance to create a new one.

Then, you should apply all the state updates to the next instance. Please note that these are just local/off-chain updates and are yet to be applied to the blockchain.

nextInstance.increment();

This is the SAME method we call on chain in incrementOnChain, thanks to the fact that both the on-chain smart contract and off-chain code are written in TypeScript.

3. Call the method on the current instance to apply updates on chain

As described in this section, we can build a call transaction. The only difference here is that we pass in the next instance and its balance as a method call option in a stateful contract. So the method (i.e., incrementOnChain) have all the information to verify that all updates made to the next instance follow the state transition rules in it.

const { tx: tx_i, atInputIndex } = await current.methods.incrementOnChain(
  {
    // Since `counter.deploy` hasn't been called before, a fake UTXO of the contract should be passed in.
    fromUTXO: getDummyUTXO(balance),

    // the `next` instance and its balance should be provided here
    next: {
      instance: nextInstance,
      balance
    }
  } as MethodCallOptions<Counter>
);

Finally, we can check the validity of the method call as before.

let result = tx_i.verifyScript(atInputIndex);
expect(result.success, result.error).to.eq(true);

Running the tests

As before, we can just use the following command:

npm run test

Full code is here.

You may visit here to see more details on contract deployment and call.