39 KiB
Protocol state machines
This article explains our experimental approach to modelling financial protocols in code. It explains how the platform's state machine framework is used, and takes you through the code for a simple 2-party asset trading protocol which is included in the source.
Introduction
Shared distributed ledgers are interesting because they allow many different, mutually distrusting parties to share a single source of truth about the ownership of assets. Digitally signed transactions are used to update that shared ledger, and transactions may alter many states simultaneously and atomically.
Blockchain systems such as Bitcoin support the idea of building up a finished, signed transaction by passing around partially signed invalid transactions outside of the main network, and by doing this you can implement delivery versus payment such that there is no chance of settlement failure, because the movement of cash and the traded asset are performed atomically by the same transaction. To perform such a trade involves a multi-step protocol in which messages are passed back and forth privately between parties, checked, signed and so on.
Despite how useful these protocols are, platforms such as Bitcoin and Ethereum do not assist the developer with the rather tricky task of actually building them. That is unfortunate. There are many awkward problems in their implementation that a good platform would take care of for you, problems like:
- Avoiding "callback hell" in which code that should ideally be sequential is turned into an unreadable mess due to the desire to avoid using up a thread for every protocol instantiation.
- Surviving node shutdowns/restarts that may occur in the middle of the protocol without complicating things. This implies that the state of the protocol must be persisted to disk.
- Error handling.
- Message routing.
- Serialisation.
- Catching type errors, in which the developer gets temporarily confused and expects to receive/send one type of message when actually they need to receive/send another.
- Unit testing of the finished protocol.
Actor frameworks can solve some of the above but they are often tightly bound to a particular messaging layer, and we would like to keep a clean separation. Additionally, they are typically not type safe, and don't make persistence or writing sequential code much easier.
To put these problems in perspective, the payment channel protocol in the bitcoinj library, which allows bitcoins to be temporarily moved off-chain and traded at high speed between two parties in private, consists of about 7000 lines of Java and took over a month of full time work to develop. Most of that code is concerned with the details of persistence, message passing, lifecycle management, error handling and callback management. Because the business logic is quite spread out the code can be difficult to read and debug.
As small contract-specific trading protocols are a common occurence in finance, we provide a framework for the construction of them that automatically handles many of the concerns outlined above.
Theory
A continuation is a suspended stack frame stored in a regular object that can be passed around, serialised, unserialised and resumed from where it was suspended. This concept is sometimes referred to as "fibers". This may sound abstract but don't worry, the examples below will make it clearer. The JVM does not natively support continuations, so we implement them using a library called Quasar which works through behind-the-scenes bytecode rewriting. You don't have to know how this works to benefit from it, however.
We use continuations for the following reasons:
- It allows us to write code that is free of callbacks, that looks like ordinary sequential code.
- A suspended continuation takes far less memory than a suspended thread. It can be as low as a few hundred bytes. In contrast a suspended Java thread stack can easily be 1mb in size.
- It frees the developer from thinking (much) about persistence and serialisation.
A state machine is a piece of code that moves through various states. These are not the same as states in the data model (that represent facts about the world on the ledger), but rather indicate different stages in the progression of a multi-stage protocol. Typically writing a state machine would require the use of a big switch statement and some explicit variables to keep track of where you're up to. The use of continuations avoids this hassle.
A two party trading protocol
We would like to implement the "hello world" of shared transaction building protocols: a seller wishes to sell some asset (e.g. some commercial paper) in return for cash. The buyer wishes to purchase the asset using his cash. They want the trade to be atomic so neither side is exposed to the risk of settlement failure. We assume that the buyer and seller have found each other and arranged the details on some exchange, or over the counter. The details of how the trade is arranged isn't covered in this article.
Our protocol has two parties (B and S for buyer and seller) and will proceed as follows:
- S sends a
StateAndRef
pointing to the state they want to sell to B, along with info about the price they require B to pay. - B sends to S a
SignedTransaction
that includes the state as input, B's cash as input, the state with the new owner key as output, and any change cash as output. It contains a single signature from B but isn't valid because it lacks a signature from S authorising movement of the asset. - S signs it and hands the now finalised
SignedTransaction
back to B.
You can find the implementation of this protocol in the file contracts/protocols/TwoPartyTradeProtocol.kt
.
Assuming no malicious termination, they both end the protocol being in posession of a valid, signed transaction that represents an atomic asset swap.
Note that it's the seller who initiates contact with the buyer, not vice-versa as you might imagine.
We start by defining a wrapper that namespaces the protocol code, two functions to start either the buy or sell side of the protocol, and two classes that will contain the protocol definition. We also pick what data will be used by each side.
Note
The code samples in this tutorial are only available in Kotlin, but you can use any JVM language to write them and the approach is the same.
object TwoPartyTradeProtocol {
val TOPIC = "platform.trade"
class UnacceptablePriceException(val givenPrice: Amount<Currency>) : Exception("Unacceptable price: $givenPrice")
class AssetMismatchException(val expectedTypeName: String, val typeName: String) : Exception() {
override fun toString() = "The submitted asset didn't match the expected type: $expectedTypeName vs $typeName"
}
// This object is serialised to the network and is the first protocol message the seller sends to the buyer.
class SellerTradeInfo(
val assetForSale: StateAndRef<OwnableState>,
val price: Amount,
val sellerOwnerKey: PublicKey,
val sessionID: Long
)
class SignaturesFromSeller(val timestampAuthoritySig: DigitalSignature.WithKey, val sellerSig: DigitalSignature.WithKey)
open class Seller(val otherSide: Party,
val notaryNode: NodeInfo,
val assetToSell: StateAndRef<OwnableState>,
val price: Amount<Currency>,
val myKeyPair: KeyPair,
val buyerSessionID: Long,
override val progressTracker: ProgressTracker = Seller.tracker()) : ProtocolLogic<SignedTransaction>() {
@Suspendable
override fun call(): SignedTransaction {
TODO()
}
}
open class Buyer(val otherSide: Party,
val notary: Party,
val acceptablePrice: Amount<Currency>,
val typeToBuy: Class<out OwnableState>,
val sessionID: Long) : ProtocolLogic<SignedTransaction>() {
@Suspendable
override fun call(): SignedTransaction {
TODO()
}
} }
Let's unpack what this code does:
- It defines a several classes nested inside the main
TwoPartyTradeProtocol
singleton. Some of the classes are simply protocol messages or exceptions. The other two represent the buyer and seller side of the protocol. - It defines the "trade topic", which is just a string that namespaces this protocol. The prefix "platform." is reserved by Corda, but you can define your own protocols using standard Java-style reverse DNS notation.
Going through the data needed to become a seller, we have:
otherSide: SingleMessageRecipient
- the network address of the node with which you are trading.notaryNode: NodeInfo
- the entry in the network map for the chosen notary. See "consensus
" for more information on notaries.assetToSell: StateAndRef<OwnableState>
- a pointer to the ledger entry that represents the thing being sold.price: Amount<Currency>
- the agreed on price that the asset is being sold for (without an issuer constraint).myKeyPair: KeyPair
- the key pair that controls the asset being sold. It will be used to sign the transaction.buyerSessionID: Long
- a unique number that identifies this trade to the buyer. It is expected that the buyer knows that the trade is going to take place and has sent you such a number already.
Note
Session IDs will be automatically handled in a future version of the framework.
And for the buyer:
acceptablePrice: Amount<Currency>
- the price that was agreed upon out of band. If the seller specifies a price less than or equal to this, then the trade will go ahead.typeToBuy: Class<out OwnableState>
- the type of state that is being purchased. This is used to check that the sell side of the protocol isn't trying to sell us the wrong thing, whether by accident or on purpose.sessionID: Long
- the session ID that was handed to the seller in order to start the protocol.
Alright, so using this protocol shouldn't be too hard: in the simplest case we can just create a Buyer or Seller with the details of the trade, depending on who we are. We then have to start the protocol in some way. Just calling the call
method ourselves won't work: instead we need to ask the framework to start the protocol for us. More on that in a moment.
Suspendable methods
The call
method of the buyer/seller classes is marked with the @Suspendable
annotation. What does this mean?
As mentioned above, our protocol framework will at points suspend the code and serialise it to disk. For this to work, any methods on the call stack must have been pre-marked as @Suspendable
so the bytecode rewriter knows to modify the underlying code to support this new feature. A protocol is suspended when calling either receive
, send
or sendAndReceive
which we will learn more about below. For now, just be aware that when one of these methods is invoked, all methods on the stack must have been marked. If you forget, then in the unit test environment you will get a useful error message telling you which methods you didn't mark. The fix is simple enough: just add the annotation and try again.
Note
Java 9 is likely to remove this pre-marking requirement completely.
Starting your protocol
The StateMachineManager
is the class responsible for taking care of all running protocols in a node. It knows how to register handlers with the messaging system (see "messaging
") and iterate the right state machine when messages arrive. It provides the send/receive/sendAndReceive calls that let the code request network interaction and it will save/restore serialised versions of the fiber at the right times.
Protocols can be invoked in several ways. For instance, they can be triggered by scheduled events, see "event-scheduling
" to learn more about this. Or they can be triggered via the HTTP API. Or they can be triggered directly via the Java-level node APIs from your app code.
You request a protocol to be invoked by using the ServiceHub.invokeProtocolAsync
method. This takes a Java reflection Class
object that describes the protocol class to use (in this case, either Buyer or Seller). It also takes a set of arguments to pass to the constructor. Because it's possible for protocol invocations to be requested by untrusted code (e.g. a state that you have been sent), the types that can be passed into the protocol are checked against a whitelist, which can be extended by apps themselves at load time.
The process of starting a protocol returns a ListenableFuture
that you can use to either block waiting for the result, or register a callback that will be invoked when the result is ready.
Implementing the seller
Let's implement the Seller.call
method. This will be run when the protocol is invoked.
@Suspendable
override fun call(): SignedTransaction {
val partialTX: SignedTransaction = receiveAndCheckProposedTransaction()
val ourSignature: DigitalSignature.WithKey = signWithOurKey(partialTX)
val notarySignature = getNotarySignature(partialTX)
val result: SignedTransaction = sendSignatures(partialTX, ourSignature, notarySignature)
return result
}
Here we see the outline of the procedure. We receive a proposed trade transaction from the buyer and check that it's valid. Then we sign with our own key and request a notary to assert with another signature that the timestamp in the transaction (if any) is valid and there are no double spends, and finally we send back both our signature and the notaries signature. Finally, we hand back to the code that invoked the protocol the finished transaction.
Let's fill out the receiveAndCheckProposedTransaction()
method.
@Suspendable
private fun receiveAndCheckProposedTransaction(): SignedTransaction {
val sessionID = random63BitValue()
// Make the first message we'll send to kick off the protocol.
val hello = SellerTradeInfo(assetToSell, price, myKeyPair.public, sessionID)
val maybeSTX = sendAndReceive<SignedTransaction>(otherSide, buyerSessionID, sessionID, hello)
maybeSTX.validate {// Check that the tx proposed by the buyer is valid.
val missingSigs: Set<PublicKey> = it.verifySignatures(throwIfSignaturesAreMissing = false)
val expected = setOf(myKeyPair.public, notaryNode.identity.owningKey)
if (missingSigs != expected)
throw SignatureException("The set of missing signatures is not as expected: ${missingSigs.toStringsShort()} vs ${expected.toStringsShort()}")
val wtx: WireTransaction = it.tx
"Received partially signed transaction: ${it.id}" }
logger.trace {
// Download and check all the things that this transaction depends on and verify it is contract-valid,
// even though it is missing signatures.
subProtocol(ResolveTransactionsProtocol(wtx, otherSide))
if (wtx.outputs.map { it.data }.sumCashBy(myKeyPair.public).withoutIssuer() != price)
throw IllegalArgumentException("Transaction is not sending us the right amount of cash")
return it
} }
Let's break this down. We generate a session ID to identify what's happening on the seller side, fill out the initial protocol message, and then call sendAndReceive
. This function takes a few arguments:
- The topic string that ensures the message is routed to the right bit of code in the other side's node.
- The session IDs that ensure the messages don't get mixed up with other simultaneous trades.
- The thing to send. It'll be serialised and sent automatically.
- Finally a type argument, which is the kind of object we're expecting to receive from the other side. If we get back something else an exception is thrown.
Once sendAndReceive
is called, the call method will be suspended into a continuation and saved to persistent storage. If the node crashes or is restarted, the protocol will effectively continue as if nothing had happened. Your code may remain blocked inside such a call for seconds, minutes, hours or even days in the case of a protocol that needs human interaction!
Note
There are a couple of rules you need to bear in mind when writing a class that will be used as a continuation. The first is that anything on the stack when the function is suspended will be stored into the heap and kept alive by the garbage collector. So try to avoid keeping enormous data structures alive unless you really have to.
The second is that as well as being kept on the heap, objects reachable from the stack will be serialised. The state of the function call may be resurrected much later! Kryo doesn't require objects be marked as serialisable, but even so, doing things like creating threads from inside these calls would be a bad idea. They should only contain business logic and only do I/O via the methods exposed by the protocol framework.
It's OK to keep references around to many large internal node services though: these will be serialised using a special token that's recognised by the platform, and wired up to the right instance when the continuation is loaded off disk again.
The buyer is supposed to send us a transaction with all the right inputs/outputs/commands in response to the opening message, with their cash put into the transaction and their signature on it authorising the movement of the cash.
You get back a simple wrapper class, UntrustworthyData<SignedTransaction>
, which is just a marker class that reminds us that the data came from a potentially malicious external source and may have been tampered with or be unexpected in other ways. It doesn't add any functionality, but acts as a reminder to "scrub" the data before use.
Our "scrubbing" has three parts:
- Check that the expected signatures are present and correct. At this point we expect our own signature to be missing, because of course we didn't sign it yet, and also the signature of the notary because that must always come last.
- We resolve the transaction, which we will cover below.
- We verify that the transaction is paying us the demanded price.
Subprotocols
Protocols can be composed via nesting. Invoking a sub-protocol looks similar to an ordinary function call:
@Suspendable
private fun getNotarySignature(stx: SignedTransaction): DigitalSignature.LegallyIdentifiable {
progressTracker.currentStep = NOTARYreturn subProtocol(NotaryProtocol.Client(stx))
}
In this code snippet we are using the NotaryProtocol.Client
to request notarisation of the transaction. We simply create the protocol object via its constructor, and then pass it to the subProtocol
method which returns the result of the protocol's execution directly. Behind the scenes all this is doing is wiring up progress tracking (discussed more below) and then running the objects call
method. Because this little helper method can be on the stack when network IO takes place, we mark it as @Suspendable
.
Going back to the previous code snippet, we use a subprotocol called ResolveTransactionsProtocol
. This is responsible for downloading and checking all the dependencies of a transaction, which in Corda are always retrievable from the party that sent you a transaction that uses them. This protocol returns a list of LedgerTransaction
objects, but we don't need them here so we just ignore the return value.
Note
Transaction dependency resolution assumes that the peer you got the transaction from has all of the dependencies itself. It must do, otherwise it could not have convinced itself that the dependencies were themselves valid. It's important to realise that requesting only the transactions we require is a privacy leak, because if we don't download a transaction from the peer, they know we must have already seen it before. Fixing this privacy leak will come later.
After the dependencies, we check the proposed trading transaction for validity by running the contracts for that as well (but having handled the fact that some signatures are missing ourselves).
Here's the rest of the code:
open fun signWithOurKey(partialTX: SignedTransaction) = myKeyPair.signWithECDSA(partialTX.txBits)
@Suspendable
private fun sendSignatures(partialTX: SignedTransaction, ourSignature: DigitalSignature.WithKey,
notarySignature: DigitalSignature.LegallyIdentifiable): SignedTransaction {
val fullySigned = partialTX + ourSignature + notarySignature
"Built finished transaction, sending back to secondary!" }
logger.trace {
send(otherSide, buyerSessionID, SignaturesFromSeller(ourSignature, notarySignature))return fullySigned
}
It's all pretty straightforward from now on. Here txBits
is the raw byte array representing the serialised transaction, and we just use our private key to calculate a signature over it. As a reminder, in Corda signatures do not cover other signatures: just the core of the transaction data.
In sendSignatures
, we take the two signatures we obtained and add them to the partial transaction we were sent. There is an overload for the + operator so signatures can be added to a SignedTransaction easily. Finally, we wrap the two signatures in a simple wrapper message class and send it back. The send won't block waiting for an acknowledgement, but the underlying message queue software will retry delivery if the other side has gone away temporarily.
You can also see that every protocol instance has a logger (using the SLF4J API) which you can use to log progress messages.
Warning
This sample code is not secure. Other than not checking for all possible invalid constructions, if the seller stops before sending the finalised transaction to the buyer, the seller is left with a valid transaction but the buyer isn't, so they can't spend the asset they just purchased! This sort of thing will be fixed in a future version of the code.
Implementing the buyer
OK, let's do the same for the buyer side:
@Suspendable
override fun call(): SignedTransaction {
val tradeRequest = receiveAndValidateTradeRequest()
val (ptx, cashSigningPubKeys) = assembleSharedTX(tradeRequest)
val stx = signWithOurKeys(cashSigningPubKeys, ptx)
val signatures = swapSignaturesWithSeller(stx, tradeRequest.sessionID)
"Got signatures from seller, verifying ... " }
logger.trace {
val fullySigned = stx + signatures.sellerSig + signatures.notarySig
fullySigned.verifySignatures()
"Signatures received are valid. Trade complete! :-)" }
logger.trace { return fullySigned
}
@Suspendable
private fun receiveAndValidateTradeRequest(): SellerTradeInfo {
// Wait for a trade request to come in on our pre-provided session ID.
val maybeTradeRequest = receive<SellerTradeInfo>(sessionID)
maybeTradeRequest.validate {// What is the seller trying to sell us?
val asset = it.assetForSale.state.data
val assetTypeName = asset.javaClass.name
"Got trade request for a $assetTypeName: ${it.assetForSale}" }
logger.trace {
// Check the start message for acceptability.
0)
check(it.sessionID > if (it.price > acceptablePrice)
throw UnacceptablePriceException(it.price)
if (!typeToBuy.isInstance(asset))
throw AssetMismatchException(typeToBuy.name, assetTypeName)
// Check the transaction that contains the state which is being resolved.
// We only have a hash here, so if we don't know it already, we have to ask for it.
subProtocol(ResolveTransactionsProtocol(setOf(it.assetForSale.ref.txhash), otherSide))
return it
}
}
@Suspendable
private fun swapSignaturesWithSeller(stx: SignedTransaction, theirSessionID: Long): SignaturesFromSeller {
progressTracker.currentStep = SWAPPING_SIGNATURES"Sending partially signed transaction to seller" }
logger.trace {
// TODO: Protect against the seller terminating here and leaving us in the lurch without the final tx.
return sendAndReceive<SignaturesFromSeller>(otherSide, theirSessionID, sessionID, stx).validate { it }
}
private fun signWithOurKeys(cashSigningPubKeys: List<PublicKey>, ptx: TransactionBuilder): SignedTransaction {
// Now sign the transaction with whatever keys we need to move the cash.
for (k in cashSigningPubKeys) {
val priv = serviceHub.keyManagementService.toPrivate(k)
ptx.signWith(KeyPair(k, priv))
}
return ptx.toSignedTransaction(checkSufficientSignatures = false)
}
private fun assembleSharedTX(tradeRequest: SellerTradeInfo): Pair<TransactionBuilder, List<PublicKey>> {
val ptx = TransactionType.General.Builder(notary)
// Add input and output states for the movement of cash, by using the Cash contract to generate the states.
val wallet = serviceHub.walletService.currentWallet
val cashStates = wallet.statesOfType<Cash.State>()
val cashSigningPubKeys = Cash().generateSpend(ptx, tradeRequest.price, tradeRequest.sellerOwnerKey, cashStates)
// Add inputs/outputs/a command for the movement of the asset.
ptx.addInputState(tradeRequest.assetForSale)// Just pick some new public key for now. This won't be linked with our identity in any way, which is what
// we want for privacy reasons: the key is here ONLY to manage and control ownership, it is not intended to
// reveal who the owner actually is. The key management service is expected to derive a unique key from some
// initial seed in order to provide privacy protection.
val freshKey = serviceHub.keyManagementService.freshKey()
val (command, state) = tradeRequest.assetForSale.state.data.withNewOwner(freshKey.public)
ptx.addOutputState(state, tradeRequest.assetForSale.state.notary)data.owner)
ptx.addCommand(command, tradeRequest.assetForSale.state.
// And add a request for timestamping: it may be that none of the contracts need this! But it can't hurt
// to have one.
val currentTime = serviceHub.clock.instant()
30.seconds)
ptx.setTime(currentTime, return Pair(ptx, cashSigningPubKeys)
}
This code is longer but no more complicated. Here are some things to pay attention to:
- We do some sanity checking on the received message to ensure we're being offered what we expected to be offered.
- We create a cash spend in the normal way, by using
Cash().generateSpend
. See the contracts tutorial if this part isn't clear. - We access the service hub when we need it to access things that are transient and may change or be recreated whilst a protocol is suspended, things like the wallet or the network map.
- Finally, we send the unfinished, invalid transaction to the seller so they can sign it. They are expected to send back to us a
SignaturesFromSeller
, which once we verify it, should be the final outcome of the trade.
As you can see, the protocol logic is straightforward and does not contain any callbacks or network glue code, despite the fact that it takes minimal resources and can survive node restarts.
Warning
In the current version of the platform, exceptions thrown during protocol execution are not propagated back to the sender. A thorough error handling and exceptions framework will be in a future version of the platform.
Progress tracking
Not shown in the code snippets above is the usage of the ProgressTracker
API. Progress tracking exports information from a protocol about where it's got up to in such a way that observers can render it in a useful manner to humans who may need to be informed. It may be rendered via an API, in a GUI, onto a terminal window, etc.
A ProgressTracker
is constructed with a series of Step
objects, where each step is an object representing a stage in a piece of work. It is therefore typical to use singletons that subclass Step
, which may be defined easily in one line when using Kotlin. Typical steps might be "Waiting for response from peer", "Waiting for signature to be approved", "Downloading and verifying data" etc.
Each step exposes a label. By default labels are fixed, but by subclassing RelabelableStep
you can make a step that can update its label on the fly. That's useful for steps that want to expose non-structured progress information like the current file being downloaded. By defining your own step types, you can export progress in a way that's both human readable and machine readable.
Progress trackers are hierarchical. Each step can be the parent for another tracker. By altering the ProgressTracker.childrenFor[step] = tracker
map, a tree of steps can be created. It's allowed to alter the hierarchy at runtime, on the fly, and the progress renderers will adapt to that properly. This can be helpful when you don't fully know ahead of time what steps will be required. If you _do know what is required, configuring as much of the hierarchy ahead of time is a good idea, as that will help the users see what is coming up.
Every tracker has not only the steps given to it at construction time, but also the singleton ProgressTracker.UNSTARTED
step and the ProgressTracker.DONE
step. Once a tracker has become DONE
its position may not be modified again (because e.g. the UI may have been removed/cleaned up), but until that point, the position can be set to any arbitrary set both forwards and backwards. Steps may be skipped, repeated, etc. Note that rolling the current step backwards will delete any progress trackers that are children of the steps being reversed, on the assumption that those subtasks will have to be repeated.
Trackers provide an Rx observable which streams changes to the hierarchy. The top level observable exposes all the events generated by its children as well. The changes are represented by objects indicating whether the change is one of position (i.e. progress), structure (i.e. new subtasks being added/removed) or some other aspect of rendering (i.e. a step has changed in some way and is requesting a re-render).
The protocol framework is somewhat integrated with this API. Each ProtocolLogic
may optionally provide a tracker by overriding the protocolTracker
property (getProtocolTracker
method in Java). If the ProtocolLogic.subProtocol
method is used, then the tracker of the sub-protocol will be made a child of the current step in the parent protocol automatically, if the parent is using tracking in the first place. The framework will also automatically set the current step to DONE
for you, when the protocol is finished.
Because a protocol may sometimes wish to configure the children in its progress hierarchy _before the sub-protocol is constructed, for sub-protocols that always follow the same outline regardless of their parameters it's conventional to define a companion object/static method (for Kotlin/Java respectively) that constructs a tracker, and then allow the sub-protocol to have the tracker it will use be passed in as a parameter. This allows all trackers to be built and linked ahead of time.
In future, the progress tracking framework will become a vital part of how exceptions, errors, and other faults are surfaced to human operators for investigation and resolution.
Unit testing
A protocol can be a fairly complex thing that interacts with many services and other parties over the network. That means unit testing one requires some infrastructure to provide lightweight mock implementations. The MockNetwork provides this testing infrastructure layer; you can find this class in the node module
A good example to examine for learning how to unit test protocols is the ResolveTransactionsProtocol
tests. This protocol takes care of downloading and verifying transaction graphs, with all the needed dependencies. We start with this basic skeleton:
class ResolveTransactionsProtocolTest {
var net: MockNetwork
lateinit var a: MockNetwork.MockNode
lateinit var b: MockNetwork.MockNode
lateinit var notary: Party
lateinit
@Before
fun setup() {
net = MockNetwork()val nodes = net.createSomeNodes()
0]
a = nodes.partyNodes[1]
b = nodes.partyNodes[
notary = nodes.notaryNode.info.identity
net.runNetwork()
}
@After
fun tearDown() {
net.stopNodes()
} }
We create a mock network in our @Before
setup method and create a couple of nodes. We also record the identity of the notary in our test network, which will come in handy later. We also tidy up when we're done.
Next, we write a test case:
@Test
fun resolveFromTwoHashes() {
val (stx1, stx2) = makeTransactions()
val p = ResolveTransactionsProtocol(setOf(stx2.id), a.info.identity)
val future = b.services.startProtocol("resolve", p)
net.runNetwork()val results = future.get()
assertEquals(listOf(stx1.id, stx2.id), results.map { it.id })
assertEquals(stx1, b.storage.validatedTransactions.getTransaction(stx1.id))
assertEquals(stx2, b.storage.validatedTransactions.getTransaction(stx2.id)) }
We'll take a look at the makeTransactions
function in a moment. For now, it's enough to know that it returns two SignedTransaction
objects, the second of which spends the first. Both transactions are known by node A but not node B.
The test logic is simple enough: we create the protocol, giving it node A's identity as the target to talk to. Then we start it on node B and use the net.runNetwork()
method to bounce messages around until things have settled (i.e. there are no more messages waiting to be delivered). All this is done using an in memory message routing implementation that is fast to initialise and use. Finally, we obtain the result of the protocol and do some tests on it. We also check the contents of node B's database to see that the protocol had the intended effect on the node's persistent state.
Here's what makeTransactions
looks like:
private fun makeTransactions(): Pair<SignedTransaction, SignedTransaction> {
// Make a chain of custody of dummy states and insert into node A.
val dummy1: SignedTransaction = DummyContract.generateInitial(MEGA_CORP.ref(1), 0, notary).let {
it.signWith(MEGA_CORP_KEY)
it.signWith(DUMMY_NOTARY_KEY)false)
it.toSignedTransaction(
}val dummy2: SignedTransaction = DummyContract.move(dummy1.tx.outRef(0), MINI_CORP_PUBKEY).let {
it.signWith(MEGA_CORP_KEY)
it.signWith(DUMMY_NOTARY_KEY)
it.toSignedTransaction()
}
a.services.recordTransactions(dummy1, dummy2)return Pair(dummy1, dummy2)
}
We're using the DummyContract
, a simple test smart contract which stores a single number in its states, along with ownership and issuer information. You can issue such states, exit them and re-assign ownership (move them). It doesn't do anything else. This code simply creates a transaction that issues a dummy state (the issuer is MEGA_CORP
, a pre-defined unit test identity), signs it with the test notary and MegaCorp keys and then converts the builder to the final SignedTransaction
. It then does so again, but this time instead of issuing it re-assigns ownership instead. The chain of two transactions is finally committed to node A by sending them directly to the a.services.recordTransaction
method (note that this method doesn't check the transactions are valid).
And that's it: you can explore the documentation for the MockNode API here.
Versioning
Fibers involve persisting object-serialised stack frames to disk. Although we may do some R&D into in-place upgrades in future, for now the upgrade process for protocols is simple: you duplicate the code and rename it so it has a new set of class names. Old versions of the protocol can then drain out of the system whilst new versions are initiated. When enough time has passed that no old versions are still waiting for anything to happen, the previous copy of the code can be deleted.
Whilst kind of ugly, this is a very simple approach that should suffice for now.
Warning
Protocols are not meant to live for months or years, and by implication they are not meant to implement entire deal lifecycles. For instance, implementing the entire life cycle of an interest rate swap as a single protocol - whilst technically possible - would not be a good idea. The platform provides a job scheduler tool that can invoke protocols for this reason (see "event-scheduling
")
Future features
The protocol framework is a key part of the platform and will be extended in major ways in future. Here are some of the features we have planned:
- Automatic session ID management
- Identity based addressing
- Exposing progress trackers to local (inside the firewall) clients using message queues and/or WebSockets
- Exception propagation and management, with a "protocol hospital" tool to manually provide solutions to unavoidable problems (e.g. the other side doesn't know the trade)
- Being able to interact with internal apps and tools via HTTP and similar
- Being able to interact with people, either via some sort of external ticketing system, or email, or a custom UI. For example to implement human transaction authorisations.
- A standard library of protocols that can be easily sub-classed by local developers in order to integrate internal reporting logic, or anything else that might be required as part of a communications lifecycle.