Jan 16, 2025 • Testing
Testing the Transport Layer
Creating meaningful unit tests can be difficult – even more when the system under test is a transport layer protocol like the Bitcoin wire protocol.
A modern framework like Swift Testing offers facilities to deal with asynchronous code. We are taking full advantage of that capability to verify node interactions like the handshake sequence, post handshake exchange, ping/pong and transaction and block relay communication.
As an example let's look at how Alice would relay a transaction to Bob.
First let's define our services get a reference to some channels:
import Bitcoin
let alice = NodeService(blockchain: BlockchainService())
let bob = NodeService(blockchain: BlockchainService())
// Alice's asynchronous messages to Bob.
var aliceToBob = await alice.getChannel(for: peerB).makeAsyncIterator()
// Alice's transaction inventory (subscription to internal blockchain events).
var aliceTxs = try #require(await alice.txs?.makeAsyncIterator())
After bootstrapping our nodes with a synchronized state we can kickstart an interaction by submitting a new transaction to Alice's mempool:
let tx = …
// Add the transaction directly to Alice's blockchain as one would with RPC.
Task {
try await alice.blockchain.addTx(tx)
}
// Alice's transactions channel will notify us of the newly accepted transaction.
let aliceTx = try #require(await aliceTxs.next())
await Task.yield()
// We can now forward the transaction to the node so that it can relay it to its peers if needed.
Task {
await alice.handleTx(aliceTx)
}
We can see how a combination of subtasks and Task.yield()
can help us work through the asynchronicity.
Once the transaction relay process is triggered we proceed to checking the actual message exchange:
// Alice --(inv)->> …
let messageAB0_inv = try #require(await aliceToBob.next())
await Task.yield()
#expect(messageAB0_inv.command == .inv)
let inv = try #require(InventoryMessage(messageAB0_inv.payload))
#expect(inv.items == [.init(type: .witnessTx, hash: tx.id)])
// … --(inv)->> Bob
try await bob.processMessage(messageAB0_inv, from: peerA)
// Bob --(getdata)->> …
let messageBA0_getdata = try #require(await bob.popMessage(peerA))
#expect(messageBA0_getdata.command == .getdata)
let getData = try #require(GetDataMessage(messageBA0_getdata.payload))
#expect(getData.items == [.init(type: .witnessTx, hash: tx.id)])
// … --(getdata)->> Alice
try await alice.processMessage(messageBA0_getdata, from: peerB)
// Alice --(tx)->> …
let messageAB1_tx = try #require(await alice.popMessage(peerB))
#expect(messageAB1_tx.command == .tx)
let txMessage = try #require(BitcoinTx(messageAB1_tx.payload))
#expect(txMessage == tx)
// … --(tx)->> Bob
try await bob.processMessage(messageAB1_tx, from: peerA)
As indicated by the comments in the snippet above the sequence of messages being verified looks like:
- Alice sends an
inv
message to Bob. - Bob responds to Alice with a
getdata
message. - Alice responds to Bob with a
tx
message.
In addition to inspecting each message's content along the way we can also ensure the state of the node and blockchain services is correct. For instance here's how we would compare the mempool's content to our expectation of it containing our relay transaction:
let bobsMempool = await bob.blockchain.mempool
#expect(await alice.blockchain.mempool == bobsMempool)
That's it. Check out the rest of Swift Bitcoin Transport tests and feel free to reach out if you would like to contribute!