Write Flows
In Corda, flows automate the process of agreeing ledger updates. They are a sequence of steps that tell the node how to achieve a specific ledger update, such as issuing an asset or making a deposit. Nodes communicate using these flows in point-to-point interactions, rather than a global broadcast system. Network participants must specify what information needs to be sent, to which counterparties.
This tutorial guides you through writing the three flows you need in your CorDapp Corda Distributed Application. A Java (or any JVM targeting language) application built using the Corda build toolchain and CorDapp API to solve some problem that is best solved in a decentralized manner. . These are:
CreateAndIssueAppleStampFlow
PackageApplesFlow
RedeemApplesFlow
You will be creating these flows in the workflows/src/main/kotlin/com/r3/developers/apples/workflows
directory in this tutorial.
Learning Objectives
After you have completed this tutorial, you will know how to write and implement flows in a CorDapp.
Before You Start
Before you start writing flows, read more about flows.
Write the CreateAndIssueAppleStampFlow
The CreateAndIssueAppleStampFlow
creates the AppleStamp
and issues it to the customer.
Write the Initiator Flow
The CreateAndIssueAppleStampFlow
action requires interaction between the issuer and the customer. For this reason, you must create an initiator flow and a responder flow.
Implement the CreateAndIssueAppleStampFlow
Class
- Go to
workflows/src/main/kotlin/com/r3/developers/apples
and right-click theworkflows
folder. - Select New > Kotlin Class.
- Create a file called
CreateAndIssueAppleStampFlow
.
Make the CreateAndIssueAppleStampFlow
Startable by REST
Add the ClientStartableFlow
to the CreateAndIssueAppleStampFlow
. This allows the flow to be started by REST.
You must implement this interface if you want to trigger the flow with REST via Corda’s HTTP endpoints. Your code should now look like this:
class CreateAndIssueAppleStampFlow : ClientStartableFlow
Allow CreateAndIssueAppleStampFlow
to Initiate Flows with Peers
- Add the
@InitiatingFlow
annotation toCreateAndIssueAppleStampFlow
. This indicates that this flow is the initiating flow in an initiating and initiated flow pair. - Define a protocol for the initiating flow as a parameter to the annotation. This will also be added to the initiated (responder) flow later. The protocol is a string name of your choice.
Your code should now look similar to this:
@InitiatingFlow(protocol = "create-and-issue-apple-stamp")
class CreateAndIssueAppleStampFlow : ClientStartableFlow
Add the call
Method
- Add the
call
method with anClientRequestBody
argument andString
return type (thatClientStartableFlow
requires to be implemented). - Add the
@Suspendable
annotation.
String
returned from the call
method is the result of the flow which can be requested using one of Corda’s HTTP endpoints.
For more information, see Corda REST API documentation.Your code should now look like this:
@InitiatingFlow(protocol = "create-and-issue-apple-stamp")
class CreateAndIssueAppleStampFlow : ClientStartableFlow {
@Suspendable
override fun call(requestBody: ClientRequestBody): String {
}
}
Inject the Required Services into CreateAndIssueAppleStampFlow
CreateAndIssueAppleStampFlow
requires a number of services to be injected so that they can be used by the flow.
Add the following code to inject the services required by this flow. This code adds properties to the flow and should not be added inside the call
method:
@InitiatingFlow(protocol = "create-and-issue-apple-stamp")
class CreateAndIssueAppleStampFlow : ClientStartableFlow {
@CordaInject
lateinit var flowMessaging: FlowMessaging
@CordaInject
lateinit var jsonMarshallingService: JsonMarshallingService
@CordaInject
lateinit var memberLookup: MemberLookup
@CordaInject
lateinit var notaryLookup: NotaryLookup
@CordaInject
lateinit var utxoLedgerService: UtxoLedgerService
@Suspendable
override fun call(requestBody: ClientRequestBody): String {
}
}
Extract the CreateAndIssueAppleStampFlow
’s Request Parameters
ClientRequestBody
contains the flow’s request parameters passed in via HTTP. To extract the data from the ClientRequestBody, you should create a class that represents the data.
Create
CreateAndIssueAppleStampRequest
as a private data class within your flow class, with the following properties:stampDescription
- AString
description for theAppleStamp
.holder
- AMemberX500Name
for the participant who is issued anAppleStamp
.
This is how it should look like:
data class CreateAndIssueAppleStampRequest( val stampDescription: String, val holder: MemberX500Name)
To extract the request data into a
CreateAndIssueAppleStampRequest
, addClientRequestBody.getRequestBodyAs
toCreateAndIssueAppleStampFlow
.Add variables for
stampDescription
andholderName
and extract them from theCreateAndIssueAppleStampRequest
instance.Your code should now look like this:
@InitiatingFlow(protocol = "create-and-issue-apple-stamp")
class CreateAndIssueAppleStampFlow : ClientStartableFlow {
@CordaInject
lateinit var flowMessaging: FlowMessaging
@CordaInject
lateinit var jsonMarshallingService: JsonMarshallingService
@CordaInject
lateinit var memberLookup: MemberLookup
@CordaInject
lateinit var notaryLookup: NotaryLookup
@CordaInject
lateinit var utxoLedgerService: UtxoLedgerService
private data class CreateAndIssueAppleStampRequest(
val stampDescription: String,
val holder: MemberX500Name
)
@Suspendable
override fun call(requestBody: ClientRequestBody): String {
val request = requestBody.getRequestBodyAs(
jsonMarshallingService,
CreateAndIssueAppleStampRequest::class.java)
val stampDescription = request.stampDescription
val holderName = request.holder
}
}
Obtain a Reference for the Notary
Any flows using the UTXO Unspent Transaction Output. The unspent output of a cryptocurrency transaction, representing the amount of digital currency that has not been spent and is available for use in future transactions. ledger require a notary service to track the states An immutable object representing a fact known by one or more participants at a specific point in time. You can use states to represent any type of data, and any kind of fact. created and consumed by transactions.
Add the following code to retrieve a notary:
// Retrieve the notaries public key (this will change)
val notaryInfo = notaryLookup.notaryServices.single()
first()
instead of single()
because it will result in non-deterministic selection of
a notary service when there are multiple notary services on the network.Lookup the Issuer and Holder of the New AppleStamp
State
- Use
memberLookup.myInfo
to lookup the current participant (who is executing the flow). - Extract the
name
andledgerKey
s (take the first one) of the participant and store the result in aParty
. - Repeat the same process for the holder of the new
AppleStamp
state using theholderName
from the flow’s input parameters. - Verify that the participant with the passed in
holderName
exists in the network.
Your code should now contain the following lines:
val issuer = memberLookup.myInfo().ledgerKeys.first()
val holder = memberLookup.lookup(holderName)
?.let { it.ledgerKeys.first() }
?: throw IllegalArgumentException("The holder $holderName does not exist within the network")
Build the Output AppleStamp
State
In flows with inputs, you use those inputs to determine the outputs a flow will have. Since this flow is creating and issuing the AppleStamp
, there are no inputs to utilize.
Build the output newStamp
using the parameters from the AppleStamp
state:
id
stampDescription
issuer
holder
UniqueIdentifier
Encapsulate the Output and Notary into a Transaction
Use an UtxoTransactionBuilder
to encapsulate everything into a transaction.
- Use
UtxoLedgerService.createTransactionBuilder
to create aUtxoTransactionBuilder
. - Use
setNotary
to set the name of the transaction’s notary. - Use
addOutputState
to add thenewStamp
(the createdAppleStamp
). - Use
addCommand
to add theIssue
command of theAppleStampContract
. - Use
addSignatories
to add the list of required signatories of the transaction. This should include theissuer
and theholder
. - Use
setTimeWindowUntil
to set a time window for the transaction in which the transaction is valid. In Corda 5, all transactions using the UTXO ledger must specify an upper bound time window in this way. - Use
toSignedTransaction
to sign the transaction.
This is what your transaction creation code should look like now:
val newStamp = AppleStamp(
id = UUID.randomUUID(),
stampDesc = stampDescription,
issuer = issuer,
holder = holder,
participants = listOf(issuer, holder)
)
val transaction = utxoLedgerService.createTransactionBuilder()
.setNotary(notaryInfo.name)
.addOutputState(newStamp)
.addCommand(AppleCommands.Issue())
.setTimeWindowUntil(Instant.now().plus(1, ChronoUnit.DAYS))
.addSignatories(listOf(issuer, holder))
.toSignedTransaction()
Finalize the Transaction
Finalizing a transaction does the following:
- Sends it to counterparties to:
- verify the transaction’s contracts.
- validate the transaction’s contents.
- sign the transaction.
- Notarizes the transaction.
- Records the transaction for the current participant.
- Sends the counterparties the finalized transaction signatures.
- The counterparties record the transaction.
To finalize a transaction:
- Start a
FlowSession
with theholder
usingFlowMessaging.initiateFlow
:val session = flowMessaging.initiateFlow(holderName)
- Call
UtxoLedgerService.finalize
and pass the transaction and session in. Include atry/catch
block around the call:try { // Send the transaction and state to the counterparty and let them sign it // Then notarise and record the transaction in both parties' vaults. utxoLedgerService.finalize(transaction, listOf(session)) } catch (e: Exception) { }
Return a Result from the Flow
The transaction will now be successfully finalized and the end of the flow has been reached.
The flow must return a String
representation of the result of the flow.
Extract and return the id
of the newStamp
created earlier (this will be useful in later flows) and return it from the flow. A String
should also be returned to represent failures occurring inside the finalize call.
This should look like:
return try {
// Send the transaction and state to the counterparty and let them sign it
// Then notarise and record the transaction in both parties' vaults.
utxoLedgerService.finalize(transaction, listOf(session))
newStamp.id.toString()
} catch (e: Exception) {
"Flow failed, message: ${e.message}"
}
Write the Responder Flow
As noted previously, the initiator flow needs a corresponding responder flow. The counterparty runs the responder flow.
Implement the CreateAndIssueAppleStampResponderFlow
Class
- Go to
workflows/src/main/kotlin/com/r3/developers/apples
and right-click theworkflows
folder. - Select New > Kotlin Class.
- Create a file called
CreateAndIssueAppleStampResponderFlow
. - Make
CreateAndIssueAppleStampResponderFlow
implementResponderFlow
. This allows the flow to be initiated when an initiating flow starts a session. You must implement this interface if you want a flow to respond to initiation requests with an initiating flow. - Add the
@InitiatiedBy
annotation toCreateAndIssueAppleStampResponderFlow
.
This indicates that this flow is the initiated flow in an initiating and initiated flow pair. A protocol
must be
defined that both the initiating and initiated flow reference. You should use the same protocol name that you used
when creating the initiator flow earlier.
Your code should now look as follows:
@InitiatedBy(protocol = "create-and-issue-apple-stamp")
class CreateAndIssueAppleStampResponderFlow : ResponderFlow
Add the call
Method
- Add the
call
method with aFlowSession
argument (thatResponderFlow
requires implemented). - Add the
@Suspendable
annotation.
Your code should now look like this:
@InitiatedBy(protocol = "create-and-issue-apple-stamp")
class CreateAndIssueAppleStampResponderFlow : ResponderFlow {
@Suspendable
override fun call(session: FlowSession) {
}
}
Inject the Required Services into CreateAndIssueAppleStampResponderFlow
Inject the UtxoLedgerService into CreateAndIssueAppleStampResponderFlow
.
Your code should now look like this:
@InitiatedBy(protocol = "create-and-issue-apple-stamp")
class CreateAndIssueAppleStampResponderFlow : ResponderFlow {
@CordaInject
lateinit var utxoLedgerService: UtxoLedgerService
@Suspendable
override fun call(session: FlowSession) {
}
}
Finalize the Transaction in CreateAndIssueAppleStampResponderFlow
Call UtxoLedgerService.receiveFinality
and pass in the FlowSession
from call
’s arguments to finalize a transaction. This is the responding side which matches the UtxoLedgerService.finalize
called by CreateAndIssueAppleStampResponderFlow
.
receiveFinality
requires a callback to be defined that validates the received transaction, you can leave this empty for now.
Check Your Work
You have now written both CreateAndIssueAppleStampFlow
and CreateAndIssueAppleStampResponderFlow
. You code should look like this:
CreateAndIssueAppleStampFlow
package com.r3.developers.apples.workflows
import com.r3.developers.apples.contracts.AppleCommands
import com.r3.developers.apples.states.AppleStamp
import net.corda.v5.application.flows.ClientRequestBody
import net.corda.v5.application.flows.ClientStartableFlow
import net.corda.v5.application.flows.CordaInject
import net.corda.v5.application.flows.InitiatingFlow
import net.corda.v5.application.marshalling.JsonMarshallingService
import net.corda.v5.application.membership.MemberLookup
import net.corda.v5.application.messaging.FlowMessaging
import net.corda.v5.base.annotations.Suspendable
import net.corda.v5.base.types.MemberX500Name
import net.corda.v5.ledger.common.NotaryLookup
import net.corda.v5.ledger.utxo.UtxoLedgerService
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.*
@InitiatingFlow(protocol = "create-and-issue-apple-stamp")
class CreateAndIssueAppleStampFlow : ClientStartableFlow {
@CordaInject
lateinit var flowMessaging: FlowMessaging
@CordaInject
lateinit var jsonMarshallingService: JsonMarshallingService
@CordaInject
lateinit var memberLookup: MemberLookup
@CordaInject
lateinit var notaryLookup: NotaryLookup
@CordaInject
lateinit var utxoLedgerService: UtxoLedgerService
private data class CreateAndIssueAppleStampRequest(
val stampDescription: String,
val holder: MemberX500Name
)
@Suspendable
override fun call(requestBody: ClientRequestBody): String {
val request = requestBody.getRequestBodyAs(
jsonMarshallingService,
CreateAndIssueAppleStampRequest::class.java)
val stampDescription = request.stampDescription
val holderName = request.holder
val notaryInfo = notaryLookup.notaryServices.single()
val issuer = memberLookup.myInfo().ledgerKeys.first()
val holder = memberLookup.lookup(holderName)
?.let { it.ledgerKeys.first() }
?: throw IllegalArgumentException("The holder $holderName does not exist within the network")
// Building the output AppleStamp state
val newStamp = AppleStamp(
id = UUID.randomUUID(),
stampDesc = stampDescription,
issuer = issuer,
holder = holder,
participants = listOf(issuer, holder)
)
val transaction = utxoLedgerService.createTransactionBuilder()
.setNotary(notaryInfo.name)
.addOutputState(newStamp)
.addCommand(AppleCommands.Issue())
.setTimeWindowUntil(Instant.now().plus(1, ChronoUnit.DAYS))
.addSignatories(listOf(issuer, holder))
.toSignedTransaction()
val session = flowMessaging.initiateFlow(holderName)
return try {
// Send the transaction and state to the counterparty and let them sign it
// Then notarise and record the transaction in both parties' vaults.
utxoLedgerService.finalize(transaction, listOf(session))
newStamp.id.toString()
} catch (e: Exception) {
"Flow failed, message: ${e.message}"
}
}
}
CreateAndIssueAppleStampResponderFlow
package com.r3.developers.apples.workflows
import net.corda.v5.application.flows.CordaInject
import net.corda.v5.application.flows.InitiatedBy
import net.corda.v5.application.flows.ResponderFlow
import net.corda.v5.application.messaging.FlowSession
import net.corda.v5.base.annotations.Suspendable
import net.corda.v5.ledger.utxo.UtxoLedgerService
@InitiatedBy(protocol = "create-and-issue-apple-stamp")
class CreateAndIssueAppleStampResponderFlow : ResponderFlow {
@CordaInject
lateinit var utxoLedgerService: UtxoLedgerService
@Suspendable
override fun call(session: FlowSession) {
// Receive, verify, validate, sign and record the transaction sent from the initiator
utxoLedgerService.receiveFinality(session) { transaction ->
/*
* [receiveFinality] will automatically verify the transaction and its signatures before signing it.
* However, just because a transaction is contractually valid doesn't mean we necessarily want to sign.
* What if we don't want to deal with the counterparty in question, or the value is too high,
* or we're not happy with the transaction's structure? [UtxoTransactionValidator] (the lambda created
* here) allows us to define the additional checks. If any of these conditions are not met,
* we will not sign the transaction - even if the transaction and its signatures are contractually valid.
*/
}
}
}
Write the PackageApplesFlow
and RedeemApplesFlow
Now that you have written the CreateAndIssueAppleStampFlow
, try writing the PackageApplesFlow
and RedeemApplesFlow
on your own.
Write the PackageApplesFlow
The PackageApples
flow is simpler than the CreateAndIssueAppleStamp
flow in that it only involves one party.
This flow represents Farmer Bob preparing the apples for Dave to collect.
Since the flow only involves one party (Farmer Bob), there is no need to initiate any sessions and therefore neither @InitiatingFlow
or @InitiatedBy
are required.
Include these variables in the flow:
appleDescription
- any relevant information, such as the type of apple. Use typeString
.weight
- the weight of the apples. Use typeInt
.
Check Your Work
After you’ve written the PackageApplesFlow
, your code should look like this:
PackageApplesFlow
package com.r3.developers.apples.workflows
import com.r3.developers.apples.contracts.AppleCommands
import com.r3.developers.apples.states.BasketOfApples
import net.corda.v5.application.flows.CordaInject
import net.corda.v5.application.flows.ClientRequestBody
import net.corda.v5.application.flows.ClientStartableFlow
import net.corda.v5.application.marshalling.JsonMarshallingService
import net.corda.v5.application.membership.MemberLookup
import net.corda.v5.base.annotations.Suspendable
import net.corda.v5.ledger.common.NotaryLookup
import net.corda.v5.ledger.utxo.UtxoLedgerService
import java.time.Instant
import java.time.temporal.ChronoUnit
class PackageApplesFlow : ClientStartableFlow {
private data class PackApplesRequest(val appleDescription: String, val weight: Int)
@CordaInject
lateinit var jsonMarshallingService: JsonMarshallingService
@CordaInject
lateinit var memberLookup: MemberLookup
@CordaInject
lateinit var notaryLookup: NotaryLookup
@CordaInject
lateinit var utxoLedgerService: UtxoLedgerService
@Suspendable
override fun call(requestBody: ClientRequestBody): String {
val request = requestBody.getRequestBodyAs(jsonMarshallingService, PackApplesRequest::class.java)
val appleDescription = request.appleDescription
val weight = request.weight
val notary = notaryLookup.notaryServices.single()
val myKey = memberLookup.myInfo().ledgerKeys.first()
// Building the output BasketOfApples state
val basket = BasketOfApples(
description = appleDescription,
farm = myKey,
owner = myKey,
weight = weight,
participants = listOf(myKey)
)
// Create the transaction
val transaction = utxoLedgerService.createTransactionBuilder()
.setNotary(notary.name)
.addOutputState(basket)
.addCommand(AppleCommands.PackBasket())
.setTimeWindowUntil(Instant.now().plus(1, ChronoUnit.DAYS))
.addSignatories(listOf(myKey))
.toSignedTransaction()
return try {
// Record the transaction, no sessions are passed in as the transaction is only being
// recorded locally
utxoLedgerService.finalize(transaction, emptyList()).toString()
} catch (e: Exception) {
"Flow failed, message: ${e.message}"
}
}
}
Write the RedeemApplesFlow
The RedeemApples
flow involves two parties: Farmer Bob and Dave. When this flow is called, Dave redeems his
AppleStamp
for the BasketOfApples
that Farmer Bob gives him. You will need an initiator and responder flow pair for this flow.
Include these variables in the flow:
buyer
- the customer buying the apples, in this case Dave.stampId
- the unique identifier of theAppleStamp
.
The RedeemApplesFlow
has an additional step that you did not see in the previous two flows. It must query the output
states from the previous two transactions. These output states are the inputs for the RedeemApplesFlow
. Use the
UtxoLedgerService
to find these states.
You should check for the following in the flow:
- The specified
AppleStamp
is unconsumed. - There is an unconsumed
BasketOfApples
where the current owner is the issuer of theAppleStamp
.
Check Your Work
After you’ve written the RedeemApplesFlow
, your code should look like this:
RedeemApplesFlow
package com.r3.developers.apples.workflows
import com.r3.developers.apples.contracts.AppleCommands
import com.r3.developers.apples.states.AppleStamp
import com.r3.developers.apples.states.BasketOfApples
import net.corda.v5.base.types.MemberX500Name
import java.util.*
import net.corda.v5.application.flows.CordaInject
import net.corda.v5.application.flows.InitiatingFlow
import net.corda.v5.application.flows.ClientRequestBody
import net.corda.v5.application.flows.ClientStartableFlow
import net.corda.v5.application.marshalling.JsonMarshallingService
import net.corda.v5.application.membership.MemberLookup
import net.corda.v5.application.messaging.FlowMessaging
import net.corda.v5.base.annotations.Suspendable
import net.corda.v5.ledger.common.NotaryLookup
import net.corda.v5.ledger.utxo.UtxoLedgerService
import java.time.Instant
import java.time.temporal.ChronoUnit
@InitiatingFlow(protocol = "redeem-apples")
class RedeemApplesFlow : ClientStartableFlow {
private data class RedeemApplesRequest(val buyer: MemberX500Name, val stampId: UUID)
@CordaInject
lateinit var flowMessaging: FlowMessaging
@CordaInject
lateinit var jsonMarshallingService: JsonMarshallingService
@CordaInject
lateinit var memberLookup: MemberLookup
@CordaInject
lateinit var notaryLookup: NotaryLookup
@CordaInject
lateinit var utxoLedgerService: UtxoLedgerService
@Suspendable
override fun call(requestBody: ClientRequestBody): String {
val request = requestBody.getRequestBodyAs(jsonMarshallingService, RedeemApplesRequest::class.java)
val buyerName = request.buyer
val stampId = request.stampId
// Retrieve the notaries public key (this will change)
val notaryInfo = notaryLookup.notaryServices.single()
val myKey = memberLookup.myInfo().let { it.ledgerKeys.first() }
val buyer = memberLookup.lookup(buyerName)
?.let { it.ledgerKeys.first() }
?: throw IllegalArgumentException("The buyer does not exist within the network")
val appleStampStateAndRef = utxoLedgerService.findUnconsumedStatesByType(AppleStamp::class.java)
.firstOrNull { stateAndRef -> stateAndRef.state.contractState.id == stampId }
?: throw IllegalArgumentException("No apple stamp matching the stamp id $stampId")
val basketOfApplesStampStateAndRef = utxoLedgerService.findUnconsumedStatesByType(BasketOfApples::class.java)
.firstOrNull { basketStateAndRef -> basketStateAndRef.state.contractState.owner ==
appleStampStateAndRef.state.contractState.issuer }
?: throw IllegalArgumentException("There are no eligible baskets of apples")
val originalBasketOfApples = basketOfApplesStampStateAndRef.state.contractState
val updatedBasket = originalBasketOfApples.changeOwner(buyer)
// Create the transaction
val transaction = utxoLedgerService.createTransactionBuilder()
.setNotary(notaryInfo.name)
.addInputStates(appleStampStateAndRef.ref, basketOfApplesStampStateAndRef.ref)
.addOutputState(updatedBasket)
.addCommand(AppleCommands.Redeem())
.setTimeWindowUntil(Instant.now().plus(1, ChronoUnit.DAYS))
.addSignatories(listOf(myKey, buyer))
.toSignedTransaction()
val session = flowMessaging.initiateFlow(buyerName)
return try {
// Send the transaction and state to the counterparty and let them sign it
// Then notarise and record the transaction in both parties' vaults.
utxoLedgerService.finalize(transaction, listOf(session)).toString()
} catch (e: Exception) {
"Flow failed, message: ${e.message}"
}
}
}
RedeemApplesResponderFlow
package com.r3.developers.apples.workflows
import net.corda.v5.application.flows.CordaInject
import net.corda.v5.application.flows.InitiatedBy
import net.corda.v5.application.flows.ResponderFlow
import net.corda.v5.application.messaging.FlowSession
import net.corda.v5.base.annotations.Suspendable
import net.corda.v5.ledger.utxo.UtxoLedgerService
@InitiatedBy(protocol = "redeem-apples")
class RedeemApplesResponderFlow : ResponderFlow {
@CordaInject
lateinit var utxoLedgerService: UtxoLedgerService
@Suspendable
override fun call(session: FlowSession) {
// Receive, verify, validate, sign and record the transaction sent from the initiator
utxoLedgerService.receiveFinality(session) { transaction ->
/*
* [receiveFinality] will automatically verify the transaction and its signatures before signing it.
* However, just because a transaction is contractually valid doesn't mean we necessarily want to sign.
* What if we don't want to deal with the counterparty in question, or the value is too high,
* or we're not happy with the transaction's structure? [UtxoTransactionValidator] (the lambda created
* here) allows us to define the additional checks. If any of these conditions are not met,
* we will not sign the transaction - even if the transaction and its signatures are contractually valid.
*/
}
}
}
Next Steps
Follow the Test Your CorDapp tutorial to continue on this learning path.
Was this page helpful?
Thanks for your feedback!
Chat with us
Chat with us on our #docs channel on slack. You can also join a lot of other slack channels there and have access to 1-on-1 communication with members of the R3 team and the online community.
Propose documentation improvements directly
Help us to improve the docs by contributing directly. It's simple - just fork this repository and raise a PR of your own - R3's Technical Writers will review it and apply the relevant suggestions.
We're sorry this page wasn't helpful. Let us know how we can make it better!
Chat with us
Chat with us on our #docs channel on slack. You can also join a lot of other slack channels there and have access to 1-on-1 communication with members of the R3 team and the online community.
Create an issue
Create a new GitHub issue in this repository - submit technical feedback, draw attention to a potential documentation bug, or share ideas for improvement and general feedback.
Propose documentation improvements directly
Help us to improve the docs by contributing directly. It's simple - just fork this repository and raise a PR of your own - R3's Technical Writers will review it and apply the relevant suggestions.