From 492005bcfe24f928fa8a281a2c26a3f6c49657c4 Mon Sep 17 00:00:00 2001 From: "rick.parker" Date: Mon, 21 Nov 2016 17:11:51 +0000 Subject: [PATCH] Fix up and improve some docs --- .../core/flows/ResolveTransactionsFlowTest.kt | 4 + docs/source/flow-state-machines.rst | 262 +++--------------- docs/source/flow-testing.rst | 83 ++++++ docs/source/index.rst | 1 + .../net/corda/flows/TwoPartyTradeFlow.kt | 2 + 5 files changed, 128 insertions(+), 224 deletions(-) create mode 100644 docs/source/flow-testing.rst diff --git a/core/src/test/kotlin/net/corda/core/flows/ResolveTransactionsFlowTest.kt b/core/src/test/kotlin/net/corda/core/flows/ResolveTransactionsFlowTest.kt index fc80266d86..eb8c60575e 100644 --- a/core/src/test/kotlin/net/corda/core/flows/ResolveTransactionsFlowTest.kt +++ b/core/src/test/kotlin/net/corda/core/flows/ResolveTransactionsFlowTest.kt @@ -45,6 +45,7 @@ class ResolveTransactionsFlowTest { net.stopNodes() } + // DOCSTART 1 @Test fun `resolve from two hashes`() { val (stx1, stx2) = makeTransactions() @@ -58,6 +59,7 @@ class ResolveTransactionsFlowTest { assertEquals(stx2, b.storage.validatedTransactions.getTransaction(stx2.id)) } } + // DOCEND 1 @Test fun `dependency with an error`() { @@ -145,6 +147,7 @@ class ResolveTransactionsFlowTest { assertNotNull(b.services.storageService.attachments.openAttachment(id)) } + // DOCSTART 2 private fun makeTransactions(signFirstTX: Boolean = true, withAttachment: SecureHash? = null): Pair { // 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 { @@ -165,4 +168,5 @@ class ResolveTransactionsFlowTest { } return Pair(dummy1, dummy2) } + // DOCEND 2 } diff --git a/docs/source/flow-state-machines.rst b/docs/source/flow-state-machines.rst index 3f39a15bd7..1e3c3500d8 100644 --- a/docs/source/flow-state-machines.rst +++ b/docs/source/flow-state-machines.rst @@ -4,10 +4,10 @@ -Flow state machines -=================== +Writing flows +============= -This article explains our experimental approach to modelling financial flows in code. It explains how the +This article explains our approach to modelling financial flows 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 flow which is included in the source. @@ -91,7 +91,7 @@ Our flow has two parties (B and S for buyer and seller) and will proceed as foll it lacks a signature from S authorising movement of the asset. 3. S signs it and hands the now finalised ``SignedTransaction`` back to B. -You can find the implementation of this flow in the file ``finance/src/main/kotlin/net.corda.flows/TwoPartyTradeFlow.kt``. +You can find the implementation of this flow in the file ``finance/src/main/kotlin/net/corda/flows/TwoPartyTradeFlow.kt``. Assuming no malicious termination, they both end the flow being in posession of a valid, signed transaction that represents an atomic asset swap. @@ -120,13 +120,13 @@ each side. data class SellerTradeInfo( val assetForSale: StateAndRef, val price: Amount, - val sellerOwnerKey: PublicKey + val sellerOwnerKey: CompositeKey ) data class SignaturesFromSeller(val sellerSig: DigitalSignature.WithKey, val notarySig: DigitalSignature.LegallyIdentifiable) - open class Seller(val otherSide: Party, + open class Seller(val otherParty: Party, val notaryNode: NodeInfo, val assetToSell: StateAndRef, val price: Amount, @@ -138,7 +138,7 @@ each side. } } - open class Buyer(val otherSide: Party, + open class Buyer(val otherParty: Party, val notary: Party, val acceptablePrice: Amount, val typeToBuy: Class) : FlowLogic() { @@ -154,7 +154,7 @@ simply flow messages or exceptions. The other two represent the buyer and seller Going through the data needed to become a seller, we have: -- ``otherSide: Party`` - the party with which you are trading. +- ``otherParty: Party`` - the party with which you are trading. - ``notaryNode: NodeInfo`` - the entry in the network map for the chosen notary. See ":doc:`consensus`" for more information on notaries. - ``assetToSell: StateAndRef`` - a pointer to the ledger entry that represents the thing being sold. @@ -197,20 +197,23 @@ when messages arrive. It provides the send/receive/sendAndReceive calls that let interaction and it will save/restore serialised versions of the fiber at the right times. Flows can be invoked in several ways. For instance, they can be triggered by scheduled events, -see ":doc:`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. +see ":doc:`event-scheduling`" to learn more about this. Or they can be triggered directly via the Java-level node RPC +APIs from your app code. -You request a flow to be invoked by using the ``ServiceHub.invokeFlowAsync`` method. This takes a +You request a flow to be invoked by using the ``CordaRPCOps.startFlowDynamic`` method. This takes a Java reflection ``Class`` object that describes the flow 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 flow invocations to be requested by untrusted code (e.g. a state that you have been sent), the types that can be passed into the -flow are checked against a whitelist, which can be extended by apps themselves at load time. +flow are checked against a whitelist, which can be extended by apps themselves at load time. There are also a series +of inlined extension functions of the form ``CordaRPCOps.startFlow`` which help with invoking flows in a type +safe manner. -The process of starting a flow 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. +The process of starting a flow returns a ``FlowHandle`` that you can use to either observe +the result, observe its progress and which also contains a permanent identifier for the invoked flow in the form +of the ``StateMachineRunId``. -In a two party flow only one side is to be manually started using ``ServiceHub.invokeFlowAsync``. The other side -has to be registered by its node to respond to the initiating flow via ``ServiceHubInternal.registerFlowInitiator``. +In a two party flow only one side is to be manually started using ``CordaRPCOps.startFlow``. The other side +has to be registered by its node to respond to the initiating flow via ``PluginServiceHub.registerFlowInitiator``. In our example it doesn't matter which flow is the initiator and which is the initiated. For example, if we are to take the seller as the initiator then we would register the buyer as such: @@ -218,8 +221,7 @@ take the seller as the initiator then we would register the buyer as such: .. sourcecode:: kotlin - val services: ServiceHubInternal = TODO() - + val services: PluginServiceHub = TODO() services.registerFlowInitiator(Seller::class) { otherParty -> val notary = services.networkMapCache.notaryNodes[0] val acceptablePrice = TODO() @@ -266,25 +268,21 @@ Let's fill out the ``receiveAndCheckProposedTransaction()`` method. @Suspendable private fun receiveAndCheckProposedTransaction(): SignedTransaction { // Make the first message we'll send to kick off the flow. - val hello = SellerTradeInfo(assetToSell, price, myKeyPair.public) + val myPublicKey = myKeyPair.public.composite + val hello = SellerTradeInfo(assetToSell, price, myPublicKey) val maybeSTX = sendAndReceive(otherSide, hello) maybeSTX.unwrap { // Check that the tx proposed by the buyer is valid. - val missingSigs: Set = 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 + val wtx: WireTransaction = it.verifySignatures(myPublicKey, notaryNode.notaryIdentity.owningKey) logger.trace { "Received partially signed transaction: ${it.id}" } // Download and check all the things that this transaction depends on and verify it is contract-valid, // even though it is missing signatures. - subFlow(ResolveTransactionsFlow(wtx, otherSide)) + subFlow(ResolveTransactionsFlow(wtx, otherParty)) - if (wtx.outputs.map { it.data }.sumCashBy(myKeyPair.public).withoutIssuer() != price) + if (wtx.outputs.map { it.data }.sumCashBy(myPublicKey).withoutIssuer() != price) throw IllegalArgumentException("Transaction is not sending us the right amount of cash") return it @@ -306,7 +304,9 @@ 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 garbage collector. So try to avoid keeping enormous data structures alive unless you really have to. You can + always use private methods to keep the stack uncluttered with temporary variables, or to avoid objects that + Kryo is not able to serialise correctly. 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, @@ -372,18 +372,18 @@ Here's the rest of the code: .. sourcecode:: kotlin - open fun computeOurSignature(partialTX: SignedTransaction) = myKeyPair.signWithECDSA(partialTX.txBits) + open fun calculateOurSignature(partialTX: SignedTransaction) = myKeyPair.signWithECDSA(partialTX.id) @Suspendable private fun sendSignatures(allPartySignedTX: SignedTransaction, ourSignature: DigitalSignature.WithKey, - notarySignature: DigitalSignature.LegallyIdentifiable): SignedTransaction { + notarySignature: DigitalSignature.WithKey): SignedTransaction { val fullySigned = allPartySignedTX + notarySignature logger.trace { "Built finished transaction, sending back to secondary!" } send(otherSide, SignaturesFromSeller(ourSignature, notarySignature)) return fullySigned } -It's all pretty straightforward from now on. Here ``txBits`` is the raw byte array representing the serialised +It's all pretty straightforward from now on. Here ``id`` is the secure hash 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. @@ -405,98 +405,15 @@ Implementing the buyer OK, let's do the same for the buyer side: -.. container:: codeset - - .. sourcecode:: kotlin - - @Suspendable - override fun call(): SignedTransaction { - val tradeRequest = receiveAndValidateTradeRequest() - val (ptx, cashSigningPubKeys) = assembleSharedTX(tradeRequest) - val stx = signWithOurKeys(cashSigningPubKeys, ptx) - - val signatures = swapSignaturesWithSeller(stx) - - logger.trace { "Got signatures from seller, verifying ... " } - - val fullySigned = stx + signatures.sellerSig + signatures.notarySig - fullySigned.verifySignatures() - - logger.trace { "Signatures received are valid. Trade complete! :-)" } - return fullySigned - } - - @Suspendable - private fun receiveAndValidateTradeRequest(): SellerTradeInfo { - // Wait for a trade request to come in from the other side - val maybeTradeRequest = receive(otherParty) - maybeTradeRequest.unwrap { - // What is the seller trying to sell us? - val asset = it.assetForSale.state.data - val assetTypeName = asset.javaClass.name - logger.trace { "Got trade request for a $assetTypeName: ${it.assetForSale}" } - - 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. - subFlow(ResolveTransactionsFlow(setOf(it.assetForSale.ref.txhash), otherSide)) - - return it - } - } - - @Suspendable - private fun swapSignaturesWithSeller(stx: SignedTransaction): SignaturesFromSeller { - progressTracker.currentStep = SWAPPING_SIGNATURES - logger.trace { "Sending partially signed transaction to seller" } - - // TODO: Protect against the seller terminating here and leaving us in the lurch without the final tx. - - return sendAndReceive(otherSide, stx).unwrap { it } - } - - private fun signWithOurKeys(cashSigningPubKeys: List, 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> { - 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() - 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) - ptx.addCommand(command, tradeRequest.assetForSale.state.data.owner) - - // 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() - ptx.setTime(currentTime, 30.seconds) - return Pair(ptx, cashSigningPubKeys) - } +.. literalinclude:: ../../finance/src/main/kotlin/net/corda/flows/TwoPartyTradeFlow.kt + :language: kotlin + :start-after: DOCSTART 1 + :end-before: DOCEND 1 This code is longer but no more complicated. Here are some things to pay attention to: 1. We do some sanity checking on the received message to ensure we're being offered what we expected to be offered. -2. We create a cash spend in the normal way, by using ``Cash().generateSpend``. See the contracts tutorial if this +2. We create a cash spend in the normal way, by using ``VaultService.generateSpend``. See the vault documentation if this part isn't clear. 3. We access the *service hub* when we need it to access things that are transient and may change or be recreated whilst a flow is suspended, things like the wallet or the network map. @@ -559,108 +476,6 @@ 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 flow 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 flows is the ``ResolveTransactionsFlow`` tests. This -flow takes care of downloading and verifying transaction graphs, with all the needed dependencies. We start -with this basic skeleton: - -.. container:: codeset - - .. sourcecode:: kotlin - - class ResolveTransactionsFlowTest { - lateinit var net: MockNetwork - lateinit var a: MockNetwork.MockNode - lateinit var b: MockNetwork.MockNode - lateinit var notary: Party - - @Before - fun setup() { - net = MockNetwork() - val nodes = net.createSomeNodes() - a = nodes.partyNodes[0] - b = nodes.partyNodes[1] - 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: - -.. container:: codeset - - .. sourcecode:: kotlin - - @Test - fun resolveFromTwoHashes() { - val (stx1, stx2) = makeTransactions() - val p = ResolveTransactionsFlow(setOf(stx2.id), a.info.identity) - val future = b.services.startFlow("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 flow, 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 flow and do -some tests on it. We also check the contents of node B's database to see that the flow had the intended effect -on the node's persistent state. - -Here's what ``makeTransactions`` looks like: - -.. container:: codeset - - .. sourcecode:: kotlin - - private fun makeTransactions(): Pair { - // 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) - it.toSignedTransaction(false) - } - 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 ---------- @@ -684,11 +499,10 @@ The flow framework is a key part of the platform and will be extended in major w the features we have planned: * Identity based addressing -* Exposing progress trackers to local (inside the firewall) clients using message queues and/or WebSockets * Exception propagation and management, with a "flow 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 internal apps and tools via RPC * 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 flows 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. + reporting logic, or anything else that might be required as part of a communications lifecycle. \ No newline at end of file diff --git a/docs/source/flow-testing.rst b/docs/source/flow-testing.rst new file mode 100644 index 0000000000..010d83c886 --- /dev/null +++ b/docs/source/flow-testing.rst @@ -0,0 +1,83 @@ +.. highlight:: kotlin +.. raw:: html + + + + +Writing flow tests +================== + +A flow 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 test-utils module. + +A good example to examine for learning how to unit test flows is the ``ResolveTransactionsFlow`` tests. This +flow takes care of downloading and verifying transaction graphs, with all the needed dependencies. We start +with this basic skeleton: + +.. container:: codeset + + .. sourcecode:: kotlin + + class ResolveTransactionsFlowTest { + lateinit var net: MockNetwork + lateinit var a: MockNetwork.MockNode + lateinit var b: MockNetwork.MockNode + lateinit var notary: Party + + @Before + fun setup() { + net = MockNetwork() + val nodes = net.createSomeNodes() + a = nodes.partyNodes[0] + b = nodes.partyNodes[1] + notary = nodes.notaryNode.info.notaryIdentity + 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: + +.. literalinclude:: ../../core/src/test/kotlin/net/corda/core/flows/ResolveTransactionsFlowTest.kt + :language: kotlin + :start-after: DOCSTART 1 + :end-before: DOCEND 1 + +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 flow, 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 flow and do +some tests on it. We also check the contents of node B's database to see that the flow had the intended effect +on the node's persistent state. + +Here's what ``makeTransactions`` looks like: + +.. literalinclude:: ../../core/src/test/kotlin/net/corda/core/flows/ResolveTransactionsFlowTest.kt + :language: kotlin + :start-after: DOCSTART 2 + :end-before: DOCEND 2 + +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) inside a ``databaseTransaction``. All node flows run within a database transaction in the nodes themselves, +but any time we need to use the database directly from a unit test, you need to provide a database transaction as shown +here. + +And that's it: you can explore the documentation for the `MockNode API `_ here. diff --git a/docs/source/index.rst b/docs/source/index.rst index 3a309c8c9e..112b1043ab 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -68,6 +68,7 @@ Read on to learn: tutorial-test-dsl tutorial-clientrpc-api flow-state-machines + flow-testing oracles tutorial-attachments event-scheduling diff --git a/finance/src/main/kotlin/net/corda/flows/TwoPartyTradeFlow.kt b/finance/src/main/kotlin/net/corda/flows/TwoPartyTradeFlow.kt index b14e4cc419..956cb559df 100644 --- a/finance/src/main/kotlin/net/corda/flows/TwoPartyTradeFlow.kt +++ b/finance/src/main/kotlin/net/corda/flows/TwoPartyTradeFlow.kt @@ -168,6 +168,7 @@ object TwoPartyTradeFlow { override val progressTracker = ProgressTracker(RECEIVING, VERIFYING, SIGNING, SWAPPING_SIGNATURES) + // DOCSTART 1 @Suspendable override fun call(): SignedTransaction { val tradeRequest = receiveAndValidateTradeRequest() @@ -257,5 +258,6 @@ object TwoPartyTradeFlow { tx.setTime(currentTime, 30.seconds) return Pair(tx, cashSigningPubKeys) } + // DOCEND 1 } }