Integration testing with NBitcoin

METACO, SA
METACO
Published in
9 min readMar 8, 2019

In Programming the Blockchain in C#, we learned about how to develop with Bitcoin using a public block explorer like QBitNinja.

However using a full block explorer is very often not desirable. If you want to provide reliable service to your customers, you can’t afford downtime caused by third party services. One solution is to setup your own block explorer on your own infrastructure.

However, setting up your own full block explorer is quite challenging. A full block explorer need to index the entirety of all transactions, blocks and scriptPubKeys. This is time consuming, require a big configuration and lot’s of storage. (Blockstream’s Esplora is recommended if you want your full block explorer)

Moreover, this can be quite challenging to effectively test against it. One could imagine mocking the block explorer, but the majority of bugs, especially in Bitcoin programming happens at the integration stage.

So how can we do our integration testing?

One solution is to rely on a shared block explorer at the company level on Testnet. Testnet is an alternative public blockchain for Bitcoin but with worthless coins. Sadly, testnet is highly unpredictable because of very variable hashing power. The chain can be blocked for a day, or mine 1 block every 5 seconds. (we call this event a block storm)

Sharing a testnet server with the whole team is bound to have several issues like lack of reproductibility, bad coverage of corner cases, and penury of testnet coins.

Luckily, you can make your code easy to test in two ways:

  1. Use Regtest
  2. Use NBitcoin.TestFramework
  3. Having an architecture which do no rely on a full block explorer

First, Regtest is a private chain where the developer has the power to mine as many block he wants. Everything which can be done on Mainnet with 100% of hashing power, can be done on Regtest. This make it very useful for integration testing.

Second, NBitcoin.TestFramework is a framework which allow you, in C#, to easily create your own Regtest chain, create bitcoin nodes, connect them together or interact with them.

Third, in almost 95% of services developed for Bitcoin, a full block explorer is not actually needed. What you need is a way to track a wallet’s UTXOs. We will see how to manage a wallet through Bitcoin Core RPC wallet API.

Use case: Using Bitcoin core RPC for sending from Alice to Bob

In this example, we will test a simple test case illustrating Alice sending money to Bob.

We will use Bitcoin Core RPC wallet API to for our operations.

Bitcoin Core RPC is a JSON API exposed by the bitcoind deamon. The bitcoind deamon exposes:

  1. RPC API (we will see in this article)
  2. REST API (rarely used)
  3. Bitcoin P2P protocol (we will see in a later article)
  4. ZMQ queues for block and wallet transaction notifications (not supported by NBitcoin)

We will use .NET Core in command line. We advise you to use a proper IDE like Visual Studio Code (Linux, Mac, Windows) or Visual Studio Community (Windows only) to easily debug or step by step in the code.

Let’s create a test project with NBitcoin.TestFramework, NBitcoin and NBitcoin.Altcoins.

mkdir IntegrationTests
cd IntegrationTests
dotnet new xunit
dotnet add package NBitcoin
dotnet add package NBitcoin.Altcoins
dotnet add package NBitcoin.TestFramework

Now, here is our test plan:

  1. Create a bitcoin full node for Alice
  2. Create a bitcoin full node for Bob
  3. Create a bitcoin full node for the Miner
  4. The Miner gives money to Alice
  5. Alice gives the money to Bob
  6. Let’s make sure Bob received the payment

Let’s modify our test file UnitTest1.cs to have logs and proper test name.

using System;
using Xunit;
using Xunit.Abstractions;

namespace IntegrationTests
{
public class UnitTest1
{
+ public ITestOutputHelper Logger { get; }
+ public UnitTest1(ITestOutputHelper logger)
+ {
+ Logger = logger;
+ }

[Fact]
- public void Test1()
+ public void AliceCanSendToBob()
{
}
}
}

Let’s make sure the miner gives some money to Alice:

[Fact]
public void AliceCanSendToBob()
{
using(var env = NBitcoin.Tests.NodeBuilder.Create(NodeDownloadData.Bitcoin.v0_17_0, Bitcoin.Instance.Regtest))
{
var miner = env.CreateNode();
var alice = env.CreateNode();
var bob = env.CreateNode();
env.StartAll();
Logger.WriteLine("Miner, Alice and Bob started");
var minerRPC = miner.CreateRPCClient();
var aliceRPC = alice.CreateRPCClient();
var bobRPC = bob.CreateRPCClient();

var aliceAddress = aliceRPC.GetNewAddress();
minerRPC.SendToAddress(aliceAddress, Money.Coins(1.0m));
Logger.WriteLine("Miner just sent 1 BTC to Alice");
}
}

We also added the namespaces for NBitcoin at the top.

using System;
+ using NBitcoin;
+ using NBitcoin.Altcoins;
+ using NBitcoin.Tests;
using Xunit;
using Xunit.Abstractions;

Let’s try to run it, note that it can take a while because Bitcoin Core 0.17.0 is downloaded on your machine during the first test.

dotnet test

The test fails:

Build started, please wait...
Build completed.

Test run for C:\Users\NicolasDorier\AppData\Local\Temp\624858694\IntegrationTests\bin\Debug\netcoreapp2.2\IntegrationTests.dll(.NETCoreApp,Version=v2.2)
Microsoft (R) Test Execution Command Line Tool Version 15.9.0
Copyright (c) Microsoft Corporation. All rights reserved.

Starting test execution, please wait...
[xUnit.net 00:00:07.34] IntegrationTests.UnitTest1.AliceCanSendToBob [FAIL]
Failed IntegrationTests.UnitTest1.AliceCanSendToBob
Error Message:
NBitcoin.RPC.RPCException : Insufficient funds
Stack Trace:
at NBitcoin.RPC.RPCResponse.ThrowIfError()
at NBitcoin.RPC.RPCClient.SendCommandAsyncCore(RPCRequest request, Boolean throwIfRPCError)
at NBitcoin.RPC.RPCClient.SendCommandAsync(RPCRequest request, Boolean throwIfRPCError)
at NBitcoin.RPC.RPCClient.SendToAddressAsync(BitcoinAddress address, Money amount, String commentTx, String commentDest, Boolean subtractFeeFromAmount, Boolean replaceable)
at NBitcoin.RPC.RPCClient.SendToAddress(BitcoinAddress address, Money amount, String commentTx, String commentDest, Boolean subtractFeeFromAmount, Boolean replaceable)
at IntegrationTests.UnitTest1.AliceCanSendToBob() in C:\Users\NicolasDorier\AppData\Local\Temp\624858694\IntegrationTests\UnitTest1.cs:line 32
Standard Output Messages:
Miner, Alice and Bob started



Total tests: 1. Passed: 0. Failed: 1. Skipped: 0.
Test Run Failed.
Test execution time: 8.5959 Seconds

It seems that our miner could not send money to Alice. The reason is that the miner does not have any money yet because he has mined no block. A miner would earn 50 BTC per block, however they must wait 100 confirmations before spending it (we call the number of blocks "coinbase maturity"). So the miner need to mine 101 blocks before having a UTXO to spend.

Let’s fix this.

[Fact]
public void AliceCanSendToBob()
{
using(var env = NBitcoin.Tests.NodeBuilder.Create(NodeDownloadData.Bitcoin.v0_17_0, Bitcoin.Instance.Regtest))
{
var miner = env.CreateNode();
var alice = env.CreateNode();
var bob = env.CreateNode();
env.StartAll();
Logger.WriteLine("Miner, Alice and Bob started");
var minerRPC = miner.CreateRPCClient();
var aliceRPC = alice.CreateRPCClient();
var bobRPC = bob.CreateRPCClient();

+ minerRPC.Generate(101);

var aliceAddress = aliceRPC.GetNewAddress();
minerRPC.SendToAddress(aliceAddress, Money.Coins(1.0m));
Logger.WriteLine("Miner just sent 1 BTC to Alice");
}
}

This now work

dotnet test

Build started, please wait...
Build completed.

Test run for C:\Users\NicolasDorier\AppData\Local\Temp\624858694\IntegrationTests\bin\Debug\netcoreapp2.2\IntegrationTests.dll(.NETCoreApp,Version=v2.2)
Microsoft (R) Test Execution Command Line Tool Version 15.9.0
Copyright (c) Microsoft Corporation. All rights reserved.

Starting test execution, please wait...

Total tests: 1. Passed: 1. Failed: 0. Skipped: 0.
+ Test Run Successful.
Test execution time: 9.9467 Seconds

Note that some altcoins have different coin maturity, let’s try with FeatherCoin.

[Fact]
public void AliceCanSendToBob()
{
+ using(var env = NBitcoin.Tests.NodeBuilder.Create(NodeDownloadData.Feathercoin.v0_16_0, Feathercoin.Instance.Regtest))
- using(var env = NBitcoin.Tests.NodeBuilder.Create(NodeDownloadData.Bitcoin.v0_17_0, Bitcoin.Instance.Regtest))
{
var miner = env.CreateNode();
var alice = env.CreateNode();
var bob = env.CreateNode();
env.StartAll();
Logger.WriteLine("Miner, Alice and Bob started");
var minerRPC = miner.CreateRPCClient();
var aliceRPC = alice.CreateRPCClient();
var bobRPC = bob.CreateRPCClient();

minerRPC.Generate(101);

var aliceAddress = aliceRPC.GetNewAddress();
minerRPC.SendToAddress(aliceAddress, Money.Coins(1.0m));
Logger.WriteLine("Miner just sent 1 BTC to Alice");
}
}

Running this will fails because FeatherCoin has a coinbase maturity of 120 blocks.

NBitcoin.RPC.RPCException : Insufficient funds

But this is easy to fix

[Fact]
public void AliceCanSendToBob()
{
using(var env = NBitcoin.Tests.NodeBuilder.Create(NodeDownloadData.Feathercoin.v0_16_0, Feathercoin.Instance.Regtest))
{
var miner = env.CreateNode();
var alice = env.CreateNode();
var bob = env.CreateNode();
env.StartAll();
Logger.WriteLine("Miner, Alice and Bob started");
var minerRPC = miner.CreateRPCClient();
var aliceRPC = alice.CreateRPCClient();
var bobRPC = bob.CreateRPCClient();

- minerRPC.Generate(101);
+ minerRPC.Generate(env.Network.Consensus.CoinbaseMaturity + 1);

var aliceAddress = aliceRPC.GetNewAddress();
minerRPC.SendToAddress(aliceAddress, Money.Coins(1.0m));
Logger.WriteLine("Miner just sent 1 BTC to Alice");
}
}

NBitcoin abstract the particularities of each altcoin to provide a unified model for development and integration testing, here is what we support today.

  • BCash
  • BGold
  • BPlus
  • BitCore
  • Dash
  • Dogecoin
  • Dystem
  • Feathercoin
  • Groestlcoin
  • Liquid
  • Litecoin
  • Monacoin
  • Polis
  • UFO
  • Viacoin
  • Zclassic
  • Koto
  • Chaincoin
  • Stratis

Let’s go back to Bitcoin.

-   using(var env = NBitcoin.Tests.NodeBuilder.Create(NodeDownloadData.Feathercoin.v0_16_0, Feathercoin.Instance.Regtest))
+ using(var env = NBitcoin.Tests.NodeBuilder.Create(NodeDownloadData.Bitcoin.v0_17_0, Bitcoin.Instance.Regtest))

So now the miner sent money to Alice, let’s check if Alice’s node received the money after one confirmation.

var aliceAddress = aliceRPC.GetNewAddress();
minerRPC.SendToAddress(aliceAddress, Money.Coins(1.0m));
Logger.WriteLine("Miner just sent 1 BTC to Alice");
+minerRPC.Generate(1);
+Assert.Equal(Money.Coins(1.0m), aliceRPC.GetBalance());

While the miner sent the money, it seems that from Alice point of view, nothing was received.

[xUnit.net 00:00:08.35]     IntegrationTests.UnitTest1.AliceCanSendToBob [FAIL]
Failed IntegrationTests.UnitTest1.AliceCanSendToBob
Error Message:
Assert.Equal() Failure
Expected: 1.00000000
Actual: 0.00000000

The reason is that Alice node and the Miner node are not connected together, so Alice has no way to know that a transaction has been broadcasted.

var aliceAddress = aliceRPC.GetNewAddress();
minerRPC.SendToAddress(aliceAddress, Money.Coins(1.0m));
Logger.WriteLine("Miner just sent 1 BTC to Alice");

+Logger.WriteLine("Let's connect the Miner to Alice and Bob");
+miner.Sync(alice, true);
+miner.Sync(bob, true);

minerRPC.Generate(1);
Assert.Equal(Money.Coins(1.0m), aliceRPC.GetBalance());

Now the miner is connected to Alice and Bob, and the test is a success.

Let’s continue our test, now Alice must send a coin to Bob.

...
Logger.WriteLine("Alice send to Bob 0.4 BTC minus fees");
var bobAddress = bobRPC.GetNewAddress();
var txId = aliceRPC.SendToAddress(bobAddress, Money.Coins(0.4m), subtractFeeFromAmount: true);
minerRPC.Generate(1);
miner.Sync(alice, true);
miner.Sync(bob, true);

Logger.WriteLine("Just make sure Alice, Bob and Miner have same block height");
Assert.Equal(aliceRPC.GetBlockCount(), minerRPC.GetBlockCount());
Assert.Equal(bobRPC.GetBlockCount(), minerRPC.GetBlockCount());

Logger.WriteLine("Alice should have 0.6 BTC");
Assert.Equal(Money.Coins(0.6m), aliceRPC.GetBalance());

Logger.WriteLine("Bob should have almost 0.4 BTC (minus the fees)");
Assert.Equal(0.4m, bobRPC.GetBalance().ToDecimal(MoneyUnit.BTC), 3);

Suprisingly, this test fail

Build started, please wait...
Build completed.

Test run for C:\Users\NicolasDorier\AppData\Local\Temp\624858694\IntegrationTests\bin\Debug\netcoreapp2.2\IntegrationTests.dll(.NETCoreApp,Version=v2.2)
Microsoft (R) Test Execution Command Line Tool Version 15.9.0
Copyright (c) Microsoft Corporation. All rights reserved.

Starting test execution, please wait...
[xUnit.net 00:00:09.51] IntegrationTests.UnitTest1.AliceCanSendToBob [FAIL]
-Failed IntegrationTests.UnitTest1.AliceCanSendToBob
Error Message:
Assert.Equal() Failure
Expected: 0.4 (rounded from 0.4)
Actual: 0 (rounded from 0)
Stack Trace:
at IntegrationTests.UnitTest1.AliceCanSendToBob() in C:\Users\NicolasDorier\AppData\Local\Temp\624858694\IntegrationTests\UnitTest1.cs:line 60
Standard Output Messages:
Miner, Alice and Bob started
Miner just sent 1 BTC to Alice
Let's connect the Miner to Alice and Bob
Alice send to Bob 0.4 BTC minus fees
Just make sure Alice, Bob and Miner have same block height
Alice should have 0.6 BTC
Bob should have almost 0.4 BTC (minus the fees)



Total tests: 1. Passed: 0. Failed: 1. Skipped: 0.
Test Run Failed.
Test execution time: 10.6400 Seconds

So it seems Bob did not received the money. The reason is that there is a delay between the moment we call SendRawTransaction to the moment the miner actually see the transaction.

We can confirm this by seeing that this bug disappear if Alice is the one mining the block.

Logger.WriteLine("Alice send to Bob 0.4 BTC minus fees");
var bobAddress = bobRPC.GetNewAddress();
var txId = aliceRPC.SendToAddress(bobAddress, Money.Coins(0.4m), subtractFeeFromAmount: true);
+aliceRPC.Generate(1);
-minerRPC.Generate(1);
miner.Sync(alice, true);
miner.Sync(bob, true);

+Logger.WriteLine("Let's make sure the transaction is mined");
+Assert.Equal(1U, aliceRPC.GetRawTransactionInfo(txId).Confirmations);

We advise you to mine a block with the sender when you want to simulate sending a transaction, this is the simplest way to have a fast test.

A second solution, would be to connect with the P2P protocol to the miner until and wait that it broadcasts alice’s transaction. We will cover how to test P2P protocol in a later article.

A third solution would be to poll Bob’s unconfirmed balance until we notice a change.

A fourth solution is to use ZMQ for getting notifications. Sadly NBitcoin does not support this.

A fifth solution is to use -blocknotify and -walletnotify Bitcoin core parameters to run a program when a new block or wallet transaction is incoming. But this is hard to unit test properly, and we would need some custom IPC to communicate between the notification program and our tested program.

Conclusion

We just saw how to use NBitcoin to make integrations tests for Bitcoin and altcoins.

NBitcoin completely abstract the differences between crypto currencies, so in practice, you can often only test for Bitcoin and have a high chance your service will just work with another altcoins by changing the Network instance.

We saw that NBitcoin.TestFramework is a very useful framework to create your own blockchain for test purposes. You can create several nodes, connect them together and use Bitcoin Core easily.

We learned how to workaround the fact that transactions take time to broadcast from one node to another.

In a later article, we will learn how to make a simple transaction listener by connecting through with the Bitcoin protocol to our miner’s node.

We will explore also other strategy for testing if Bitcoin RPC is not good enough for the need of your service.

Our final code is:

using System;
using System.Threading.Tasks;
using NBitcoin;
using NBitcoin.Altcoins;
using NBitcoin.Tests;
using Xunit;
using Xunit.Abstractions;

namespace IntegrationTests
{
public class UnitTest1
{
public ITestOutputHelper Logger { get; }
public UnitTest1(ITestOutputHelper logger)
{
Logger = logger;
}

[Fact]
public void AliceCanSendToBob()
{
using(var env = NBitcoin.Tests.NodeBuilder.Create(NodeDownloadData.Bitcoin.v0_17_0, Bitcoin.Instance.Regtest))
{
var miner = env.CreateNode();
var alice = env.CreateNode();
var bob = env.CreateNode();
env.StartAll();
Logger.WriteLine("Miner, Alice and Bob started");
var minerRPC = miner.CreateRPCClient();
var aliceRPC = alice.CreateRPCClient();
var bobRPC = bob.CreateRPCClient();

minerRPC.Generate(env.Network.Consensus.CoinbaseMaturity + 1);

var aliceAddress = aliceRPC.GetNewAddress();
minerRPC.SendToAddress(aliceAddress, Money.Coins(1.0m));
Logger.WriteLine("Miner just sent 1 BTC to Alice");

Logger.WriteLine("Let's connect the Miner to Alice and Bob");
miner.Sync(alice, true);
miner.Sync(bob, true);

minerRPC.Generate(1);
Assert.Equal(Money.Coins(1.0m), aliceRPC.GetBalance());

Logger.WriteLine("Alice send to Bob 0.4 BTC minus fees");
var bobAddress = bobRPC.GetNewAddress();
var txId = aliceRPC.SendToAddress(bobAddress, Money.Coins(0.4m), subtractFeeFromAmount: true);
aliceRPC.Generate(1);
miner.Sync(alice, true);
miner.Sync(bob, true);

Logger.WriteLine("Let's make sure the transaction is mined");
Assert.Equal(1U, aliceRPC.GetRawTransactionInfo(txId).Confirmations);

Logger.WriteLine("Just make sure Alice, Bob and Miner have same block height");
Assert.Equal(aliceRPC.GetBlockCount(), minerRPC.GetBlockCount());
Assert.Equal(bobRPC.GetBlockCount(), minerRPC.GetBlockCount());

Logger.WriteLine("Alice should have 0.6 BTC");
Assert.Equal(Money.Coins(0.6m), aliceRPC.GetBalance());

Logger.WriteLine("Bob should have almost 0.4 BTC (minus the fees)");
Assert.Equal(0.4m, bobRPC.GetBalance().ToDecimal(MoneyUnit.BTC), 3);
}
}
}
}

Originally published at gist.github.com.

Author: Nicolas Dorier (http://nicolas-dorier.com/)

This story is property of Nicolas Dorier and METACO SA.

--

--

METACO, SA
METACO
Editor for

METACO is the leading provider of security-critical infrastructure enabling financial institutions to enter the digital asset ecosystem.