Spaces:
Runtime error
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.